2026-06-25 22:34:24 +02:00
|
|
|
// App-wide store of open floating chat windows (the Messenger-style dock, bottom-right).
|
2026-06-25 22:46:54 +02:00
|
|
|
// 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.
|
2026-06-25 22:34:24 +02:00
|
|
|
import { useSyncExternalStore } from "react";
|
|
|
|
|
|
|
|
|
|
export interface DockChat {
|
|
|
|
|
partnerId: number;
|
|
|
|
|
name: string;
|
|
|
|
|
avatar: string | null;
|
|
|
|
|
minimized: boolean;
|
2026-06-25 22:46:54 +02:00
|
|
|
flash: number; // bumped to trigger a one-shot attention flash on the window
|
2026-06-25 22:34:24 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 22:46:54 +02:00
|
|
|
type PartnerLike = { id: number; name: string; avatar_url: string | null };
|
|
|
|
|
|
2026-06-25 22:34:24 +02:00
|
|
|
const MAX_OPEN = 3; // keep the dock from overflowing the corner
|
|
|
|
|
let chats: DockChat[] = [];
|
2026-06-25 22:46:54 +02:00
|
|
|
let userId: number | null = null;
|
2026-06-25 22:34:24 +02:00
|
|
|
const subs = new Set<() => void>();
|
|
|
|
|
|
2026-06-25 22:46:54 +02:00
|
|
|
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 */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 22:34:24 +02:00
|
|
|
function emit(next: DockChat[]): void {
|
|
|
|
|
chats = next;
|
2026-06-25 22:46:54 +02:00
|
|
|
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);
|
2026-06-25 22:34:24 +02:00
|
|
|
for (const s of subs) s();
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 22:46:54 +02:00
|
|
|
export function openChat(partner: PartnerLike): void {
|
2026-06-25 22:34:24 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-25 22:46:54 +02:00
|
|
|
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)));
|
2026-06-25 22:34:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|