// First, on purpose: loading it takes an invitation link out of the address
// before anything else here can read, log or route on it (invite-link.js).
import { clearPending, loadPending } from './invite-link.js';
import {
html, render, useState, useEffect, useLayoutEffect, useCallback, useRef,
createContext, useContext,
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed, etaSeconds } from './transfers.js';
import * as platform from './platform.js';
import * as downloads from './downloads.js';
import { Icon } from './icon.js';
import { formatSize } from './file-utils.js';
import {
HUB, navigate, session, purgeGroupIndexCache,
_storeBundleKey, _loadBundleKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch,
refreshAccessToken, logoutOnHub,
} from './hub-client.js';
import { startIdleWatch, markActive } from './idle.js';
import { GroupPage } from './group-page.js';
import { lazy } from './lazy.js';
import { ConnectionPool } from './connection-pool.js';
import { MusicPlayerBar } from './music-player.js';
import { NameModal } from './playlist-menu.js';
import {
saveQueueAsPlaylist, setPlaylistTransport, pullOnce, flushPush,
} from './playlists.js';
import { IndexingDock } from './index-dock.js';
import { SettingsPage } from './settings-page.js';
import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
import { GroupName } from './group-name.js';
import { FirstRunPage, LoginPage, RegisterPage, ResetPasswordPage } from './auth-page.js';
import { InvitePage, JoinByLink } from './invite-page.js';
// ── Constants ────────────────────────────────────────────────────────────────
// How often to look. Cheap — it reads a timestamp out of the token and almost
// always does nothing.
const TOKEN_CHECK_MS = 60000;
const THEME_KEY = 'mb_theme';
// ── Theme ────────────────────────────────────────────────────────────────────
function getInitialTheme() {
const stored = localStorage.getItem(THEME_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
return 'system';
}
function resolveTheme(pref) {
if (pref === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return pref;
}
// ── Router ───────────────────────────────────────────────────────────────────
function useRoute() {
const [hash, setHash] = useState(window.location.hash.slice(1) || '/');
useEffect(() => {
const onHash = () => setHash(window.location.hash.slice(1) || '/');
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
return hash;
}
// ── Context ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
function useAuth() { return useContext(AuthContext); }
// The M of the wordmark is a picture; the rest is text. Resolved from this
// module's own URL so the hub's fingerprinted path and the application's
// app:// scheme both come out right without either being named here.
const BRAND_M = new URL('./meshbay-m.png', import.meta.url).href;
// ── User Menu ────────────────────────────────────────────────────────────────
function UserMenu({ user, theme, onThemeChange, onLogout }) {
const [open, setOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
const resolved = resolveTheme(theme);
return html`
${open && html`
${user.username[0].toUpperCase()}
${user.username}
${user.role || 'user'}
${langOpen && LOCALES.map(l => html`
`)}
`}
`;
}
// ── Transfers widget ─────────────────────────────────────────────────────────
function TransferWidget() {
const [items, setItems] = useState(() => transfers.list());
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => transfers.subscribe(setItems), []);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
const running = items.filter(i => i.status === 'running');
const waiting = items.filter(
i => i.status === 'queued' || i.status === 'preparing');
// Its own group, and not a leftover.
//
// "Finished" used to be defined as everything that is not running, queued or
// preparing — a definition by exclusion, which quietly swallowed `paused` the
// day pausing shipped. A transfer somebody stopped on purpose then sat under
// "Finished", beside the ones that are actually over, offering a resume
// button in the section of things that cannot be resumed.
const paused = items.filter(i => i.status === 'paused');
const finished = items.filter(
i => i.status !== 'running' && i.status !== 'queued'
&& i.status !== 'preparing' && i.status !== 'paused');
// Paused counts as active: it is not over, the person means to come back to
// it, and the badge saying nothing is happening would be a lie.
const active = running.length + waiting.length + paused.length;
// Grouped, and in this order: what is moving, what is waiting, what is over.
// Re-sorting the flat list on every emit made rows jump under the pointer
// each time a neighbour finished — the group is what changes, not the
// position within it, so a row only moves when its own state does.
const groups = [
['running', running],
['waiting', waiting],
['paused', paused],
['finished', finished],
].filter(([, rows]) => rows.length);
if (!items.length) return null;
return html`
${/* One live region for the panel, announcing what changed state rather
than every progress tick — a reader that says "62%… 63%… 64%" for a
four-gigabyte film is a reader nobody leaves on. */''}
${t('transfers.summary', { running: running.length, waiting: waiting.length })}
${open && html`
`;
}
/** One row. Split out so the panel above reads as a layout and this as a state
* machine — they change for different reasons. */
function TransferRow({ it }) {
const eta = etaSeconds(it);
return html`
<${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
${it.canOpen
? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}`
: html`${it.name}`}
${it.pausable && (it.status === 'running' || it.status === 'paused')
&& html`
${/* Offered only where the target can actually do it: a
service-worker stream is a download the browser already owns,
and a pause there would restart from zero. */''}
`}
${!it.pausable && downloads.SUPPORTED && it.kind === 'download'
&& (it.status === 'running' || it.status === 'queued') && html`
${/* Say why, rather than leaving a gap where a button is on the row
above. Without a granted folder this browser writes through the
service worker — a download it already owns, which cannot be
paused — so the button is absent for a reason nobody can see,
and an upload beside it has one. Shown only where choosing a
folder is actually possible: on Firefox and Safari there is no
folder to choose and this hint would be a lie. */''}
<${Icon} name="pause" />
`}
${(it.status === 'running' || it.status === 'queued'
|| it.status === 'preparing' || it.status === 'paused') && html`
`}
${it.status === 'preparing'
? html`
${/* Not a progress bar at 0%: nothing is wrong and nothing is
stalled, the download is still finding somewhere to write. The
row exists from the click precisely so this state is visible
instead of being an empty panel. */''}
`
: it.status === 'paused'
? html`
${/* The bar keeps its fill: what has been written is still there,
and resuming continues from it rather than starting again. */''}
`;
}
/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose
* speed varies is a number that is wrong most of the time and looks precise. */
function formatEta(seconds) {
if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) });
if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) });
return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 });
}
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
hubUnset }) {
return html`
`;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey,
allowPublicGroups = true }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
`;
}
// The legal pages of the hub in use: the page's own origin in a browser, the
// configured hub in the application. A new tab either way: the application
// refuses to navigate away from its interface and hands a new window to the
// system browser instead, and in a browser it keeps the session on screen.
function legalUrl() {
return `${platform.hubBase().replace(/\/$/, '')}/legal/`;
}
// ── Home Page ────────────────────────────────────────────────────────────────
function NotificationFeed({ notifications, onMarkRead, onPurge }) {
if (!notifications.length) return null;
return html`
${t('notif.title')}
${notifications.map(n => html`
{
// Reading it is the point of clicking it: it goes, here and in the
// count, rather than sitting there greyed out.
onMarkRead(n.id);
if (n.link) navigate(n.link);
}}>
${n.kind}${n.title}${new Date(n.created_at).toLocaleDateString()}
`;
}
// ── First-run welcome (Electron-only, shown once on empty home) ─────────────
function SetupWelcome({ onDismiss }) {
// An install-time NSIS page used to be the only place this choice was
// ever offered, and an AppX/MSIX install has no install-time page at all
// (no custom actions, full stop, not just no elevation) -- so a build
// running its own bundled node needs to say so somewhere the user will
// actually see it, not just leave the choice sitting unfound on the Node
// page. Shown only while neither startup mode is configured yet; it
// disappears on its own once one is (or stays hidden forever if the
// platform has no node.service at all, e.g. Light, non-Windows, browser).
const [startupHint, setStartupHint] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!platform.node.available || !(await platform.node.bundled())) return;
if (!platform.node.service.available) return;
const status = await platform.node.service.status();
const configured = status.mode === 'service' || Boolean(status.autostart);
if (!cancelled && status.supported !== false && !configured) setStartupHint(true);
} catch { /* best effort -- the Node page itself is the source of truth */ }
})();
return () => { cancelled = true; };
}, []);
return html`
`;
}
// ── Lazy-loaded Search page ──────────────────────────────────────────────────
//
// It renders every app's results, so importing it here would bring every app
// into the first download.
const SearchPage = lazy(() => import('./search-page.js'), 'SearchPage',
html`
`);
// ── Lazy-loaded Create Group page ────────────────────────────────────────────
let _CreateGroupPage = null;
function LazyCreateGroupPage(props) {
const [loaded, setLoaded] = useState(!!_CreateGroupPage);
useEffect(() => {
if (!_CreateGroupPage) {
import('./create-group-page.js').then(m => { _CreateGroupPage = m.CreateGroupPage; setLoaded(true); });
}
}, []);
if (!loaded) return html`
`;
return html`<${_NodePage} ...${props} />`;
}
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
// Signed in, on the sign-in or sign-up form. A session restored under a
// `#/login` still in the address bar drew the login form, prefilled by the
// browser, beneath a navigation bar already showing the account. Home instead,
// with `replace` so Back does not lead to the form again. Not `#/reset`: that
// flow signs in half-way and still has its progress and result to show.
const onAuthForm = route === '/login' || route === '/register';
useEffect(() => {
// An invitation waiting in this tab is where a sign-in was headed.
if (user && onAuthForm) window.location.replace(loadPending() ? '#/invite' : '#/');
}, [user, onAuthForm]);
// A desktop build with a remembered device signs in without asking. Null
// until it has tried, so nothing renders a sign-in form the user is about to
// be taken past.
const [deviceTried, setDeviceTried] = useState(!platform.device.available);
// Native, and nowhere to talk to yet.
const [needsHub, setNeedsHub] = useState(
platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifDisabled, setNotifDisabled] = useState(false);
const [userPrefs, setUserPrefs] = useState({});
const [hasNodeKey, setHasNodeKey] = useState(false);
// Instance policy, fetched once, unauthenticated. `null` until it answers;
// treat unknown as "allowed" so a slow hub never blocks a legitimate private
// group — the hub refuses a public one server-side regardless.
const [hubInfo, setHubInfo] = useState(null);
const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false;
// -- Persistent music player (lifted from group-page.js) --
const [musicQueue, setMusicQueue] = useState(null);
const musicPoolRef = useRef(null);
const userRef = useRef(user);
userRef.current = user;
const groupTransportRef = useRef(null);
useEffect(() => {
musicPoolRef.current = new ConnectionPool(HUB);
return () => { if (musicPoolRef.current) musicPoolRef.current.closeAll(); };
}, []);
const getMusicConnection = useCallback(async (groupId) => {
const gt = groupTransportRef.current;
if (gt && gt.groupId === groupId) {
const tr = gt.transportRef.current;
if (tr && tr.connected) return { transport: tr, gek: gt.gekRef.current };
}
const u = userRef.current;
if (!u || !musicPoolRef.current) throw new Error('no connection');
const bundleKey = session.bundleKey || await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
const conn = await musicPoolRef.current.connect(
groupId, u.token, bundleKey, u.username, u.userId);
return { transport: conn.transport, gek: conn.gek };
}, []);
// `op` is 'replace' (the default, and what playing an album or loading a
// playlist means), 'next', or 'append' — docs/playlists.md §9.2.
const handlePlayQueue = useCallback((tracks, startIndex, source, op) => {
const how = op || 'replace';
// A single-slot fast path for the group whose page is open, so playing
// from it reuses the live transport instead of dialing through the pool.
// Only a replace is about that group: repointing it because one track
// from somewhere else was enqueued would drop whatever is *playing* back
// to the pool, for nothing.
if (how === 'replace') {
if (source && source.transportRef) {
groupTransportRef.current = {
groupId: source.groupId,
transportRef: source.transportRef,
gekRef: source.gekRef,
};
} else {
groupTransportRef.current = null;
}
}
setMusicQueue({ tracks, startIndex, nonce: Date.now(), op: how });
}, []);
const handleStopMusic = useCallback(() => setMusicQueue(null), []);
// ── Playlists reach a node through here ────────────────────────────────
//
// `playlists.js` pushes on every write and needs a transport to push
// through; the shell is what owns connections. Preferring the group already
// open costs nothing, and dialing when there is none is a change of position
// that is worth stating: §7 refused dialing *at sign-in for a feature nobody
// had asked to use*. Somebody who has just made a playlist has asked, and one
// dial to save their work is cheaper than losing it.
useEffect(() => {
if (!user) { setPlaylistTransport(null); return undefined; }
setPlaylistTransport(async () => {
const gt = groupTransportRef.current;
const live = gt && gt.transportRef && gt.transportRef.current;
if (live && live.connected) return live;
// Bounded: an account with twenty groups, all offline, must not spend
// twenty ten-second timeouts on a background push.
for (const g of groups.slice(0, 3)) {
try {
const conn = await getMusicConnection(g.id);
if (conn && conn.transport && conn.transport.connected) return conn.transport;
} catch { /* try the next one */ }
}
return null;
});
// A pending push has a second and a half to wait, and closing a laptop
// inside that window is not rare. `pagehide` covers a tab closing and a
// navigation; `visibilitychange` covers a phone being backgrounded, which
// on iOS is the only one of the two that reliably fires at all.
const flush = () => { flushPush().catch(() => {}); };
const onHidden = () => { if (document.visibilityState === 'hidden') flush(); };
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', onHidden);
return () => {
window.removeEventListener('pagehide', flush);
document.removeEventListener('visibilitychange', onHidden);
setPlaylistTransport(null);
};
}, [user, groups, getMusicConnection]);
// Reconcile once per sign-in, whatever this browser already holds.
//
// Nothing went looking until a group's Music tab happened to be opened, which
// is not where anybody looks for a playlist — and bounding this to an empty
// device, as the first version did, left out the ordinary case: a phone that
// has nine playlists and is missing the tenth.
//
// `groups.length` moves as the sidebar fills, so the ref is what makes "once"
// mean once rather than once per group that arrives.
const pulledForRef = useRef(null);
useEffect(() => {
if (!user || !groups.length) return;
if (pulledForRef.current === user.userId) return;
pulledForRef.current = user.userId;
pullOnce(user.userId).catch(() => {});
}, [user, groups.length]);
// Saving the queue is a shell-level action because the queue is: the player
// bar outlives every page, and the account it belongs to is here.
const [saveQueue, setSaveQueue] = useState(null);
const handleSaveQueue = useCallback((rows) => setSaveQueue(rows), []);
const resolved = resolveTheme(theme);
// The name of the last session. A failed renewal clears the stored session,
// name included, and the device sign-in below needs it to try again.
const lastUsernameRef = useRef(user ? user.username : null);
if (user) lastUsernameRef.current = user.username;
// Set by a deliberate sign-out, which the device key must not undo.
const signedOutRef = useRef(false);
// Sign in with this device's key. Desktop only: resolves false in a browser,
// or with no key, or with a key the hub no longer knows.
const signInWithDevice = useCallback(async (username) => {
if (!username || !platform.device.available) return false;
try {
const signed = await platform.device.sign(username);
if (!signed) return false;
const data = await hubFetch('/v1/users/auth', {
method: 'POST',
body: { username, timestamp: signed.timestamp,
signature: signed.signature },
});
const me = await hubFetch('/v1/users/me', { token: data.access_token });
const u = { username, userId: me.user_id, token: data.access_token,
refreshToken: data.refresh_token, role: me.role };
signedOutRef.current = false;
setAuth(u);
setUser(u);
return true;
} catch {
return false;
}
}, []);
// Keep the session alive without anyone having to think about it.
useEffect(() => {
// A renewal can happen inside hubFetch, well away from any render. This is
// how the component learns about it — including a failed one, which sets
// null and lands on the login page instead of failing every later call.
setAuthChangeListener((auth) => {
setUser(auth);
// A renewal the hub refused — the session outlived its idle window, say,
// on a laptop that slept through it. The desktop application signs back
// in with its device key instead of showing the form; a browser has none.
if (!auth && !signedOutRef.current) signInWithDevice(lastUsernameRef.current);
});
// On mount above all: a tab reopened tomorrow holds an hour-old access
// token and a refresh token good for a month, and used to greet its owner
// with "invalid token" rather than spending the second on renewing it.
ensureFreshToken();
const timer = setInterval(ensureFreshToken, TOKEN_CHECK_MS);
// A backgrounded tab has its timers throttled hard, so the check above may
// not have run for the whole time it was away. Coming back is exactly when
// the token is most likely to be stale.
const onVisible = () => {
if (document.visibilityState === 'visible') ensureFreshToken();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
setAuthChangeListener(null);
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, []);
useEffect(() => {
document.documentElement.className = `theme-${resolved}`;
localStorage.setItem(THEME_KEY, theme);
}, [theme, resolved]);
useEffect(() => {
hubFetch('/v1/hub/info').then(setHubInfo).catch(() => {});
}, []);
const fetchNotifications = useCallback(() => {
if (!user || notifDisabled) {
setNotifications([]); setUnreadCount(0); return;
}
// `unread_only`: clicking one is what dismisses it (see markRead), so a
// read notification is a dismissed notification and must not come back on
// the next launch. Without this the two halves disagreed — the click
// removed it here and marked it read on the hub, and the next startup
// asked for everything and put it straight back. `unread_count` is
// computed server-side and is unaffected by the filter.
hubFetch('/v1/notifications?limit=20&unread_only=true', { token: user.token })
.then(data => {
setNotifications(data.notifications || []);
setUnreadCount(data.unread_count || 0);
})
.catch(() => {});
}, [user, notifDisabled]);
useEffect(() => {
if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
hubFetch('/v1/users/me/preferences', { token: user.token })
.then(prefs => {
setUserPrefs(prefs || {});
if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
})
.catch(() => {});
if (platform.capabilities.nodeAdmin) {
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
.then(data => setHasNodeKey(Boolean(data.pk_node_ed25519)))
.catch(() => {});
}
fetchNotifications();
}, [user]);
// The group list lives here, so an edit made three components down has to come
// back up rather than be re-fetched: a reload would drop the WebRTC connection
// the page is holding.
const updateGroup = useCallback((gid, patch) => {
setGroups(prev => prev.map(g => (g.id === gid ? { ...g, ...patch } : g)));
}, []);
// What this browser saw for itself, which beats what the hub reported. Held
// for the session only: it is a cache of observations, not a source of truth,
// and a reload should go back to asking.
const [presence, setPresence] = useState({});
// Percentage alongside 'indexing' presence — kept separate from `presence`
// itself so a changing % does not require treating every tick as a new
// presence state (see the Sidebar dot's title/aria-label).
const [indexProgressPct, setIndexProgressPct] = useState({});
const notePresence = useCallback((gid, state, pct) => {
setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state }));
if (pct !== undefined) {
setIndexProgressPct(prev => (prev[gid] === pct ? prev : { ...prev, [gid]: pct }));
}
}, []);
const handleLeftGroup = useCallback((gid) => {
setGroups(prev => prev.filter(g => g.id !== gid));
setPresence(prev => {
const next = { ...prev };
delete next[gid];
return next;
});
navigate('/');
}, []);
const markRead = useCallback((id) => {
if (!user) return;
// Drop it here and now. Waiting for the round trip leaves it on screen while
// the page navigates, which reads as "the click did nothing".
setNotifications(prev => prev.filter(n => n.id !== id));
setUnreadCount(c => Math.max(0, c - 1));
// DELETE, not `/read`: dismissing one drops the row. The old path still
// works and still deletes, for interfaces older than the hub.
hubFetch(`/v1/notifications/${id}`, { method: 'DELETE', token: user.token })
.catch(() => fetchNotifications());
}, [user, fetchNotifications]);
const purgeNotifications = useCallback(() => {
if (!user) return;
setNotifications([]);
setUnreadCount(0);
hubFetch('/v1/notifications', { method: 'DELETE', token: user.token })
.catch(() => fetchNotifications());
}, [user, fetchNotifications]);
/** Clear the invitation for a group once its code has actually been redeemed. */
const dismissGroupNotifications = useCallback((groupId) => {
if (!user) return;
setNotifications(prev => {
const gone = prev.filter(n => n.group_id === groupId && n.kind === 'group_invite');
gone.forEach(n => hubFetch(`/v1/notifications/${n.id}`,
{ method: 'DELETE', token: user.token }).catch(() => {}));
if (gone.length) setUnreadCount(c => Math.max(0, c - gone.length));
return prev.filter(n => !gone.includes(n));
});
}, [user]);
// Sign in with this device's key, once, at startup.
//
// The passphrase stays the account's credential and its recovery path; this
// is what saves entering it every launch. A refusal is not an error worth
// showing — the key may have been retired from another device, or the hub may
// have forgotten it — so it falls through to the ordinary form.
useEffect(() => {
// Nothing to do when a session was restored from storage, or when this is
// a browser. `user` is read once here on purpose: this runs at startup and
// must not re-fire when the session it just created lands.
if (deviceTried || user) { setDeviceTried(true); return; }
let cancelled = false;
(async () => {
// `loadAuth` keeps the username even when the tokens in it are stale,
// and `app://meshbay` is a stable origin, so localStorage survives a
// relaunch. A fresh install has nothing here and asks for a passphrase,
// which is right: the first sign-in is what registers the device. A
// refusal falls through to the sign-in form, the honest outcome.
const saved = loadAuth();
await signInWithDevice(saved && saved.username);
if (!cancelled) setDeviceTried(true);
})();
return () => { cancelled = true; };
}, []);
useEffect(() => { setMenuOpen(false); }, [route]);
const changeTheme = useCallback((val) => {
setTheme(val);
}, []);
/**
* Register this device's hub key, once, after a passphrase sign-in.
*
* Deliberately not fatal: a hub that refuses it, or a machine with no key
* storage, means the passphrase is asked for again next time — which is
* exactly what a browser does, and is a worse experience rather than a
* broken one.
*/
const registerThisDevice = useCallback(async (token) => {
if (!platform.device.available) return;
try {
const backend = await platform.secrets.backend();
if (backend === 'unavailable') return;
const pk = await platform.device.ensure();
if (!pk) return;
await hubFetch('/v1/users/devices', {
method: 'POST', token,
body: { pk_auth_ed25519: pk, label: t('device.this_device') },
});
} catch (err) {
console.warn('device not registered:', err.message);
}
}, []);
const authCtx = {
user,
login: async (username, password) => {
let token, refreshToken;
if (window.MeshBayKeys) {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
token = data.accessToken;
refreshToken = data.refreshToken;
// The only thing sign-in produces: the key that opens a node's bundle.
// Which identity we use is decided per node, when we get there.
session.bundleKey = data.bundleKey;
await _storeBundleKey(session.bundleKey);
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
body: { username, password },
});
token = data.access_token;
refreshToken = data.refresh_token;
}
const me = await hubFetch('/v1/users/me', { token });
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
// On a desktop build, remember this device so the next launch does not ask
// for the passphrase again. The key is generated and held by the main
// process; what travels here is only its public half.
await registerThisDevice(token);
// Before the session lands, so the idle watch starting with it does not
// read the last-active time of whoever used this browser before.
markActive(true);
signedOutRef.current = false;
// setAuth, not saveAuth: it is the one writer that also updates the copy
// hubFetch renews from. Storing the session without it left the renewal
// path with no refresh token to present.
setAuth(u);
setUser(u);
},
logout: () => {
signedOutRef.current = true;
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
// Revoked on the hub too, so a copy of the refresh token is worth
// nothing. Read before the next line clears it.
logoutOnHub();
// An invitation belongs to whoever was about to use it, not to the tab.
clearPending();
setAuth(null);
setUser(null);
setGroups([]);
navigate('/login');
},
};
// A browser signs itself out after a stretch with nobody at it (idle.js). Not
// the desktop application: its owner's machine, which the device key would
// sign straight back in anyway.
const idleHours = hubInfo && hubInfo.browser_idle_hours;
const signedInId = user ? user.userId : null;
useEffect(() => {
if (!signedInId || platform.isNative || !idleHours) return undefined;
return startIdleWatch(idleHours * 3600 * 1000, () => authCtx.logout());
}, [signedInId, idleHours]);
// Group membership is baked into the access token at login and the hub does not
// push updates, so someone invited after they signed in carries a token that
// says they are in nothing. Refreshing re-reads membership from the database.
// Goes through refreshAccessToken like everything else. It used to call the
// endpoint here and keep only the access token, dropping the rotated refresh
// token that came back with it — so the refresh token was spent on first use,
// and presenting the spent one again revoked the whole family. Which is how
// a session that should last a month ended at "invalid token" with signing
// out as the only way back.
const refreshAuth = useCallback(() => refreshAccessToken(), []);
let page;
// A desktop build with no hub configured cannot do anything at all, so it
// asks before showing a sign-in form that could not work. Deliberately not
// defaulted to meshbay.org: a client that picks its own hub is a client that
// can be pointed at one.
if (needsHub) {
page = html`<${FirstRunPage} onSet=${() => setNeedsHub(false)} />`;
} else if (!deviceTried) {
// Signing in with this device's key. Showing a form here would be showing
// one the user is about to be taken past.
page = html`
${t('status.connecting')}
`;
} else if ((onAuthForm && !user) || route === '/reset') {
page = route === '/register'
? html`<${RegisterPage} />`
: route === '/reset'
? html`<${ResetPasswordPage} onLogin=${authCtx.login} />`
: html`<${LoginPage} onLogin=${authCtx.login} />`;
} else if (route === '/invite') {
// Signed in or not: signed out, it explains and sends to register or sign
// in; signed in, it asks the hub what the ticket is for.
page = html`<${InvitePage} user=${user} onJoined=${async () => {
try {
const data = await hubFetch('/v1/groups/mine', { token: user.token });
setGroups(data.groups || []);
} catch { /* the group page fetches what it needs */ }
}} />`;
} else if (!user) {
page = html`<${LoginPage} onLogin=${authCtx.login} />`;
} else if (route === '/search') {
page = html`<${SearchPage}
token=${user.token} username=${user.username} userId=${user.userId}
groups=${groups} userPrefs=${userPrefs}
onPlayQueue=${handlePlayQueue} />`;
} else if (route === '/explore') {
page = html`<${ExplorePage} token=${user.token}
myGroupIds=${groups.map(g => g.id)}
allowPublicGroups=${allowPublicGroups} />`;
} else if (route === '/create-group') {
page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username}
allowPublicGroups=${allowPublicGroups}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => {});
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
} else if (route.startsWith('/group/')) {
const groupId = route.slice(7);
const group = groups.find(g => g.id === groupId);
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} userId=${user.userId}
userPrefs=${userPrefs}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
onGroupUpdated=${updateGroup} onPresence=${notePresence}
onLeft=${handleLeftGroup}
onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${LazyAdminPage} token=${user.token} role=${user.role} />`
: html`<${HomePage} groups=${groups} notifications=${notifications}
allowPublicGroups=${allowPublicGroups}
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
onThemeChange=${setTheme} groups=${groups}
onPrefsChange=${(p) => {
if ('notifications_disabled' in p) {
setNotifDisabled(p.notifications_disabled);
if (p.notifications_disabled) { setNotifications([]); setUnreadCount(0); }
else fetchNotifications();
}
setUserPrefs(prev => ({ ...prev, ...p }));
}} />`;
} else if (route === '/profile') {
page = html`<${ProfilePage} user=${user} onLogout=${authCtx.logout} />`;
} else {
page = html`<${HomePage} groups=${groups} notifications=${notifications}
allowPublicGroups=${allowPublicGroups}
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
}
return html`
<${AuthContext.Provider} value=${authCtx}>
<${Nav}
user=${user}
theme=${theme}
onThemeChange=${changeTheme}
onLogout=${authCtx.logout}
onMenuToggle=${() => setMenuOpen(o => !o)}
unreadCount=${unreadCount} hubUnset=${needsHub} />
${user && html`<${IndexingDock} groups=${groups} />`}
${saveQueue && user && html`
<${NameModal} title=${t('playlists.save_queue')}
onSubmit=${async (name) => {
await saveQueueAsPlaylist(user.userId, name, saveQueue);
}}
onClose=${() => setSaveQueue(null)} />
`}
${musicQueue && html`
<${MusicPlayerBar}
getConnection=${getMusicConnection}
queue=${musicQueue}
userPrefs=${userPrefs}
onSaveQueue=${user ? handleSaveQueue : null}
onClose=${handleStopMusic} />
`}
/>
`;
}
// ── Boot ─────────────────────────────────────────────────────────────────────
// The tray menu's four strings. The main process has no i18n (see
// packages/meshbay-client/src/main.js), so they are translated here and sent
// over the bridge — once at boot, because the app now creates its indicator at
// launch rather than on the first minimise, and again from the nav button.
const trayLabels = () => ({
show: t('tray.show'), quit: t('tray.quit'),
start_node: t('tray.start_node'), stop_node: t('tray.stop_node'),
});
// Catalogues are fetched, so the first render waits for one: mounting earlier
// would paint the interface in English and then swap every string. initLocale()
// falls back to English rather than rejecting, so this cannot strand the page.
// One sweep, once per browser, to remove what the cross-group search of 2026-08
// left behind: a cleartext copy of every group's file listing that nothing has
// read since, and that no sign-out removed. Guarded by a flag so it costs one
// transaction ever rather than one per load; a browser that refuses storage
// simply does it again, which is harmless.
const PURGED_KEY = 'meshbay.indexcache.purged';
const purgeOnce = () => {
try {
if (localStorage.getItem(PURGED_KEY)) return;
} catch { /* no storage: purge anyway, it is idempotent */ }
purgeGroupIndexCache().then(() => {
try { localStorage.setItem(PURGED_KEY, '1'); } catch { /* nothing to remember with */ }
});
};
const mount = () => {
purgeOnce();
render(html`<${App} />`, document.getElementById('app'));
// Get the download worker registered and this page under its control now,
// rather than inside the first click on Download. On Firefox and Safari it is
// the only unbounded way to write a file to disk, and it used to be
// registered lazily — so the first download of a session paid install,
// activate and claim while somebody watched, and a claim that missed its
// budget sent the file to a path that cannot hold a film. Fire-and-forget:
// nothing renders differently for it, and a failure is retried on demand.
downloads.primeServiceWorker();
// After the catalogue, so the labels are in the right language. A no-op in a
// browser and on macOS. A language change reloads the page, which comes back
// through here, so nothing else has to watch for it.
platform.setTrayLabels(trayLabels()).catch(() => {});
};
initLocale().then(mount, (err) => {
// Nothing in initLocale() is supposed to reject. If something does, an
// English interface is still an interface; an unhandled rejection here is a
// blank page.
console.error('[MeshBay] locale init failed, continuing in English:', err);
mount();
});