Files
siftlode/frontend/src/lib/messagesSocket.ts
T
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00

111 lines
3.4 KiB
TypeScript

// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down.
import { getActiveAccount, type Message, type MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window.
interface IncomingMessage {
message: Message;
from?: MessageUser;
to?: MessageUser;
}
type Handler = (e: IncomingMessage) => void;
type UnreadHandler = () => void;
const handlers = new Set<Handler>();
const unreadHandlers = new Set<UnreadHandler>();
let ws: WebSocket | null = null;
let started = false;
let backoff = 1000;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side). The account is read once
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`;
}
function open() {
try {
ws = new WebSocket(url());
} catch {
schedule();
return;
}
ws.onopen = () => {
backoff = 1000;
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data?.type === "message" && data.message) {
const e: IncomingMessage = {
message: data.message as Message,
from: data.from,
to: data.to,
};
for (const h of handlers) h(e);
} else if (data?.type === "unread") {
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
for (const h of unreadHandlers) h();
}
} catch {
/* ignore malformed frames */
}
};
ws.onclose = () => {
ws = null;
if (started) schedule();
};
ws.onerror = () => {
ws?.close();
};
}
function schedule() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (started) open();
}, backoff);
backoff = Math.min(backoff * 2, 30000);
}
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
// last one leaves.
export function onMessage(h: Handler): () => void {
handlers.add(h);
if (!started) {
started = true;
open();
}
return () => {
handlers.delete(h);
if (handlers.size === 0) {
started = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ws?.close();
ws = null;
}
};
}
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
// registers a callback — it does not open the socket on its own.
export function onUnread(h: UnreadHandler): () => void {
unreadHandlers.add(h);
return () => {
unreadHandlers.delete(h);
};
}