diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index f7240ae..59998a4 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -325,7 +325,14 @@ def send_message( db.add(m) db.commit() db.refresh(m) - event = {"type": "message", "message": _serialize_msg(m)} + # Carry both parties so each recipient can open/flash the right dock window (the partner is + # whichever of from/to isn't them). + event = { + "type": "message", + "message": _serialize_msg(m), + "from": _serialize_user(user), + "to": _serialize_user(recipient), + } manager.push(recipient.id, event) manager.push(user.id, event) # the sender's other open devices return _serialize_msg(m) diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index e0902ff..ba24db7 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -1,8 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronUp, X } from "lucide-react"; -import { closeChat, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; +import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; import { useUnlocked } from "../lib/messaging"; import { onMessage } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; @@ -10,27 +10,33 @@ import Avatar from "./Avatar"; import ChatThread from "./ChatThread"; // The Messenger-style floating chat dock: open conversations live here, bottom-right, across -// page navigation. Always mounted (for human users) so it also owns the app-wide live-message -// subscription and restores this device's unlock state on load. +// page navigation (state persisted per user). Always mounted (for human users) so it also owns +// the app-wide live-message subscription and restores this device's unlock state on load. export default function ChatDock({ meId }: { meId: number }) { const qc = useQueryClient(); const chats = useDockChats(); const unlocked = useUnlocked(); - // Restore the per-device unlocked key once, app-wide. + // Restore the per-device unlocked key and the persisted dock windows once, app-wide. useEffect(() => { + initDock(meId); e2ee.loadFromDevice(meId); }, [meId]); - // Live delivery: any pushed message refreshes the conversation list, unread badge, and the - // affected thread (re-decrypts). Drives both the dock and the Messages page. + // Live delivery: refresh the conversation list, unread badge, and the affected thread; and + // for an INCOMING message, open or flash that partner's dock window. useEffect( () => - onMessage((m) => { + onMessage(({ message: m, from }) => { qc.invalidateQueries({ queryKey: ["conversations"] }); qc.invalidateQueries({ queryKey: ["message-unread"] }); - const partner = m.sender_id === meId ? m.recipient_id : m.sender_id; - qc.invalidateQueries({ queryKey: ["thread", partner] }); + const partnerId = m.sender_id === meId ? m.recipient_id : m.sender_id; + qc.invalidateQueries({ queryKey: ["thread", partnerId] }); + // Auto-open/flash only for messages from someone else (not my own echo) and only for + // real user conversations. + if (m.kind === "user" && m.sender_id !== meId && from) { + notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url }); + } }), [qc, meId] ); @@ -47,8 +53,22 @@ export default function ChatDock({ meId }: { meId: number }) { function ChatWindow({ chat, meId, unlocked }: { chat: DockChat; meId: number; unlocked: boolean }) { const { t } = useTranslation(); + const [flashing, setFlashing] = useState(false); + + // One-shot attention flash whenever a new message bumps the counter. + useEffect(() => { + if (chat.flash <= 0) return; + setFlashing(true); + const id = setTimeout(() => setFlashing(false), 800); + return () => clearTimeout(id); + }, [chat.flash]); + return ( -
+