# UI Phase 10 — Messaging & notifications — Report (2026-07-19) ## What was built ### Ticket inbox → a real inbox (`TicketInboxScreen`, `TicketListCard`) - **Status filter chips** (همه/باز/بسته) + **load-more paging**: `useMyTickets({ status, pageSize, page: 1 })` with a growing `pageSize` (the same "growing limit" pattern `NotificationCenter` already used), reset to `TICKETS_PAGE_SIZE` on a filter change. `keepPreviousData` (already set on the hook) means a chip switch never flashes. - **`TicketListCard` redesigned around activity:** bold subject + unread pill (unchanged behavior — it turns out `unreadCount`/`lastMessageAt` are **already real** on the wire, REQ-028 having been delivered since the audit was written — see "Stale audit note" below) **plus** a one-line last-message preview prefixed with the translated author label (e.g. "پرستار: ساعت ۵ عصر هماهنگ شد") and a **relative** last-activity time (`formatRelativeTime`, decaying to Shamsi past 7 days) instead of the old absolute `formatShamsiDateTime`. The preview/author-role fields don't exist on the wire summary yet — filed as **REQ-059** — so `TicketSummary.lastMessagePreview`/`lastAuthorRole` are optional and the card renders subject + status + time only when they're absent (no empty slot, never a fake value). - **Support-entry unread badge in the chrome:** a new `useSupportUnreadTotal()` read behind the `services/tickets` seam (mock sums its tickets' `unreadCount`; real returns `null` — REQ-059). Mounted as a `Badge` on the customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a new `LinkToPage.badgeCount` prop, rendered by `SideBarNavItem` — a minimal, intentional touch to the phase-2-owned `layout/` files, noted per the ownership rules). - **Emergency affordance right-sized:** the permanent alarm-red `EmergencyBanner` is gone from both ticket inboxes, replaced by a new **`EmergencyPlaybookRow`** — a compact, neutral, collapsed-by-default row that expands to rewritten copy (never instructs calling a number the inbox can't show) + the "open a ticket" action. `EmergencyBanner` itself is untouched and stays exactly where it belongs: the nurse's post-confirmation booking detail (`BookingSupportEntry`), the only surface with a real `tel:` contact. - Unified the messaging surface width (inbox now 720px, matching the thread — was 640/720 mismatched). ### Live thread (`useTicket`/`useTicketThread`, `useThreadScroll`, `TicketMessageList`) - **Polls while mounted:** `useTicket`/`useTicketThread` both got `refetchInterval: TICKET_THREAD_REFETCH_INTERVAL` (15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the thread unmounts. No `refetchIntervalInBackground`. The 60s notification-count poll stays the only always-on poll (§5 posture unchanged). - **`useThreadScroll`** — a new, reusable scroll-orchestration hook (`components/messaging/useThreadScroll.ts`, exported from the barrel): opens the thread scrolled to the **newest** message (instant, on first load), always scrolls to a newly-sent (mine) message, and auto-scrolls on a received message only when the viewer is already within ~120px of the bottom — otherwise shows a floating «پیام جدید ↓» pill. Uses an `IntersectionObserver` on a bottom sentinel rather than tracking a specific scroll container, so it works whether the *page* or an inner box scrolls (guarded for environments without `IntersectionObserver`, e.g. jsdom). Deliberately exported as a consumable for **phase 11's admin thread**, which has the inverse bug (a 520px scrollbox that also opens at the top). - **Chat typography** (`TicketMessageList`): messages are grouped into date-separator / system-event / consecutive-same-author blocks (`buildBlocks`); centered Shamsi separators read امروز/دیروز/an absolute date (`formatDaySeparator`, new in `utils/date.ts`, string-equality comparison — never raw ms diffs, so it's calendar-exact); consecutive messages from the same author collapse under one author label; bubbles show **hh:mm only** (`formatShamsiTime`, new in `utils/date.ts`); `authorRole === 'system'` renders as a centered neutral chip, never a bubble. - **Fixed the bidi timestamp bug:** removed the forced `direction: 'ltr'` from the bubble's time label (`MessageBubble.tsx`) — with hh:mm-only Persian-digit stamps, no forced direction is needed; the Latin `referenceCode` keeps its forced LTR everywhere it's shown. - **Sticky composer separation:** the composer's sticky box now has a real `borderTop` (divider token) instead of a bare `bgcolor` block, so bubbles no longer scroll flush into the input. ### Composer (`MessageComposer`, `TicketConversationPanel`, `usePostMessage`, `useDiscardFailedMessage`) - **Enter semantics per input modality:** `useMediaQuery('(pointer: coarse)')` branches the composer's `onKeyDown` — desktop (fine pointer): Enter sends, Shift+Enter newlines (unchanged); touch (coarse pointer): Enter always inserts a newline, the send button is the only send path. - **Retry-in-place on failure** — the invariant-preserving rewrite: - `usePostMessage`'s `onMutate` is now **idempotent on `clientMessageId`**: a fresh send appends a pending bubble; a retry (the same id already in the cache as `sendStatus: 'failed'`) flips it back to `sending` in place instead of appending a duplicate. - `onError` **no longer rolls the thread back** — it flips the bubble to `sendStatus: 'failed'` and leaves it in place with its typed body. - `MessageBubble`'s failed state renders `role="alert"` (screen readers are told), the error-accented bubble, a «تلاش مجدد» action (re-mutates the same `clientMessageId`), and a discard (delete) action. - A new `useDiscardFailedMessage()` (not a mutation — nothing was ever sent) removes the failed message from the cached thread; `TicketConversationPanel` wires its `onDiscard` to also restore the message's body into the composer's draft — **a failure never loses typed text**, and `clientMessageId` reconciliation still never double-renders. Both invariants are proven by the updated `MessageBubble.test.tsx`. - **`TicketConversationPanel`** (new) is the component that makes this possible without leaking state across tickets: it owns the one `usePostMessage`/draft instance both `TicketMessageList`'s retry/discard and the composer's own send now share, and it's mounted `key={ticketId}` from `TicketThreadScreen` — so navigating thread→thread (the App Router reuses the `[id]` subtree) remounts the whole panel, exactly preserving the pre-existing "drafts/in-flight state never cross tickets" invariant (previously enforced by keying only `MessageComposer`). - **Attachment affordance — designed, gated:** the composer has an attachment icon button + the capability flag `TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — it renders nothing until the seam lands (REQ-060). No dead button ships. - **RTL send-icon mirror:** `send` added to `AppIcon`'s `DIRECTIONAL_ICONS` set (config.ts) — phase 0's auto-mirroring mechanism already existed, this was a one-line registry addition. ### Emergency affordance (recap) - Full alarm-red `EmergencyBanner` + `tel:` stays **only** on the nurse's post-confirmation booking read (untouched). Both ticket inboxes now show the compact `EmergencyPlaybookRow` instead. ### Notification center (`NotificationCenter`, `NotificationRow`, `notificationIcon.ts`) - **Day grouping:** امروز/دیروز/این‌هفته, then a Shamsi date header for anything older — grouped **in list order** (the server's unread-first-then-newest ordering is preserved per §5's "keep the mark-read UX as is"; a bucket can recur if an older unread item sits above newer read ones — a deliberate, documented trade-off rather than silently reordering the list). - **Relative timestamps** (`formatRelativeTime`, decaying to Shamsi) replace the absolute `formatShamsiDateTime` on every row. - **Per-kind tinted icon container:** a new `notificationTint(kind)` helper (`notificationIcon.ts`) — booking teal, payout success-green, ticket/support terracotta (the deliberate "a human from Balinyaar" accent), refund info, nurse-trust the dedicated `--bal-trust` token, `none` fully neutral. All existing `--bal-*-soft` tokens — no new tokens needed. - **Non-navigable rows render non-interactive:** `NotificationRow` now takes a `navigable` prop (the caller computes it from `notificationDeepLink(...) != null`). Navigable rows are a real `ButtonBase` with a trailing chevron (`forward` icon) and a visible `:focus-visible` ring; non-navigable rows render as a plain, static surface (no ripple, no pointer cursor, no chevron) that still marks the notification read on click/Enter/Space (kept keyboard-operable via `role="button" tabIndex={0}`, even though visually inert). - **Kept unchanged:** mark-read-on-open (optimistic), mark-all-read, load-more. ### Bell behavior (`NotificationBell`, `NotificationBellPopover`, `NotificationBellView`) - **Desktop popover on the nurse shell:** `NotificationBell` now branches on `role === 'nurse' && isDesktop` (`useMediaQuery(theme.breakpoints.up('md'))`) — opens a new `NotificationBellPopover` (5 most recent, mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches **on open** (`useNotifications(5, { enabled: open })` — `useNotifications` gained the optional `{enabled}` param), reusing the same `notificationKeys` cache the full center reads, never on the polled count's tick. Everywhere else (customer — always, mobile-first by design; nurse mobile) keeps direct navigation. - **Isolation preserved:** only `NotificationBell` (the container) calls `useUnreadCount()`; the popover's content and the shell around it never do. `NotificationBellView` now `forwardRef`s to its `IconButton` so the container can anchor the popover to it (via `anchorEl` state set through the ref callback — not a read-during-render, which the `react-hooks/refs` lint rule correctly flagged and blocked). - Badge pulse-on-increment (the phase's "optional polish") was **not** built this pass — noted under Follow-ups. ### Admin notifications — the phase-11 handshake (§3.7) - `admin/notifications/page.tsx` stays a `PlaceholderScreen` (untouched — building it is phase 11's call), but its **only entry point was the TopBar bell** (`AdminLayout` had no separate "notifications" nav item — the bell click was the dead link). Removed `` from `AdminLayout` entirely — a documented, intentional removal (comment in `AdminLayout.tsx`), not a silent regression. Re-add it (reusing `NotificationBellPopover`, already exported from the barrel) once phase 11 ships a real admin feed. - `useThreadScroll` and `NotificationBellPopover` are both exported from their barrels specifically for phase 11 to consume for the admin thread's inverse scroll bug and (eventually) an admin bell popover. ## Stale-audit note (recorded for the record, not re-litigated) The phase doc's problem list (drawn from `audit/messaging-notifications.md`) describes `unreadCount`/ `lastMessageAt` as "dead on the real API, REQ-028 gap, mock-only." That was true when the audit was written, but by the time this phase ran, **REQ-028 had already been delivered** — `USE_TICKETS_MOCK` and `USE_NOTIFICATIONS_MOCK` are both `false` in this codebase, and `ticketsClientApi.mapSummary` genuinely maps `unreadCount`/`lastMessageAt` off the wire (confirmed by reading the current code, not the audit). This phase's actual REQ-039/040-equivalent gaps turned out to be narrower than described — `lastMessagePreview`/ `lastAuthorRole`/the unread-total badge (filed as **REQ-059**, since REQ-039/040 were already taken by ui-phase-3/4 by the time this phase ran — always check the tracker's live highest number, not a phase doc's indicative one) and the attachment affordance (**REQ-060**). This mirrors the same "audit predates a same-day fix" pattern the ui-phase-9 stale-audit memory already flagged — worth a standing reminder for future phases to verify audit claims against current code before treating them as gaps to close. ## What is now testable (and exactly how) 1. `/fa` customer → پشتیبانی (`/support/tickets`): status chips filter the list without a flash; if there are >20 tickets in a scenario, «نمایش بیشتر» loads more; the compact «موارد اضطراری» row is collapsed by default and expands on tap; each card shows a bold subject, unread pill (mock scenario), one-line preview + relative time. 2. Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message, date-separated and author-grouped, `hh:mm` stamps, a centered system event line if present. Scroll up, wait ~15s for the poll (or use the mock's reply path) → a reply appears and, if scrolled up, shows «پیام جدید ↓»; tap it → scrolls to newest. 3. Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows `hh:mm`. Post the dev sentinel `/fail` as the body → the bubble turns error-accented with «تلاش مجدد» + a delete icon, `role="alert"` (verify with a screen reader or the accessibility tree); tap «تلاش مجدد» → succeeds (a real send) with **exactly one** bubble, never a duplicate. Try again and tap the delete icon instead → the bubble disappears and its text reappears in the composer. 4. On a touch-emulated viewport (devtools device toolbar), Enter in the composer inserts a newline; on a desktop viewport, Enter sends. `/fa`: the send arrow points out of the field (toward the inline-end). 5. `/nurse/support/tickets`: the sidebar's «پشتیبانی» item shows a small unread badge when the mock's `getUnreadTotal()` is positive. 6. Notification center (`/fa` + `/en`, light + dark): امروز/دیروز/این‌هفته day groups, «۵ دقیقه پیش» decaying to a Shamsi date past 7 days, tinted per-kind icon circles (teal/success/terracotta/info/trust); a non-deep-linking row (seeded via the mock's unknown-type row) doesn't ripple and has no chevron, but a click still marks it read; navigable rows show a trailing chevron and a visible focus ring on Tab. 7. Nurse desktop (`≥md` viewport): the bell opens a popover (5 recent + mark-all + «مشاهده همه»); resize to mobile → the same bell now navigates to the full center. Customer shell: the bell always navigates, any viewport. Devtools Network tab: only the unread-count endpoint polls (60s) plus the thread endpoint while a thread screen is mounted (15s) — nothing else ticks. 8. `/admin`: no notification bell in the TopBar at all (removed, not a dead link). ## What is mocked / waiting on a real service - `TicketsApi` (`services/tickets/apis/mockApi.ts`) — mock stays available for the two REQ-059 fields (`lastMessagePreview`/`lastAuthorRole`) + `getUnreadTotal()`; the real client maps the first two to `null` and the third to a constant `null`. See `mocks-registry.md`'s updated `TicketsApi` row. - The composer's attachment affordance is fully designed but inert (`TICKETS_ATTACHMENTS_ENABLED = false`) until REQ-060 ships an object-storage-backed attachment endpoint. ## Contracts - No contract consumed this phase (no new backend endpoint existed to consume — REQ-028 was already delivered before this phase started). - Requests filed in `dev/shared-working-context/frontend/requests/for-backend.md`: - **REQ-059** — Ticket inbox enrichment: `lastMessagePreview` + `lastAuthorRole` on `TicketSummaryDto` (extends REQ-028) + a cheap unread-total read for the chrome badge. - **REQ-060** — Ticket message photo attachments (object-storage-backed), gating the composer's already-built affordance. ## Docs updated - `client/CLAUDE.md` — the `messaging/` and `notifications/` Project Structure entries rewritten for the new components/hooks (`TicketConversationPanel`, `useThreadScroll`, `EmergencyPlaybookRow`, `NotificationBellPopover`, `notificationTint`); the `services/tickets`/`services/notifications` domain bullets updated (retry-in-place semantics, thread polling, `useSupportUnreadTotal`, `useDiscardFailedMessage`, REQ-028-delivered vs REQ-059-gap distinction); the `'tickets'`/`'notifications'` i18n namespace descriptions updated with the new keys; `CustomerLayout.tsx`/`NurseLayout.tsx`/ `AdminLayout.tsx`/`SideBarNavItem.tsx` structure-tree lines updated for the badge + admin-bell-removal changes. - `dev/shared-working-context/reports/mocks-registry.md` — the `TicketsApi` row corrected (it was stale: marked "deliver REQ-028, then set flag false" when the flag was already `false`) and updated with the REQ-059/060 gaps. - `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-059/060 appended (see Contracts). ## Foundation extensions (minimal, per the ownership rules) - **`layout/` (phase 2-owned):** `LinkToPage` gained an optional `badgeCount` field; `SideBarNavItem`/ `SideBarNavList` render it as a small `Badge`; `CustomerLayout`/`NurseLayout` wire it to `useSupportUnreadTotal()`; `AdminLayout` lost its notification bell (§3.7 handshake, documented above). - **`utils/date.ts`:** added `formatShamsiTime` (hh:mm-only) and `formatDaySeparator` (امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs). - **`AppIcon/config.ts`:** `send` added to `DIRECTIONAL_ICONS` (one-line registry addition, phase 0's existing mechanism). - **`services/notifications`:** `useNotifications` gained an optional `{ enabled }` param (for the popover's fetch-on-open); no breaking change to existing callers. ## Follow-ups for later phases - **Phase 11 (admin console):** build the real admin notification feed (or keep it hidden) — the bell + popover pattern (`NotificationBellPopover`) and the scroll hook (`useThreadScroll`) are ready to reuse for the admin thread's inverse scrollbox bug. - **REQ-059**/**REQ-060** as filed. - **Badge pulse-on-increment** (§3.6's "optional polish") and a document-title/favicon unread hint were not built this pass — low-risk, low-priority visual polish, safe to pick up whenever. - **Ticket lifecycle + trust affordances** (user-side close/reopen, an expected-response-time promise, a "support has seen this" state) — called out as an opportunity in the audit but out of this phase's explicit scope (§3 didn't ask for it); worth a future phase if support-response-time becomes a measured product metric.