{/* display formatted name (address or username) */}
- {isPending &&
}
+ {isPending &&
}
({
+ notificationsApi: {
+ unreadCount: (category?: string) => mockUnreadCount(category),
+ },
+}))
+
+beforeEach(() => {
+ mockUnreadCount.mockReset()
+ mockUnreadCount.mockResolvedValue({ count: 0 })
+})
+
+describe('useSupportUnread', () => {
+ it('asks only for the support category', async () => {
+ renderHook(() => useSupportUnread())
+ await waitFor(() => expect(mockUnreadCount).toHaveBeenCalledWith('support'))
+ })
+
+ it('is false while nothing is unread', async () => {
+ const { result } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(mockUnreadCount).toHaveBeenCalled())
+ expect(result.current).toBe(false)
+ })
+
+ it('is true once support has replied', async () => {
+ mockUnreadCount.mockResolvedValue({ count: 2 })
+ const { result } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(result.current).toBe(true))
+ })
+
+ it('refetches when the notifications list changes', async () => {
+ const { result } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(result.current).toBe(false))
+
+ // The drawer marks the category read, then fires this event.
+ mockUnreadCount.mockResolvedValue({ count: 1 })
+ act(() => {
+ window.dispatchEvent(new CustomEvent('notifications:updated'))
+ })
+ await waitFor(() => expect(result.current).toBe(true))
+ })
+
+ it('refetches when the app comes back to the foreground', async () => {
+ const { result } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(result.current).toBe(false))
+
+ mockUnreadCount.mockResolvedValue({ count: 1 })
+ act(() => {
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+ await waitFor(() => expect(result.current).toBe(true))
+ })
+
+ it('stays false when the count request fails', async () => {
+ mockUnreadCount.mockRejectedValue(new Error('offline'))
+ const { result } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(mockUnreadCount).toHaveBeenCalled())
+ expect(result.current).toBe(false)
+ })
+
+ it('stops listening after unmount', async () => {
+ const { unmount } = renderHook(() => useSupportUnread())
+ await waitFor(() => expect(mockUnreadCount).toHaveBeenCalledTimes(1))
+
+ unmount()
+ window.dispatchEvent(new CustomEvent('notifications:updated'))
+ expect(mockUnreadCount).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/hooks/useSupportUnread.ts b/src/hooks/useSupportUnread.ts
new file mode 100644
index 0000000000..f2ac7eff41
--- /dev/null
+++ b/src/hooks/useSupportUnread.ts
@@ -0,0 +1,57 @@
+'use client'
+
+import { notificationsApi } from '@/services/notifications'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+/**
+ * True when support has replied since the user last opened the chat.
+ *
+ * The count is server-side truth. Neither client can work it out alone: the web
+ * Crisp widget lives in a sandboxed iframe that mounts only after the drawer is
+ * first opened, and the native plugin exposes no message events. The backend
+ * writes one in-app notification row per support reply, and this reads the
+ * count for the `support` category.
+ *
+ * No polling. It refetches on mount, when the notifications list changes, and
+ * when the tab or app comes back to the foreground.
+ */
+export const useSupportUnread = (): boolean => {
+ const [hasUnread, setHasUnread] = useState(false)
+ /*
+ * Responses can land out of order, and the stale one would win. Tapping a
+ * push on a backgrounded app fires a foreground refetch (count 1), then the
+ * deep link opens the drawer, which clears the badge and fires a second
+ * refetch (count 0). If the first response arrives last — routine on a
+ * mobile radio — the badge sticks on with nothing behind it, and with no
+ * polling nothing corrects it until the next foreground.
+ */
+ const latestRequestId = useRef(0)
+
+ const refresh = useCallback(() => {
+ const requestId = ++latestRequestId.current
+ notificationsApi
+ .unreadCount('support')
+ .then(({ count }) => {
+ if (requestId === latestRequestId.current) setHasUnread(count > 0)
+ })
+ // A failed count must never break the nav bar.
+ .catch(() => {})
+ }, [])
+
+ useEffect(() => {
+ refresh()
+
+ const onVisibilityChange = () => {
+ if (document.visibilityState === 'visible') refresh()
+ }
+
+ window.addEventListener('notifications:updated', refresh)
+ document.addEventListener('visibilitychange', onVisibilityChange)
+ return () => {
+ window.removeEventListener('notifications:updated', refresh)
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ }
+ }, [refresh])
+
+ return hasUnread
+}
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index e41756464d..8f41c3ac31 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -58,7 +58,8 @@
"cashout": "Cashout",
"claim": "Claim",
"peanutLogoAlt": "Peanut Logo",
- "receipt": "Receipt"
+ "receipt": "Receipt",
+ "supportUnread": "New support reply"
},
"home": {
"rewards": "Rewards",
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 63257e0d34..30409801d7 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -58,7 +58,8 @@
"cashout": "Retiro",
"claim": "Reclamar",
"peanutLogoAlt": "Logo de Peanut",
- "receipt": "Recibo"
+ "receipt": "Recibo",
+ "supportUnread": "Nueva respuesta de soporte"
},
"home": {
"rewards": "Recompensas",
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index 939fddbdbf..c447ca99fb 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -58,7 +58,8 @@
"cashout": "Saque",
"claim": "Resgatar",
"peanutLogoAlt": "Logo da Peanut",
- "receipt": "Recibo"
+ "receipt": "Recibo",
+ "supportUnread": "Nova resposta do suporte"
},
"home": {
"rewards": "Recompensas",
diff --git a/src/services/__tests__/websocket-parse-error-pii.test.ts b/src/services/__tests__/websocket-parse-error-pii.test.ts
new file mode 100644
index 0000000000..613d305d27
--- /dev/null
+++ b/src/services/__tests__/websocket-parse-error-pii.test.ts
@@ -0,0 +1,62 @@
+import { PeanutWebSocket } from '@/services/websocket'
+
+/**
+ * console.error is wired to Sentry through
+ * captureConsoleIntegration({ levels: ['error', 'warn'] }), and
+ * beforeSendHandler scrubs headers/request.data/extra/contexts/breadcrumbs
+ * by key name — it never touches event.message. So anything handed to
+ * console.error leaves the browser verbatim.
+ *
+ * A malformed WebSocket frame carries the same shapes the good ones do
+ * (kyc_status_update, history_entry, rain_card_balance_changed), which is
+ * user KYC and financial data. This pins the parse-error path so nobody
+ * reintroduces the raw frame into that log line.
+ */
+describe('PeanutWebSocket — malformed frame never reaches Sentry via console', () => {
+ // A frame that fails JSON.parse but still carries recognisable PII.
+ const PII_FRAME =
+ '{"type":"kyc_status_update","data":{"status":"approved","fullName":"ALEKSEI SOKOLOV",' +
+ '"documentNumber":"AB1234567","email":"aleksei@example.com"}' // truncated → invalid JSON
+
+ const SECRETS = ['ALEKSEI SOKOLOV', 'AB1234567', 'aleksei@example.com', 'kyc_status_update']
+
+ let socket: { onmessage: ((event: MessageEvent) => void) | null }
+ let errorSpy: jest.SpyInstance
+
+ beforeEach(() => {
+ socket = { onmessage: null }
+ // Capture the handler `connect()` binds, without a real transport.
+ ;(global as unknown as { WebSocket: unknown }).WebSocket = jest.fn(() => socket)
+ errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ errorSpy.mockRestore()
+ jest.resetAllMocks()
+ })
+
+ const deliver = (data: string) => {
+ const ws = new PeanutWebSocket('https://api.peanut.test', '/ws')
+ ws.connect()
+ socket.onmessage?.({ data } as MessageEvent)
+ }
+
+ it('logs the parse failure without echoing the frame', () => {
+ deliver(PII_FRAME)
+
+ expect(errorSpy).toHaveBeenCalled()
+ const logged = errorSpy.mock.calls.flat().map(String).join(' ')
+
+ for (const secret of SECRETS) {
+ expect(logged).not.toContain(secret)
+ }
+ })
+
+ it('still reports the frame size so a truncated frame stays diagnosable', () => {
+ deliver(PII_FRAME)
+
+ const logged = errorSpy.mock.calls.flat().map(String).join(' ')
+ expect(logged).toContain(String(PII_FRAME.length))
+ expect(logged).toContain('Error parsing WebSocket message')
+ })
+})
diff --git a/src/services/notifications.ts b/src/services/notifications.ts
index bb6829ca65..23afbc7202 100644
--- a/src/services/notifications.ts
+++ b/src/services/notifications.ts
@@ -36,8 +36,10 @@ export const notificationsApi = {
}
},
- async unreadCount(): Promise<{ count: number }> {
- const response = await serverFetch('/notifications/unread-count', {
+ /** Pass a category (e.g. 'support') to count only that category's unread rows. */
+ async unreadCount(category?: string): Promise<{ count: number }> {
+ const query = category ? `?category=${encodeURIComponent(category)}` : ''
+ const response = await serverFetch(`/notifications/unread-count${query}`, {
method: 'GET',
})
if (!response.ok) throw new Error('failed to fetch unread count')
@@ -52,4 +54,17 @@ export const notificationsApi = {
if (!response.ok) throw new Error('failed to mark read')
return await response.json()
},
+
+ /**
+ * Mark every unread row in a category as read. Used by the support drawer,
+ * which never sees the row ids — the conversation itself lives in Crisp.
+ */
+ async markAllRead(category: string) {
+ const response = await serverFetch('/notifications/mark-read', {
+ method: 'POST',
+ body: JSON.stringify({ category }),
+ })
+ if (!response.ok) throw new Error('failed to mark read')
+ return await response.json()
+ },
}
diff --git a/src/services/websocket.ts b/src/services/websocket.ts
index 11e16b6b38..cf20e9bf04 100644
--- a/src/services/websocket.ts
+++ b/src/services/websocket.ts
@@ -260,7 +260,21 @@ export class PeanutWebSocket {
break
}
} catch (error) {
- console.error('Error parsing WebSocket message:', error, event.data)
+ // Never log the raw frame. console.error is wired to Sentry via
+ // captureConsoleIntegration({ levels: ['error', 'warn'] }), and
+ // beforeSendHandler only scrubs headers/request.data/extra/
+ // contexts/breadcrumbs by key name - it does not touch
+ // event.message. A raw frame here carries kyc_status_update,
+ // history_entry, rain_card_balance_changed and friends, so it
+ // would ship user KYC and financial data straight to Sentry.
+ // The length is enough to tell a truncated frame from a malformed
+ // one, which is all this catch ever needed. It is String.length —
+ // UTF-16 code units, not bytes — and that is deliberate: the exact
+ // byte count would mean running the whole frame through a
+ // TextEncoder inside an error path, and only the magnitude
+ // matters here.
+ const size = typeof event.data === 'string' ? event.data.length : 'non-string'
+ console.error('Error parsing WebSocket message:', error, `(frame length: ${size})`)
}
}
diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts
index adb2bbf272..52a7eb3c58 100644
--- a/src/types/api.generated.ts
+++ b/src/types/api.generated.ts
@@ -1850,6 +1850,39 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/crisp/webhooks": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Default Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/dev/cheats/approve-kyc": {
parameters: {
query?: never;
diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json
index 31afd557c4..7f44d3fbe6 100644
--- a/src/types/api.openapi.json
+++ b/src/types/api.openapi.json
@@ -3407,6 +3407,15 @@
}
}
},
+ "/crisp/webhooks": {
+ "post": {
+ "responses": {
+ "200": {
+ "description": "Default Response"
+ }
+ }
+ }
+ },
"/dev/cheats/approve-kyc": {
"post": {
"requestBody": {