feat(messages): auto-open/flash dock on incoming + persist dock across reload

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.
This commit is contained in:
npeter83 2026-06-25 22:46:54 +02:00
parent 3fd71cd316
commit 8392037e5f
4 changed files with 110 additions and 23 deletions

View file

@ -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 (
<div className="pointer-events-auto w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div
className={`pointer-events-auto w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden transition-all duration-300 ${
flashing ? "ring-2 ring-accent" : "ring-0"
}`}
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
onClick={() => toggleMinimize(chat.partnerId)}

View file

@ -1,6 +1,7 @@
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
// A window can be opened from anywhere (e.g. a conversation's "pop out" button) and stays
// open across page navigation. Plain external store so any component can read/drive it.
// A window can be opened from anywhere (a conversation's "pop out" button, or automatically
// when a message arrives) and stays open across page navigation. The dock state (which chats
// are open + their minimised state) is persisted per user so it survives a reload.
import { useSyncExternalStore } from "react";
export interface DockChat {
@ -8,18 +9,53 @@ export interface DockChat {
name: string;
avatar: string | null;
minimized: boolean;
flash: number; // bumped to trigger a one-shot attention flash on the window
}
type PartnerLike = { id: number; name: string; avatar_url: string | null };
const MAX_OPEN = 3; // keep the dock from overflowing the corner
let chats: DockChat[] = [];
let userId: number | null = null;
const subs = new Set<() => void>();
function storageKey(): string | null {
return userId != null ? `siftlode.chatDock.${userId}` : null;
}
function persist(): void {
const k = storageKey();
if (!k) return;
try {
// Don't persist the transient flash counter.
localStorage.setItem(k, JSON.stringify(chats.map(({ flash, ...c }) => c)));
} catch {
/* ignore quota/serialization errors */
}
}
function emit(next: DockChat[]): void {
chats = next;
persist();
for (const s of subs) s();
}
export function openChat(partner: { id: number; name: string; avatar_url: string | null }): void {
// Bind the dock to the signed-in user and restore their persisted windows (once per user).
export function initDock(uid: number): void {
if (userId === uid) return;
userId = uid;
let restored: DockChat[] = [];
try {
const raw = localStorage.getItem(storageKey()!);
if (raw) restored = (JSON.parse(raw) as Omit<DockChat, "flash">[]).map((c) => ({ ...c, flash: 0 }));
} catch {
restored = [];
}
chats = restored.slice(0, MAX_OPEN);
for (const s of subs) s();
}
export function openChat(partner: PartnerLike): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
@ -29,11 +65,28 @@ export function openChat(partner: { id: number; name: string; avatar_url: string
]);
return;
}
const next = [
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false },
...chats,
].slice(0, MAX_OPEN);
emit(next);
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 0 },
...chats,
].slice(0, MAX_OPEN)
);
}
// An incoming message arrived from `partner`: open the window if it's closed, otherwise just
// flash it (whether expanded or minimised — don't disturb the minimised state).
export function notifyIncoming(partner: PartnerLike): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (!existing) {
emit(
[
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false, flash: 1 },
...chats,
].slice(0, MAX_OPEN)
);
return;
}
emit(chats.map((c) => (c.partnerId === partner.id ? { ...c, flash: c.flash + 1 } : c)));
}
export function closeChat(partnerId: number): void {

View file

@ -2,9 +2,15 @@
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import type { Message } from "./api";
import type { Message, MessageUser } from "./api";
type Handler = (msg: Message) => void;
// A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage {
message: Message;
from?: MessageUser;
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
const handlers = new Set<Handler>();
let ws: WebSocket | null = null;
@ -31,7 +37,8 @@ function open() {
try {
const data = JSON.parse(ev.data);
if (data?.type === "message" && data.message) {
for (const h of handlers) h(data.message as Message);
const e: IncomingMessage = { message: data.message as Message, from: data.from, to: data.to };
for (const h of handlers) h(e);
}
} catch {
/* ignore malformed frames */