Bläddra i källkod

Keep the bell quiet for a message on the thread already open (#479)

* Keep the bell quiet for a message on the thread already open

* Refresh open WhatsApp and Telegram threads on a quiet inbound, guard clear all on an empty list
Bernt Christian Egeland 3 dagar sedan
förälder
incheckning
1fd7cecfe9

+ 30 - 0
src/__tests__/features/messaging/active-conversation.test.ts

@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+  isActiveConversation,
+  setActiveConversation,
+} from '@/features/messaging/activeConversation'
+
+describe('active conversation', () => {
+  afterEach(() => setActiveConversation(null))
+
+  it('knows nothing until a thread registers', () => {
+    expect(isActiveConversation('/messages?customerId=c1')).toBe(false)
+    expect(isActiveConversation(null)).toBe(false)
+  })
+
+  it('answers for every link the open thread registered, and no other', () => {
+    setActiveConversation([
+      '/messages?channel=email&customerId=c1',
+      '/messages?channel=email&contact=ann%40example.com',
+    ])
+    expect(isActiveConversation('/messages?channel=email&customerId=c1')).toBe(true)
+    expect(isActiveConversation('/messages?channel=email&contact=ann%40example.com')).toBe(true)
+    expect(isActiveConversation('/messages?customerId=c1')).toBe(false)
+  })
+
+  it('forgets the thread when it closes', () => {
+    setActiveConversation(['/messages?customerId=c1'])
+    setActiveConversation(null)
+    expect(isActiveConversation('/messages?customerId=c1')).toBe(false)
+  })
+})

+ 18 - 0
src/__tests__/features/notifications/notification-panel-clear-all.test.tsx

@@ -123,6 +123,24 @@ describe('NotificationPanel clear all', () => {
     expect(screen.getByRole('button', { name: 'Clear all' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: 'Clear all' })).toBeInTheDocument()
   })
   })
 
 
