18 KiB
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 growingpageSize(the same "growing limit" patternNotificationCenteralready used), reset toTICKETS_PAGE_SIZEon a filter change.keepPreviousData(already set on the hook) means a chip switch never flashes. TicketListCardredesigned around activity: bold subject + unread pill (unchanged behavior — it turns outunreadCount/lastMessageAtare 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 absoluteformatShamsiDateTime. The preview/author-role fields don't exist on the wire summary yet — filed as REQ-059 — soTicketSummary.lastMessagePreview/lastAuthorRoleare 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 theservices/ticketsseam (mock sums its tickets'unreadCount; real returnsnull— REQ-059). Mounted as aBadgeon the customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a newLinkToPage.badgeCountprop, rendered bySideBarNavItem— a minimal, intentional touch to the phase-2-ownedlayout/files, noted per the ownership rules). - Emergency affordance right-sized: the permanent alarm-red
EmergencyBanneris gone from both ticket inboxes, replaced by a newEmergencyPlaybookRow— 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.EmergencyBanneritself is untouched and stays exactly where it belongs: the nurse's post-confirmation booking detail (BookingSupportEntry), the only surface with a realtel: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/useTicketThreadboth gotrefetchInterval: TICKET_THREAD_REFETCH_INTERVAL(15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the thread unmounts. NorefetchIntervalInBackground. 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 anIntersectionObserveron a bottom sentinel rather than tracking a specific scroll container, so it works whether the page or an inner box scrolls (guarded for environments withoutIntersectionObserver, 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 inutils/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 inutils/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 LatinreferenceCodekeeps 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 barebgcolorblock, 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'sonKeyDown— 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'sonMutateis now idempotent onclientMessageId: a fresh send appends a pending bubble; a retry (the same id already in the cache assendStatus: 'failed') flips it back tosendingin place instead of appending a duplicate.onErrorno longer rolls the thread back — it flips the bubble tosendStatus: 'failed'and leaves it in place with its typed body.MessageBubble's failed state rendersrole="alert"(screen readers are told), the error-accented bubble, a «تلاش مجدد» action (re-mutates the sameclientMessageId), and a discard (delete) action.- A new
useDiscardFailedMessage()(not a mutation — nothing was ever sent) removes the failed message from the cached thread;TicketConversationPanelwires itsonDiscardto also restore the message's body into the composer's draft — a failure never loses typed text, andclientMessageIdreconciliation still never double-renders. Both invariants are proven by the updatedMessageBubble.test.tsx. TicketConversationPanel(new) is the component that makes this possible without leaking state across tickets: it owns the oneusePostMessage/draft instance bothTicketMessageList's retry/discard and the composer's own send now share, and it's mountedkey={ticketId}fromTicketThreadScreen— 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 onlyMessageComposer).
- Attachment affordance — designed, gated: the composer has an attachment icon button + the capability
flag
TICKETS_ATTACHMENTS_ENABLED(services/tickets/constants.ts, defaultfalse) — it renders nothing until the seam lands (REQ-060). No dead button ships. - RTL send-icon mirror:
sendadded toAppIcon'sDIRECTIONAL_ICONSset (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 compactEmergencyPlaybookRowinstead.
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 absoluteformatShamsiDateTimeon 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-trusttoken,nonefully neutral. All existing--bal-*-softtokens — no new tokens needed. - Non-navigable rows render non-interactive:
NotificationRownow takes anavigableprop (the caller computes it fromnotificationDeepLink(...) != null). Navigable rows are a realButtonBasewith a trailing chevron (forwardicon) and a visible:focus-visiblering; 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 viarole="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:
NotificationBellnow branches onrole === 'nurse' && isDesktop(useMediaQuery(theme.breakpoints.up('md'))) — opens a newNotificationBellPopover(5 most recent, mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches on open (useNotifications(5, { enabled: open })—useNotificationsgained the optional{enabled}param), reusing the samenotificationKeyscache 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) callsuseUnreadCount(); the popover's content and the shell around it never do.NotificationBellViewnowforwardRefs to itsIconButtonso the container can anchor the popover to it (viaanchorElstate set through the ref callback — not a read-during-render, which thereact-hooks/refslint 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.tsxstays aPlaceholderScreen(untouched — building it is phase 11's call), but its only entry point was the TopBar bell (AdminLayouthad no separate "notifications" nav item — the bell click was the dead link). Removed<NotificationBell role="admin" />fromAdminLayoutentirely — a documented, intentional removal (comment inAdminLayout.tsx), not a silent regression. Re-add it (reusingNotificationBellPopover, already exported from the barrel) once phase 11 ships a real admin feed.useThreadScrollandNotificationBellPopoverare 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)
/facustomer → پشتیبانی (/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.- Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message,
date-separated and author-grouped,
hh:mmstamps, 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. - Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows
hh:mm. Post the dev sentinel/failas 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. - 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). /nurse/support/tickets: the sidebar's «پشتیبانی» item shows a small unread badge when the mock'sgetUnreadTotal()is positive.- 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. - Nurse desktop (
≥mdviewport): 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. /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 tonulland the third to a constantnull. Seemocks-registry.md's updatedTicketsApirow.- 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+lastAuthorRoleonTicketSummaryDto(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.
- REQ-059 — Ticket inbox enrichment:
Docs updated
client/CLAUDE.md— themessaging/andnotifications/Project Structure entries rewritten for the new components/hooks (TicketConversationPanel,useThreadScroll,EmergencyPlaybookRow,NotificationBellPopover,notificationTint); theservices/tickets/services/notificationsdomain 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.tsxstructure-tree lines updated for the badge + admin-bell-removal changes.dev/shared-working-context/reports/mocks-registry.md— theTicketsApirow corrected (it was stale: marked "deliver REQ-028, then set flag false" when the flag was alreadyfalse) 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):LinkToPagegained an optionalbadgeCountfield;SideBarNavItem/SideBarNavListrender it as a smallBadge;CustomerLayout/NurseLayoutwire it touseSupportUnreadTotal();AdminLayoutlost its notification bell (§3.7 handshake, documented above).utils/date.ts: addedformatShamsiTime(hh:mm-only) andformatDaySeparator(امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs).AppIcon/config.ts:sendadded toDIRECTIONAL_ICONS(one-line registry addition, phase 0's existing mechanism).services/notifications:useNotificationsgained 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.