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.
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
|
|
// 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 {
|
|
partnerId: number;
|
|
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();
|
|
}
|
|
|
|
// 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.
|
|
emit([
|
|
{ ...existing, minimized: false },
|
|
...chats.filter((c) => c.partnerId !== partner.id),
|
|
]);
|
|
return;
|
|
}
|
|
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 {
|
|
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
|
|
);
|
|
}
|