From 8392037e5fa189b37d9facaeaaff941ec8471417 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 25 Jun 2026 22:46:54 +0200 Subject: [PATCH] feat(messages): auto-open/flash dock on incoming + persist dock across reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-message push now carries both parties, so an incoming message opens that conversation's dock window if it's closed, or flashes it once if it's already open (expanded or minimised, without disturbing the minimised state) — only for messages from someone else, never your own echo. Dock state (open windows + minimised state) is persisted per user, so a reload restores exactly what was open, minimised, or closed. --- backend/app/routes/messages.py | 9 +++- frontend/src/components/ChatDock.tsx | 42 ++++++++++++----- frontend/src/lib/chatDock.ts | 69 ++++++++++++++++++++++++---- frontend/src/lib/messagesSocket.ts | 13 ++++-- 4 files changed, 110 insertions(+), 23 deletions(-) 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 ( -
+