feat(messages): standalone Messages module + floating chat dock

Move messaging out of the notification center into its own left-nav module
(own page, own unread badge), so a reload returns to it and notifications stay
separate. Add a Messenger-style floating dock (bottom-right): pop a conversation
out from the page, keep chatting across navigation, minimise (rollup) or close.

- Messages is now a routed page; NotificationsPanel reverts to inbox-only and the
  nav badge no longer mixes in messages.
- Extract shared KeyGate + ChatThread (used by both the page and dock windows);
  e2ee exposes a shared unlock subscription so page and dock agree.
- ChatDock (always mounted for human users) owns the app-wide live-message
  subscription and per-device key restore. EN/HU/DE strings.
This commit is contained in:
npeter83 2026-06-25 22:34:24 +02:00
parent 2f0ca68e8a
commit 3fd71cd316
14 changed files with 518 additions and 347 deletions

View file

@ -0,0 +1,56 @@
// 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.
import { useSyncExternalStore } from "react";
export interface DockChat {
partnerId: number;
name: string;
avatar: string | null;
minimized: boolean;
}
const MAX_OPEN = 3; // keep the dock from overflowing the corner
let chats: DockChat[] = [];
const subs = new Set<() => void>();
function emit(next: DockChat[]): void {
chats = next;
for (const s of subs) s();
}
export function openChat(partner: { id: number; name: string; avatar_url: string | null }): void {
const existing = chats.find((c) => c.partnerId === partner.id);
if (existing) {
// Re-opening focuses it: ensure expanded and move to the front.
emit([
{ ...existing, minimized: false },
...chats.filter((c) => c.partnerId !== partner.id),
]);
return;
}
const next = [
{ partnerId: partner.id, name: partner.name, avatar: partner.avatar_url, minimized: false },
...chats,
].slice(0, MAX_OPEN);
emit(next);
}
export function closeChat(partnerId: number): void {
emit(chats.filter((c) => c.partnerId !== partnerId));
}
export function toggleMinimize(partnerId: number): void {
emit(chats.map((c) => (c.partnerId === partnerId ? { ...c, minimized: !c.minimized } : c)));
}
export function useDockChats(): DockChat[] {
return useSyncExternalStore(
(cb) => {
subs.add(cb);
return () => subs.delete(cb);
},
() => chats,
() => chats
);
}