Two tabs in one browser can now run two different signed-in accounts at once. - The signed session cookie stays the browser's account WALLET (account_ids). Which account a given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the X-Siftlode-Account header; current_user honours it only for an account already in the wallet, without mutating the cookie's default account. Switching accounts sets the header + reloads THIS tab only, instead of the old cookie-wide switch that changed every tab. - WebSocket can't send headers, so the per-tab account rides in the ?account= query param (validated against the wallet). - Logout is per-tab aware: it signs the requesting tab's active account out of the wallet (promoting a new default only if the removed one was the default), and the tab drops its override. A stale per-tab header account 401s just that tab instead of clearing the session. - Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up immediately instead of the browser running a heuristically-cached stale index.html.
89 lines
2.5 KiB
TypeScript
89 lines
2.5 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.
|
|
export interface IncomingMessage {
|
|
message: Message;
|
|
from?: MessageUser;
|
|
to?: MessageUser;
|
|
}
|
|
type Handler = (e: IncomingMessage) => void;
|
|
|
|
const handlers = new Set<Handler>();
|
|
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).
|
|
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);
|
|
}
|
|
} 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;
|
|
}
|
|
};
|
|
}
|