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:
parent
2f0ca68e8a
commit
3fd71cd316
14 changed files with 518 additions and 347 deletions
56
frontend/src/lib/chatDock.ts
Normal file
56
frontend/src/lib/chatDock.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
21
frontend/src/lib/messaging.ts
Normal file
21
frontend/src/lib/messaging.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Shared messaging helpers used by both the Messages page and the floating chat dock.
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
|
||||
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||
|
||||
// Set-once cache of partner public keys (the server is the directory; keys never change).
|
||||
const pubCache = new Map<number, string>();
|
||||
export async function partnerPub(partnerId: number): Promise<string> {
|
||||
const cached = pubCache.get(partnerId);
|
||||
if (cached) return cached;
|
||||
const r = await api.messagePublicKey(partnerId);
|
||||
pubCache.set(partnerId, r.public_key);
|
||||
return r.public_key;
|
||||
}
|
||||
|
||||
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||
export function useUnlocked(): boolean {
|
||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ export const PAGES = [
|
|||
"config",
|
||||
"users",
|
||||
"notifications",
|
||||
"messages",
|
||||
] as const;
|
||||
|
||||
export type Page = (typeof PAGES)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue