# 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 `<NotificationBell role="admin" />` 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.