+  it('does nothing when the list emptied while the question was up', async () => {
+    seed(1)
+    render(<NotificationPanel />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Clear all' }))
+    // The one notification goes by its own delete control meanwhile.
+    act(() => {
+      useNotificationStore.getState().removeNotification('n0')
+    })
+
+    fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
+    await flush()
+
+    expect(deleteAllNotifications).not.toHaveBeenCalled()
+    expect(screen.queryByText('Remove all notifications?')).not.toBeInTheDocument()
+    expect(screen.getByText('No notifications yet')).toBeInTheDocument()
+  })
+
   it('is hidden when there is nothing to clear', () => {
   it('is hidden when there is nothing to clear', () => {
     useNotificationStore.getState().setPanelOpen(true)
     useNotificationStore.getState().setPanelOpen(true)
     render(<NotificationPanel />)
     render(<NotificationPanel />)

+ 11 - 0
src/features/messaging/Components/EmailConversation.tsx

@@ -18,6 +18,7 @@ import { Textarea } from '@/components/ui/textarea'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import type { EmailChannelState } from '../Actions/inboxActions'
 import type { EmailChannelState } from '../Actions/inboxActions'
 import { getEmailConversation, sendEmailMessage } from '../Actions/emailThreadActions'
 import { getEmailConversation, sendEmailMessage } from '../Actions/emailThreadActions'
+import { setActiveConversation } from '../activeConversation'
 import { replySubject, splitQuotedReply } from '../Lib/inboundMail'
 import { replySubject, splitQuotedReply } from '../Lib/inboundMail'
 import { MessageText } from './MessageText'
 import { MessageText } from './MessageText'
 
 
@@ -81,6 +82,16 @@ export function EmailConversation({
 
 
   useEffect(() => setMounted(true), [])
   useEffect(() => setMounted(true), [])
 
 
+  // A reply to this thread is shown here, not announced. A mail from the
+  // customer is filed under them, one from an address nobody has under the
+  // address, so both links name this thread.
+  useEffect(() => {
+    const links = [`/messages?channel=email&contact=${encodeURIComponent(address)}`]
+    if (thread.customerId) links.push(`/messages?channel=email&customerId=${thread.customerId}`)
+    setActiveConversation(links)
+    return () => setActiveConversation(null)
+  }, [thread.customerId, address])
+
   const load = useCallback(async () => {
   const load = useCallback(async () => {
     const result = await getEmailConversation({
     const result = await getEmailConversation({
       customerId: thread.customerId,
       customerId: thread.customerId,

+ 22 - 0
src/features/messaging/activeConversation.ts

@@ -0,0 +1,22 @@
+/**
+ * The conversation on screen, as the links an inbound notification carries.
+ *
+ * A message arriving for the thread the reader is already looking at should
+ * not ring the bell: the thread shows it, and the feed files it read. The
+ * open thread registers the links a notification for it would carry (a
+ * customer thread and an address thread are the same conversation, so an
+ * email thread registers both); the socket handler asks before announcing.
+ *
+ * Module state rather than a store: only one conversation is open at a time
+ * and nothing renders from it, so a subscription would be overhead.
+ */
+
+let active: ReadonlySet<string> = new Set()
+
+export function setActiveConversation(urls: readonly string[] | null): void {
+  active = new Set(urls ?? [])
+}
+
+export function isActiveConversation(url: string | null | undefined): boolean {
+  return Boolean(url) && active.has(url as string)
+}

+ 5 - 0
src/features/notifications/Components/NotificationPanel.tsx

@@ -108,6 +108,11 @@ export function NotificationPanel() {
   }
   }
 
 
   const handleConfirmClearAll = async () => {
   const handleConfirmClearAll = async () => {
+    // The list can have been emptied one by one while the question was up.
+    if (notifications.length === 0) {
+      setPendingClearAll(false)
+      return
+    }
     // The newest notification on screen is the cutoff: one that arrives while
     // The newest notification on screen is the cutoff: one that arrives while
     // the request is in flight was never shown, so it is not cleared.
     // the request is in flight was never shown, so it is not cleared.
     const newest = Math.max(...notifications.map((n) => new Date(n.createdAt).getTime()))
     const newest = Math.max(...notifications.map((n) => new Date(n.createdAt).getTime()))

+ 2 - 7
src/features/notifications/hooks/useNotificationWebSocket.ts

@@ -5,7 +5,7 @@ import { useRealtime, useRealtimeState } from '@/features/realtime/RealtimeProvi
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { useNotificationStore, type Notification } from '../store/notificationStore'
 import { useNotificationStore, type Notification } from '../store/notificationStore'
 import { getNotifications, markNotificationRead } from '../Actions/notificationActions'
 import { getNotifications, markNotificationRead } from '../Actions/notificationActions'
-import { getActiveSmsCustomerId } from '@/features/sms/activeSmsView'
+import { isActiveConversation } from '@/features/messaging/activeConversation'
 
 
 export function useNotificationWebSocket() {
 export function useNotificationWebSocket() {
   const realtime = useRealtime()
   const realtime = useRealtime()
@@ -49,12 +49,7 @@ export function useNotificationWebSocket() {
       if (!data?.id) return
       if (!data?.id) return
 
 
       // Already looking at this conversation: file it read, and stay quiet.
       // Already looking at this conversation: file it read, and stay quiet.
-      const activeSmsCid = getActiveSmsCustomerId()
-      if (
-        activeSmsCid &&
-        data.type === 'sms_inbound' &&
-        data.entityUrl === `/messages?customerId=${activeSmsCid}`
-      ) {
+      if (data.type.endsWith('_inbound') && isActiveConversation(data.entityUrl)) {
         useNotificationStore.getState().addNotification({ ...data, read: true })
         useNotificationStore.getState().addNotification({ ...data, read: true })
         useNotificationStore.setState((s) => ({ unreadCount: Math.max(0, s.unreadCount - 1) }))
         useNotificationStore.setState((s) => ({ unreadCount: Math.max(0, s.unreadCount - 1) }))
         markNotificationRead(data.id)
         markNotificationRead(data.id)

+ 3 - 3
src/features/sms/Components/SmsConversation.tsx

@@ -19,7 +19,7 @@ import { Loader2, Send, MessageSquare, ChevronUp, Trash2 } from 'lucide-react'
 import { cn } from '@/lib/utils'
 import { cn } from '@/lib/utils'
 import { sendSmsToCustomer, getConversation, deleteSmsMessage } from '../Actions/smsActions'
 import { sendSmsToCustomer, getConversation, deleteSmsMessage } from '../Actions/smsActions'
 import { useNotificationStore } from '@/features/notifications/store/notificationStore'
 import { useNotificationStore } from '@/features/notifications/store/notificationStore'
-import { setActiveSmsCustomerId } from '../activeSmsView'
+import { setActiveConversation } from '@/features/messaging/activeConversation'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { MessageText } from '@/features/messaging/Components/MessageText'
 import { MessageText } from '@/features/messaging/Components/MessageText'
 
 
@@ -68,8 +68,8 @@ export function SmsConversation({
   const bottomRef = useRef<HTMLDivElement>(null)
   const bottomRef = useRef<HTMLDivElement>(null)
 
 
   useEffect(() => {
   useEffect(() => {
-    setActiveSmsCustomerId(customerId)
-    return () => setActiveSmsCustomerId(null)
+    setActiveConversation([`/messages?customerId=${customerId}`])
+    return () => setActiveConversation(null)
   }, [customerId])
   }, [customerId])
 
 
   const initialScrollDone = useRef(false)
   const initialScrollDone = useRef(false)

+ 0 - 9
src/features/sms/activeSmsView.ts

@@ -1,9 +0,0 @@
-let activeCustomerId: string | null = null
-
-export function setActiveSmsCustomerId(id: string | null) {
-  activeCustomerId = id
-}
-
-export function getActiveSmsCustomerId(): string | null {
-  return activeCustomerId
-}

+ 7 - 1
src/features/telegram/Components/TelegramConversation.tsx

@@ -2,6 +2,7 @@
 
 
 import { useState, useTransition, useRef, useEffect, useCallback } from 'react'
 import { useState, useTransition, useRef, useEffect, useCallback } from 'react'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
+import { setActiveConversation } from '@/features/messaging/activeConversation'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Textarea } from '@/components/ui/textarea'
 import { Textarea } from '@/components/ui/textarea'
 import {
 import {
@@ -70,6 +71,11 @@ export function TelegramConversation({
     setMounted(true)
     setMounted(true)
   }, [])
   }, [])
 
 
+  useEffect(() => {
+    setActiveConversation([`/messages?tab=telegram&customerId=${customerId}`])
+    return () => setActiveConversation(null)
+  }, [customerId])
+
   const initialScrollDone = useRef(false)
   const initialScrollDone = useRef(false)
   useEffect(() => {
   useEffect(() => {
     const el = scrollAreaRef.current
     const el = scrollAreaRef.current
@@ -112,7 +118,7 @@ export function TelegramConversation({
       const latest = notifications[0]
       const latest = notifications[0]
       if (
       if (
         latest?.type === 'telegram_inbound' &&
         latest?.type === 'telegram_inbound' &&
-        latest.entityUrl === `/telegram?customerId=${customerId}`
+        latest.entityUrl === `/messages?tab=telegram&customerId=${customerId}`
       ) {
       ) {
         refreshConversation()
         refreshConversation()
       }
       }

+ 25 - 0
src/features/whatsapp/Components/WhatsappConversation.tsx

@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
+import { setActiveConversation } from '@/features/messaging/activeConversation'
+import { useNotificationStore } from '@/features/notifications/store/notificationStore'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import { Textarea } from '@/components/ui/textarea'
 import { Textarea } from '@/components/ui/textarea'
 import { Clock, ImagePlus, Loader2, Send, X } from 'lucide-react'
 import { Clock, ImagePlus, Loader2, Send, X } from 'lucide-react'
@@ -67,6 +69,12 @@ export function WhatsappConversation({
 
 
   useEffect(() => setMounted(true), [])
   useEffect(() => setMounted(true), [])
 
 
+  useEffect(() => {
+    if (!thread.customerId) return
+    setActiveConversation([`/messages?tab=whatsapp&customerId=${thread.customerId}`])
+    return () => setActiveConversation(null)
+  }, [thread.customerId])
+
   const load = useCallback(async () => {
   const load = useCallback(async () => {
     // No customer yet, so the history is filed under the number itself. It is
     // No customer yet, so the history is filed under the number itself. It is
     // still readable: only replying needs a customer to reply to.
     // still readable: only replying needs a customer to reply to.
@@ -102,6 +110,23 @@ export function WhatsappConversation({
     load()
     load()
   }, [load])
   }, [load])
 
 
+  // A message from this customer arriving while the thread is open: the
+  // feed is quiet about it (the registration above), so the thread itself
+  // has to show it.
+  useEffect(() => {
+    if (!thread.customerId) return
+    const link = `/messages?tab=whatsapp&customerId=${thread.customerId}`
+    let lastCount = useNotificationStore.getState().notifications.length
+    return useNotificationStore.subscribe((state) => {
+      const { notifications } = state
+      const grew = notifications.length > lastCount
+      lastCount = notifications.length
+      if (!grew) return
+      const latest = notifications[0]
+      if (latest?.type === 'whatsapp_inbound' && latest.entityUrl === link) void load()
+    })
+  }, [thread.customerId, load])
+
   // Opening a thread should land on the newest message. Scrolling once on
   // Opening a thread should land on the newest message. Scrolling once on
   // mount ran before the messages had loaded, so it scrolled an empty list and
   // mount ran before the messages had loaded, so it scrolled an empty list and
   // stayed at the top.
   // stayed at the top.