multi-network: tab title sums unread across all networks (dynamic-subscription hook)

This commit is contained in:
Jean Chevronnet 2026-07-04 09:50:02 +00:00
parent 7a0624bde2
commit 688bd5daea
No known key found for this signature in database
2 changed files with 33 additions and 3 deletions

View file

@ -6,7 +6,7 @@ import { Chat } from './components/Chat';
import { refreshPush } from './services/push';
import { getConfig } from './core/config';
import { usePluginRegistry, matchShortcut } from './modules/registry';
import { useActiveChat, activeStore } from './core/networks';
import { activeStore, useAllNetworksUnread } from './core/networks';
import { useChat } from './core/store';
// Shown while a site handoff connects, so visitors who already chose a pseudo
@ -28,8 +28,8 @@ export default function App() {
const status = useChat((s) => s.status);
const everRegistered = useChat((s) => s.everRegistered);
const autoConnecting = useChat((s) => s.autoConnecting);
const unread = useActiveChat((s) =>
Object.values(s.buffers).reduce((acc, b) => acc + b.unread, 0));
// Tab title reflects unread across ALL connected networks, not just the active one.
const unread = useAllNetworksUnread();
useEffect(() => {
// Title follows the configured brand name (config.branding.name) — the single

View file

@ -5,6 +5,7 @@
// networks and which one is ACTIVE; the chat UI reads the active network's store
// (routing + UI land in later increments). Today it holds only the primary
// network, so single-network behaviour is exactly preserved.
import { useSyncExternalStore } from 'react';
import { create, useStore } from 'zustand';
import { createChatStore, useChat } from './store';
import { getConfig } from './config';
@ -93,6 +94,35 @@ export function useActiveChat<T>(selector: (s: ChatState) => T): T {
return useStore(store, selector);
}
// ── total unread across ALL networks (for the tab title) ────────────────────
// A dynamic-subscription hook: subscribe to every network's store AND to the
// registry, re-wiring the per-store subscriptions only when the networks list
// actually changes (add/remove) — NOT on a plain switch (setActive keeps the same
// array ref). The snapshot is a number, so referential equality stops any loop.
const unreadOf = (store: ChatStore) =>
Object.values(store.getState().buffers).reduce((a, b) => a + (b.unread || 0), 0);
function totalUnread(): number {
return useNetworks.getState().networks.reduce((sum, n) => sum + unreadOf(n.store), 0);
}
function subscribeUnread(cb: () => void): () => void {
let list = useNetworks.getState().networks;
let unsubs = list.map((n) => n.store.subscribe(cb));
const off = useNetworks.subscribe((s) => {
if (s.networks !== list) { // networks added/removed → re-wire
list = s.networks;
unsubs.forEach((u) => u());
unsubs = list.map((n) => n.store.subscribe(cb));
}
cb();
});
return () => { off(); unsubs.forEach((u) => u()); };
}
/** Sum of unread across every connected network (single-network: = the primary). */
export function useAllNetworksUnread(): number {
return useSyncExternalStore(subscribeUnread, totalUnread);
}
/** Re-add + reconnect the extra networks saved from a previous session. Called
* once at boot; each entry is isolated so one bad/failed network can't block the
* rest or the app. Passwords come from sessionStorage (may be absent guest). */