Skip to content

feat(support): unread badge on the Support nav icon - #2639

Merged
jjramirezn merged 4 commits into
devfrom
feat/support-reply-badge
Aug 7, 2026
Merged

feat(support): unread badge on the Support nav icon#2639
jjramirezn merged 4 commits into
devfrom
feat/support-reply-badge

Conversation

@abalinda

@abalinda abalinda commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 category query 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

  1. A pink dot on the Support icon in the mobile nav while support has replied and the user has not opened the chat.
  2. The dot clears when the chat opens.
  3. /home?support=open opens 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

File Role
hooks/useSupportUnread.ts hasUnread. Fetches on mount, on notifications:updated, and when the app returns to the foreground. No polling.
components/Global/SupportDeepLink/index.tsx Reads ?support=open, opens the drawer, clears the param. Renders null.
components/Global/IndicatorDot/index.tsx The pink dot, now one component.
components/Global/SupportDrawer/index.tsx Clears the badge on open.
components/Global/WalletNavigation/index.tsx Renders the badge.
services/notifications.ts unreadCount(category?), new markAllRead(category).

Why clearing hangs off isSupportModalOpen

It 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.

markAllRead takes 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 — twMerge resolves the size and animation overrides:

  • ProfileMenuItem — the animate-pulse wrapper collapses onto the dot. Same visual; aria-label="highlight-indicator" preserved.
  • CarouselCTA — the positioned wrapper stays (it owns CAROUSEL_CLOSE_BUTTON_POSITION, z-10 and the aria-label); only the inner div is replaced.
  • TransactionCardh-2 w-2 animate-pulsate passed 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 for useSupportUnread (category scoping, both refetch triggers, listener cleanup, failure path), 5 for IndicatorDot (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 typecheck clean, prettier --check . clean, npm run build clean.
  • The existing SupportDrawer suite needed a @/services/notifications mock — the drawer now makes a request on open, and serverFetch reaches for Capacitor Preferences, which jsdom has no shim for.

Screenshots

⚠️ None, and here is why. A truthful shot of the badge needs the backend deployed — the count comes from ?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.tsx asserts the resolved class string for all four call sites, so a twMerge override that stopped winning (leaving h-2.5 next to h-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

  • The desktop sidebar still links to a dead /support route. Pre-existing.
  • notificationsApi.list() already sends filter and category params the backend ignores. Pre-existing.
  • Peanut-ui gets no CodeRabbit pass on a dev-base PR (the check reports "reviews are disabled for this base branch"), so /code-review was the only automated reviewer here.
  • The green e2e check is vacuous — that job aborts before running any test. Do not read it as e2e coverage.

Review fixes applied

/code-review medium returned 6 findings. The deploy-ordering one is the ⛔ at the top of this description. The other five are fixed:

  • The badge cleared even when the reply was never seen. When the Crisp bundle fails to load, this component shows the email fallback instead — and the badge cleared anyway, burying the reply. The web path now waits for CRISP_READY; the native path has no such signal, so it clears right after openMessenger().
  • A reply arriving while the drawer was open was never marked read — the normal case in a live conversation. The badge would light with nothing new behind it and stay lit until the next open. 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); if the first response lands last it won, and with no polling nothing corrected it. Responses now carry a request id and stale ones are dropped.
  • Guests were sending an unauthenticated mark-read on every support open (claim and pay links mount this layout). 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 and falls back, so only the three locales that have one were touched.

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.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 7, 2026 1:13pm

Request Review

Copilot AI lite review requested due to automatic review settings August 7, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0786b290-79f3-415d-94bd-23c4545ff378

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7079 → 7092.94 (+13.94)
Findings: +2 net (+28 new, -26 resolved)

🆕 New findings (28)

  • critical complexity — src/components/Global/SupportDrawer/index.tsx — CC 63, MI 61.56, SLOC 173
  • critical complexity — src/app/(mobile-ui)/layout.tsx — CC 59, MI 54.24, SLOC 111
  • high method-complexity — src/components/TransactionDetails/TransactionCard.tsx:68 — CC 44 SLOC 98
  • high method-complexity — src/app/(mobile-ui)/layout.tsx:42 — CC 39 SLOC 75
  • high hotspot — src/components/TransactionDetails/TransactionCard.tsx — 35 commits, +288/-195 lines since 6 months ago
  • high complexity — src/services/notifications.ts — CC 8, MI 39.6, SLOC 61
  • medium high-mdd — src/components/Global/SupportDrawer/index.tsx:21 — SupportDrawer: MDD 81.8 (uses across many lines from declarations)
  • medium high-mdd — src/app/(mobile-ui)/layout.tsx:42 — Layout: MDD 78.8 (uses across many lines from declarations)
  • medium high-mdd — src/components/TransactionDetails/TransactionCard.tsx:68 — TransactionCard: MDD 66.6 (uses across many lines from declarations)
  • medium high-mdd — src/components/Profile/components/ProfileMenuItem.tsx:30 — ProfileMenuItem: MDD 39.8 (uses across many lines from declarations)
  • medium high-mdd — src/components/Global/WalletNavigation/index.tsx:77 — MobileNav: MDD 39.0 (uses across many lines from declarations)
  • medium high-dlt — src/components/Global/SupportDrawer/index.tsx:21 — SupportDrawer: DLT 33 (calls 33 distinct functions — high context load)
  • medium high-dlt — src/components/TransactionDetails/TransactionCard.tsx:68 — TransactionCard: DLT 33 (calls 33 distinct functions — high context load)
  • medium hotspot — src/app/(mobile-ui)/layout.tsx — 27 commits, +174/-105 lines since 6 months ago
  • medium high-mdd — src/components/Home/HomeCarouselCTA/CarouselCTA.tsx:29 — CarouselCTA: MDD 25.1 (uses across many lines from declarations)
  • medium method-complexity — src/components/Global/SupportDrawer/index.tsx:21 — CC 16 SLOC 78
  • medium complexity — src/components/Global/WalletNavigation/index.tsx — CC 13, MI 59.89, SLOC 87
  • medium react-effect-derives-state — src/app/(mobile-ui)/layout.tsx:69 — useEffect with empty deps + setState — derived state anti-pattern
  • medium react-direct-dom — src/app/(mobile-ui)/layout.tsx:83 — direct DOM: document.querySelector
  • medium react-effect-derives-state — src/components/Global/SupportDeepLink/index.tsx:16 — small useEffect that only sets state from deps

…and 8 more.

✅ Resolved (26)

  • src/app/(mobile-ui)/layout.tsx — CC 59, MI 54.25, SLOC 111
  • src/components/Global/SupportDrawer/index.tsx — CC 53, MI 61.22, SLOC 146
  • src/components/TransactionDetails/TransactionCard.tsx:67 — CC 44 SLOC 98
  • src/app/(mobile-ui)/layout.tsx:41 — CC 39 SLOC 75
  • src/components/TransactionDetails/TransactionCard.tsx — 34 commits, +286/-194 lines since 6 months ago
  • src/services/notifications.ts — CC 6, MI 42.99, SLOC 48
  • src/app/(mobile-ui)/layout.tsx:41 — Layout: MDD 77.3 (uses across many lines from declarations)
  • src/components/Global/SupportDrawer/index.tsx:20 — SupportDrawer: MDD 73.8 (uses across many lines from declarations)
  • src/components/TransactionDetails/TransactionCard.tsx:67 — TransactionCard: MDD 66.6 (uses across many lines from declarations)
  • src/components/Profile/components/ProfileMenuItem.tsx:29 — ProfileMenuItem: MDD 43.8 (uses across many lines from declarations)
  • src/components/TransactionDetails/TransactionCard.tsx:67 — TransactionCard: DLT 33 (calls 33 distinct functions — high context load)
  • src/components/Global/WalletNavigation/index.tsx:75 — MobileNav: MDD 28.0 (uses across many lines from declarations)
  • src/app/actions/sumsub.ts — 27 commits, +295/-93 lines since 6 months ago
  • src/components/Home/HomeCarouselCTA/CarouselCTA.tsx:28 — CarouselCTA: MDD 25.1 (uses across many lines from declarations)
  • src/components/Global/SupportDrawer/index.tsx:20 — CC 16 SLOC 69
  • src/components/Global/WalletNavigation/index.tsx — CC 12, MI 60.32, SLOC 84
  • src/app/(mobile-ui)/layout.tsx:68 — useEffect with empty deps + setState — derived state anti-pattern
  • src/app/(mobile-ui)/layout.tsx:82 — direct DOM: document.querySelector
  • src/components/Global/SupportDrawer/index.tsx:50 — small useEffect that only sets state from deps
  • src/components/Global/SupportDrawer/index.tsx:132 — useEffect with empty deps + setState — derived state anti-pattern

…and 6 more.

📈 Painscore deltas (top movers)

File Before After Δ
src/components/Global/SupportDeepLink/index.tsx 0.0 5.3 +5.3
src/hooks/useSupportUnread.ts 0.0 4.7 +4.7
src/components/Global/IndicatorDot/index.tsx 0.0 2.2 +2.2
src/services/notifications.ts 7.9 8.5 +0.6
src/components/Global/WalletNavigation/index.tsx 7.7 8.2 +0.5

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 2709 ran, 0 failed, 0 skipped, 43.8s

📊 Coverage (unit)

metric %
statements 64.6%
branches 48.7%
functions 54.3%
lines 65.2%
⏱ 10 slowest test cases
time test
3.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.3s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

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.
@abalinda
abalinda marked this pull request as draft August 7, 2026 13:14
@abalinda
abalinda marked this pull request as ready for review August 7, 2026 13:18
@abalinda

abalinda commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review now

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@abalinda I will review the current changes in #2639. The draft status and backend deployment requirement remain unchanged.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@abalinda
abalinda marked this pull request as draft August 7, 2026 13:36
@abalinda
abalinda marked this pull request as ready for review August 7, 2026 13:43
@jjramirezn
jjramirezn merged commit bc7b8ab into dev Aug 7, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants