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 (
-