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

@ -31,6 +31,17 @@ const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource;
let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key
const convKeys = new Map<number, CryptoKey>(); // partnerId → derived AES key
// Unlock state is shared app-wide (the Messages page and the floating chat dock both observe
// it), so expose a subscription for useSyncExternalStore.
const unlockSubs = new Set<() => void>();
function emitUnlock(): void {
for (const cb of unlockSubs) cb();
}
export function subscribeUnlock(cb: () => void): () => void {
unlockSubs.add(cb);
return () => unlockSubs.delete(cb);
}
export function isUnlocked(): boolean {
return !!privKey;
}
@ -38,6 +49,7 @@ export function isUnlocked(): boolean {
export function lock(): void {
privKey = null;
convKeys.clear();
emitUnlock();
}
// --- IndexedDB (per-device persistence of the unlocked private key) ---
@ -100,6 +112,7 @@ export async function loadFromDevice(userId: number): Promise<boolean> {
const stored = await idbGet(userId);
if (stored) {
privKey = stored;
emitUnlock();
return true;
}
return false;
@ -120,6 +133,7 @@ export async function setup(userId: number, passphrase: string): Promise<KeySetu
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
return {
public_key: b64(spki),
wrapped_private_key: b64(wrapped),
@ -145,6 +159,7 @@ export async function unlock(userId: number, passphrase: string, bundle: MyKeyBu
privKey = await importPrivate(pkcs8);
convKeys.clear();
await idbPut(userId, privKey);
emitUnlock();
}
// --- per-conversation encryption ---