feat(support): unread badge on the Support nav icon - #2639
Conversation
Second half of TASK-21141. The backend now writes an in-app notification row for every support reply; this shows it. The Support icon in the mobile nav gets a pink dot while support has replied and the user has not opened the chat. Opening the drawer clears it. The count is server truth, read from /notifications/unread-count?category=support — the Crisp widget is a sandboxed iframe on web and an event-less plugin on native, so the client cannot work this out for itself. Clearing hangs off isSupportModalOpen, which is the one flag every entry sets before anything opens — the nav tap, openSupportWithMessage(), the push deep link and the Capacitor path. One effect covers all four. SupportDeepLink handles /home?support=open, the link a support push carries. The pink dot was copy-pasted in three places and the badge would have made a fourth, so it is now one IndicatorDot component. The three call sites render the same as before — twMerge resolves the size and animation overrides. The name is deliberately neutral: on a transaction card the dot means pending, on the perk carousel it means claimable. Do not merge before the backend PR is deployed. An old backend ignores the category param and would light the badge for any unread notification.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Code-analysis diffPainscore total: 7079 → 7092.94 (+13.94) 🆕 New findings (28)
…and 8 more. ✅ Resolved (26)
…and 6 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
jest.fn(async () => …) infers a zero-arg function, so calling it with the category failed tsc. Local typecheck predated this mock and missed it.
The three migrated call sites are only safe if twMerge wins their size and animation overrides instead of emitting both. Asserting the resolved class string pins that more precisely than a screenshot of a 10px dot.
Five findings from /code-review on the badge lifecycle. Opening the drawer is not the same as reading the reply. When the Crisp bundle fails to load, this same component shows the email fallback instead — the badge used to clear anyway and bury a reply nobody saw. The web path now waits for CRISP_READY. The native path has no such signal, so it clears right after openMessenger() instead. A reply arriving while the drawer is open — the normal case in a live conversation — used to light the badge with nothing new behind it and leave it lit until the user opened support again. Clearing now also fires on the closing edge. Concurrent refreshes could resurrect a cleared badge: tapping a push fires a foreground refetch (count 1), the deep link then clears and refetches (count 0), and if the first response lands last it wins. With no polling nothing corrected it. Responses now carry a request id and stale ones are dropped. Guests reach this drawer through claim and pay links, and were sending an unauthenticated mark-read on every open. Gated on a resolved userId. The nav badge announced nothing: aria-label on a bare span is ignored by assistive tech and is an aria-prohibited-attr violation. It now carries role="status" with a translated label. es-AR has no navigation block at all and falls back, so only the three locales that do were touched.
|
@coderabbitai review now |
|
|
Notion: TASK-21141 — notify users when support replies. Frontend half.
Backend: peanutprotocol/peanut-api-ts#1303.
⛔ Merge order
Do not merge before peanut-api-ts#1303 is deployed. An old backend ignores the
categoryquery param, so the badge would show the global unread count — a dot on the Support icon for any unread notification at all, and opening support would never clear it.This PR is deliberately left as a draft so that ordering cannot be lost to a stray click. The code is finished and green — CI, review and tests are all done. Mark it ready once #1303 is live.
What this does
/home?support=openopens the support drawer — the deep link a support-reply push carries.The count is server truth:
GET /notifications/unread-count?category=support. The client cannot work it out for itself — the web Crisp widget is a sandboxed iframe that mounts only after the drawer is first opened, and the native plugin exposes no message events.Files
hooks/useSupportUnread.tshasUnread. Fetches on mount, onnotifications:updated, and when the app returns to the foreground. No polling.components/Global/SupportDeepLink/index.tsx?support=open, opens the drawer, clears the param. Renders null.components/Global/IndicatorDot/index.tsxcomponents/Global/SupportDrawer/index.tsxcomponents/Global/WalletNavigation/index.tsxservices/notifications.tsunreadCount(category?), newmarkAllRead(category).Why clearing hangs off
isSupportModalOpenIt is the one flag every entry point sets before anything opens — the nav tap,
openSupportWithMessage(), the push deep link, and the Capacitor path (which sets it before handing over to the native messenger). One effect covers all four, so there is no entry that lights the chat without clearing the badge.markAllReadtakes a category rather than ids because the drawer never sees notification ids. The conversation lives in Crisp, not in our inbox.The dot extraction
The same pink dot was copy-pasted in three places and the badge would have made a fourth, so it is now
IndicatorDot. Approved by Aleks before the work started.All three call sites render the same as before —
twMergeresolves the size and animation overrides:ProfileMenuItem— theanimate-pulsewrapper collapses onto the dot. Same visual;aria-label="highlight-indicator"preserved.CarouselCTA— the positioned wrapper stays (it ownsCAROUSEL_CLOSE_BUTTON_POSITION,z-10and the aria-label); only the inner div is replaced.TransactionCard—h-2 w-2 animate-pulsatepassed through as className.The name is neutral on purpose: on a transaction card the dot means pending, on the perk carousel it means claimable, on the nav it means unread.
Verification
npm test— 212 suites, 2706 tests pass. New: 7 foruseSupportUnread(category scoping, both refetch triggers, listener cleanup, failure path), 5 forIndicatorDot(class resolution at all four call sites + the announced badge), and 3 for the drawer (clears once the chat renders, does NOT clear on the Crisp-failure fallback, does NOT clear for a guest).npm run typecheckclean,prettier --check .clean,npm run buildclean.SupportDrawersuite needed a@/services/notificationsmock — the drawer now makes a request on open, andserverFetchreaches for Capacitor Preferences, which jsdom has no shim for.Screenshots
?category=support, which only exists on peanut-api-ts#1303. Against today's backend the dot would light for any unread notification, so a screenshot would show the wrong thing and read as proof.The dot extraction is the part that actually carries visual risk, and it is covered by something stronger than a screenshot:
IndicatorDot.test.tsxasserts the resolved class string for all four call sites, so a twMerge override that stopped winning (leavingh-2.5next toh-2) fails CI rather than needing an eye. That check is permanent; a screenshot is a one-off.Still to do by hand after the backend deploys: visual check at 375×667 on the three migrated dots (profile menu highlight, perk carousel dot, pending transaction dot) next to the new nav badge. Then the live path: real support reply → push + badge; second reply without opening → no second push; open chat → badge clears.
Known gaps, not in scope
/supportroute. Pre-existing.notificationsApi.list()already sendsfilterandcategoryparams the backend ignores. Pre-existing./code-reviewwas the only automated reviewer here.e2echeck is vacuous — that job aborts before running any test. Do not read it as e2e coverage.Review fixes applied
/code-review mediumreturned 6 findings. The deploy-ordering one is the ⛔ at the top of this description. The other five are fixed:CRISP_READY; the native path has no such signal, so it clears right afteropenMessenger().aria-labelon a bare span is ignored by assistive tech and is anaria-prohibited-attrviolation. It now carriesrole="status"with a translated label.es-ARhas nonavigationblock and falls back, so only the three locales that have one were touched.