{/* 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/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()
+ },
}