diff --git a/client/src/hooks/useSocket.ts b/client/src/hooks/useSocket.ts index e41ed32..622ca57 100644 --- a/client/src/hooks/useSocket.ts +++ b/client/src/hooks/useSocket.ts @@ -159,13 +159,13 @@ export function useSocket() { } } - useStore.getState().addMessage(chatId, msgToAdd) + const isNewMessage = useStore.getState().addMessage(chatId, msgToAdd) // If not on that chat page AND message is not from me, trigger notification const isFromMe = data.from === myId const isOnChat = window.location.pathname.includes(chatId) - if (!isFromMe && !isOnChat) { + if (isNewMessage && !isFromMe && !isOnChat) { useStore.getState().incrementUnread(chatId) // Skip all notifications for offline catch-up messages @@ -221,7 +221,7 @@ export function useSocket() { : `/chat/${chatId}` } ) - } else if (!isFromMe && isOnChat) { + } else if (isNewMessage && !isFromMe && isOnChat) { // On the chat page but still play a subtle sound for new messages // (skip if it's a self message) } diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 86b36de..d108442 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -310,6 +310,7 @@ export default function Chat() { const user = useStore(s => s.user) const messages = useStore(s => s.messages[id!] ?? EMPTY_MSGS) const setMessages = useStore(s => s.setMessages) + const clearUnread = useStore(s => s.clearUnread) const friends = useStore(s => s.friends) const groups = useStore(s => s.groups) const wsConnected = useStore(s => s.wsConnected) @@ -385,6 +386,12 @@ export default function Chat() { } // ── Load history + decrypt + merge with cache ── + useEffect(() => { + // A chat can be opened from a push/toast or a deep link, bypassing the + // conversation-list click handler that also clears this counter. + if (id) clearUnread(id) + }, [id, clearUnread]) + useEffect(() => { if (!id) return const path = isGroup ? `/api/messages/group/${id}?limit=50000` : `/api/messages/private/${id}?limit=50000` diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 1dc47be..4605d92 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -163,7 +163,11 @@ interface AppStore { // Chat Messages (keyed by chatId) messages: Record - addMessage: (chatId: string, msg: ChatMessage) => void + /** + * Adds a message if its server ID is not already cached. + * Returns true only when the message was actually inserted. + */ + addMessage: (chatId: string, msg: ChatMessage) => boolean updateMessage: (chatId: string, msgId: string, patch: Partial) => void setMessages: (chatId: string, msgs: ChatMessage[]) => void prependMessages: (chatId: string, msgs: ChatMessage[]) => void @@ -305,19 +309,25 @@ export const useStore = create((set, get) => ({ // Messages (initialized from localStorage cache) messages: loadCachedMessages(), - addMessage: (chatId, msg) => set(s => { - const existing = s.messages[chatId] || [] - // Deduplicate by message ID - if (msg.id && existing.some(m => m.id === msg.id)) { - return s // skip duplicate - } - const updated = { - ...s.messages, - [chatId]: [...existing, msg], - } - persistMessages(updated) - return { messages: updated } - }), + addMessage: (chatId, msg) => { + let inserted = false + set(s => { + const existing = s.messages[chatId] || [] + // Offline messages are replayed after every reconnect. Keep insertion + // and its return value atomic so callers do not count a replay as new. + if (msg.id && existing.some(m => m.id === msg.id)) { + return s + } + const updated = { + ...s.messages, + [chatId]: [...existing, msg], + } + inserted = true + persistMessages(updated) + return { messages: updated } + }) + return inserted + }, updateMessage: (chatId, msgId, patch) => set(s => { const msgs = s.messages[chatId] if (!msgs) return s