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 } from './transfers.js';
import * as downloads from './downloads.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
import { FILE_ICONS, formatSize } from './file-utils.js';
import {
HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes,
_storeBundleKey, _loadBundleKey, _clearKeyDB,
loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch,
refreshAccessToken,
} from './hub-client.js';
import { GroupPage } from './group-page.js';
import { APPS } from './apps.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';
/**
* Rough passphrase strength, in bits, and what it is up against.
*
* This number carries more weight here than in most applications. The encrypted
* keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node
* whose group you join, so the people who host your groups can attack it offline
* (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at.
*
* The estimate is deliberately conservative — character classes and length, with
* a penalty for repetition and for the handful of patterns everyone tries. It is
* a guide, not a guarantee, and it says so in the UI.
*/
function passwordBits(pw) {
if (!pw) return 0;
let pool = 0;
if (/[a-z]/.test(pw)) pool += 26;
if (/[A-Z]/.test(pw)) pool += 26;
if (/[0-9]/.test(pw)) pool += 10;
if (/[^A-Za-z0-9]/.test(pw)) pool += 32;
let bits = pw.length * Math.log2(pool || 1);
const unique = new Set(pw).size;
if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc"
if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs
if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3;
return Math.round(bits);
}
const PASSWORD_MIN_BITS = 60; // refuse below this
const PASSWORD_MIN_LEN = 12;
/** Public X25519 key from our own secret — never read back from the hub. */
async function _pkXFromSk(skPkcs8B64) {
const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0));
const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']);
const jwk = await crypto.subtle.exportKey('jwk', sk);
const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/');
const pad = b64.length % 4;
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
// ── 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`
`;
}
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
hubUnset }) {
return html`
`;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
`;
}
// ── Login Page ───────────────────────────────────────────────────────────────
/**
* Which hub, asked once on a desktop build.
*
* There is no default. A client that picks its own hub is a client that can be
* pointed at one, and the address is the whole of what the application trusts
* the hub for — its API, and nothing else: the interface comes from the package.
*
* Changing it restarts the window, because the address reaches the interface as
* a process argument. Reloading in place would leave it talking to the old hub
* with nothing on screen to say so.
*/
function FirstRunPage({ onSet }) {
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await window.meshbay.setHubBase(url.trim());
onSet();
} catch (err) {
setError(platform.bridgeMessage(err));
setBusy(false);
}
};
return html`
`;
}
// ── 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()}
`;
}
// ── Create Group Wizard (Electron-only) ─────────────────────────────────────
function CreateGroupWizard({ token, username, onCreated }) {
const [step, setStep] = useState(0); // 0=node check, 1=details, 2=setup, 3=done
const [nodeStatus, setNodeStatus] = useState(null); // null=loading, object=result
const [nodeStarting, setNodeStarting] = useState(false);
const [error, setError] = useState('');
// Step 1 fields
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
const [uploadIdx, setUploadIdx] = useState(0);
// Every registered app, on by default — narrowing this down here means
// members never briefly see one the operator meant to leave off, the way
// toggling it afterward from Settings would.
const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
const toggleWizardApp = useCallback((key) => {
setEnabledApps(prev => prev.includes(key)
? prev.filter(k => k !== key)
: [...prev, key]);
}, []);
// Step 2 progress
const [setupSteps, setSetupSteps] = useState([]);
const [setupError, setSetupError] = useState('');
const [groupId, setGroupId] = useState('');
// Bytes-based, not file-count-based: one 20 GB file finishing last must
// not read as "8 of 9 done" while it is still the only thing running.
const [indexProgress, setIndexProgress] = useState(null);
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
try {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token, body: { pk_node_ed25519: pk },
});
} catch { /* already linked or same key */ }
}, [token]);
// Step 0: detect node
const detectNode = useCallback(async () => {
setNodeStatus(null);
setError('');
try {
const result = await platform.node.detect();
if (result.detected) {
await linkNodeKey(result.pk_node_ed25519);
setNodeStatus(result);
setStep(1);
return;
}
// Not responding — check if the unit is installed (for the Start button)
const inst = await platform.node.installed();
setNodeStatus({ detected: false, configured: result.configured, installed: inst.installed });
} catch (err) {
setError(err.message);
setNodeStatus({ detected: false });
}
}, [token, linkNodeKey]);
const startNode = useCallback(async () => {
setNodeStarting(true);
setError('');
try {
const result = await platform.node.start({ hubUrl: HUB, username, token });
setNodeStatus({ detected: true, ...result });
setNodeStarting(false);
setStep(1);
} catch (err) {
setError(platform.bridgeMessage(err));
setNodeStarting(false);
}
}, [linkNodeKey]);
useEffect(() => { detectNode(); }, [detectNode]);
const addRoot = useCallback(async () => {
const chosen = await platform.rootPicker.choose();
if (!chosen) return;
if (roots.some(r => r.path === chosen.path)) return;
setRoots(prev => [...prev, chosen]);
}, [roots]);
const removeRoot = useCallback((idx) => {
setRoots(prev => {
const next = prev.filter((_, i) => i !== idx);
if (uploadIdx >= next.length && next.length > 0) setUploadIdx(0);
return next;
});
}, [uploadIdx]);
const runSetup = useCallback(async () => {
setStep(2);
setSetupError('');
const steps = [
{ label: t('wizard.step_create_hub'), status: 'pending' },
{ label: t('wizard.step_attach'), status: 'pending' },
];
steps.push({ label: t('wizard.step_index'), status: 'pending' });
// Always a step: the node's own default for a brand-new group is
// `chat, files` only (Roster.DEFAULT_APPS) — narrower than "every app
// checked" here, which is this wizard's own default. Skipping this call
// whenever nothing was *unchecked* used to assume those two defaults
// agreed; they don't, so leaving every box checked — the common,
// recommended case — silently left Videos/Music/Photos disabled on the
// node (found live 2026-08-25: no `set_enabled_apps`/"Enabled apps for
// group" ever logged for a group created with every app left on).
steps.push({ label: t('wizard.step_apps'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
steps.push({ label: t('wizard.step_pair'), status: 'pending' });
setSetupSteps([...steps]);
setIndexProgress(null);
let si = 0;
const update = (status) => {
steps[si].status = status;
setSetupSteps([...steps]);
};
const advance = () => { si++; };
// Every step from here on is scoped to the group the node just attached.
// The node hot-loads a brand-new group synchronously — scan included —
// before it is added to groups_ctx or its own in-memory config
// (daemon.py _reload_config_inner: the config swap is the *last* thing
// that function does, a beat after the scan, not atomic with it) — so a
// call that lands in that beat gets refused even though the wait above
// already reported the scan as done. A handful of short retries absorbs
// that gap without a real cross-process synchronization primitive.
const withRetry = async (fn, attempts = 5, delayMs = 400) => {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
const msg = String((err && err.message) || '');
const notHostedYet = /not configured on this node|not hosted on this node/i.test(msg);
if (!notHostedYet || i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, delayMs));
}
}
};
try {
// 1. Create group on hub
update('running');
const body = { name: name.trim(), join_policy: joinPolicy,
visibility: joinPolicy === 'open' ? 'public' : 'private' };
if (description.trim()) body.description = description.trim().slice(0, 512);
const data = await hubFetch('/v1/groups', { method: 'POST', token, body });
const gid = data.group_id;
setGroupId(gid);
update('done');
advance();
// 2. Attach to node with first root
update('running');
const mainRoot = roots[uploadIdx] || roots[0];
const attachBody = { name: name.trim(), shared_dir: mainRoot.path };
if (roots.length === 1 || uploadIdx === 0) {
attachBody.upload_dir = mainRoot.path;
}
await platform.node.call('POST', '/api/groups/attach', attachBody);
// Fire-and-forget on the node's side (ui/app.py) — this call itself
// returns immediately, well before the scan below finishes. It used
// to be the thing the wizard waited on, which is exactly what made
// "Attaching to node" time out on a real library (see ops.start_reload).
await platform.node.call('POST', '/api/reload');
update('done');
advance();
// 3. Wait for the node's own initial scan of this group to finish —
// the group is not usable for anything below (apps, extra roots, GEK)
// until this finishes: daemon.py registers a brand-new group in
// groups_ctx only once its initial scan completes (ui/app.py's
// index-status docstring — "it is not yet authorized for member
// connections either way"), so nothing scoped to the group can
// succeed before this, no matter how many times it's retried. Can
// take tens of minutes on a slow disk with a large library — the node
// keeps scanning on its own either way (test_hot_reload_survives_
// client_close.py); this step is only about not lying about it.
update('running');
await platform.waitForGroupHosted(gid, setIndexProgress);
update('done');
advance();
// 4. Set the enabled apps — unconditionally (see the step-list
// comment above on why "only if narrowed" was wrong: the node's own
// default is not "every app", so leaving every box checked must still
// be told to the node explicitly).
// Used to run *before* the scan above, reasoning that a member
// joining mid-scan should never briefly see an app meant to be off —
// but nobody can join before the group is hosted either (same
// authorization gate the comment above names), so that concern never
// applied, and placing it here means the retry below is defensive
// rather than the only thing standing between this step and an
// indefinite "Group not hosted on this node" (found live against a
// real, several-thousand-file library: withRetry's five attempts
// don't come close to covering a scan that takes minutes).
update('running');
await withRetry(() => platform.node.call(
'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
update('done');
advance();
// 5. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
for (let i = 0; i < roots.length; i++) {
if (i === (uploadIdx < roots.length ? uploadIdx : 0)) continue;
const r = roots[i];
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/roots`, {
path: r.path, name: r.name,
upload: i === uploadIdx,
}));
}
// Each add above only schedules its scan (platform.js's
// waitForRootsIndexed docstring) — wait for it to actually finish,
// reusing the same progress bar step 3 fed, or this step reports
// "done" while the node is still hashing gigabytes behind the
// scenes (found live 2026-08-25).
await platform.waitForRootsIndexed(gid, setIndexProgress);
update('done');
advance();
}
// 6. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
// 7. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
await platform.node.setPairingCode(pairResult.code);
session.pendingJoinCode = pairResult.code;
}
update('done');
// Reload once more so any roots added at step 4 are picked up.
try { await platform.node.call('POST', '/api/reload'); } catch { /* best effort */ }
setStep(3);
if (onCreated) onCreated();
} catch (err) {
update('error');
setSetupError(platform.bridgeMessage(err));
}
}, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
if (nodeStatus === null) {
return html`
`;
}
// ── Search Page (cross-group file search) ───────────────────────────────────
/**
* "3 hours ago", in the reader's language.
*
* The search page needs it because its results come from a cache: a file that
* was deleted an hour ago is still listed until the group is opened again, and
* the honest thing is to say how old the answer is rather than to imply it is
* live.
*/
function formatAgo(ts) {
if (!ts) return '';
const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' });
let delta = (ts - Date.now()) / 1000;
const steps = [['second', 60], ['minute', 60], ['hour', 24],
['day', 7], ['week', 4.35], ['month', 12], ['year', Infinity]];
for (const [unit, span] of steps) {
if (Math.abs(delta) < span || span === Infinity) {
return rtf.format(Math.round(delta), unit);
}
delta /= span;
}
return '';
}
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searched, setSearched] = useState(false);
const doSearch = useCallback(async (q) => {
const term = q.trim().toLowerCase();
if (!term) { setResults([]); setSearched(false); return; }
const indexes = await getAllCachedIndexes();
const hits = [];
for (const idx of indexes) {
for (const e of (idx.entries || [])) {
if (e.name.toLowerCase().includes(term) ||
(e.path && e.path.toLowerCase().includes(term))) {
hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName,
syncedAt: idx.cachedAt });
}
}
}
setResults(hits);
setSearched(true);
}, []);
const onInput = useCallback((e) => {
const q = e.target.value;
setQuery(q);
doSearch(q);
}, [doSearch]);
return html`
`;
}
function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
const [locale, setLoc] = useState(getLocale);
const [muted, setMuted] = useState(
() => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
const [globalMute, setGlobalMute] = useState(false);
const [defaultTab, setDefaultTab] = useState('chat');
const onLocaleChange = useCallback((e) => {
const code = e.target.value;
setLocale(code);
setLoc(code);
window.location.reload();
}, []);
const onThemeSelect = useCallback((e) => {
onThemeChange(e.target.value);
}, [onThemeChange]);
useEffect(() => {
hubFetch('/v1/users/me/preferences', { token: user.token })
.then(prefs => {
if (prefs.notifications_disabled === 'true') setGlobalMute(true);
if (prefs.default_tab) setDefaultTab(prefs.default_tab);
})
.catch(() => {});
}, [user.token]);
const toggleGlobalMute = useCallback(async () => {
const next = !globalMute;
setGlobalMute(next);
try {
await hubFetch('/v1/users/me/preferences/notifications_disabled', {
method: 'PUT', token: user.token,
body: { value: next ? 'true' : 'false' },
});
if (onPrefsChange) onPrefsChange({ notifications_disabled: next });
} catch (err) {
setGlobalMute(!next);
}
}, [globalMute, user.token, onPrefsChange]);
const toggleMute = useCallback(async (gid) => {
const next = !muted[gid];
setMuted(prev => ({ ...prev, [gid]: next }));
try {
await hubFetch(`/v1/groups/${gid}/mute`, {
method: 'POST', token: user.token, body: { muted: next },
});
} catch (err) {
setMuted(prev => ({ ...prev, [gid]: !next }));
}
}, [muted, user.token]);
const changeDefaultTab = useCallback(async (e) => {
const val = e.target.value;
setDefaultTab(val);
try {
await hubFetch('/v1/users/me/preferences/default_tab', {
method: 'PUT', token: user.token,
body: { value: val },
});
if (onPrefsChange) onPrefsChange({ default_tab: val });
} catch { setDefaultTab(defaultTab); }
}, [defaultTab, user.token, onPrefsChange]);
const [dlMode, setDlMode] = useState(() => downloads.getMode());
const [dlDir, setDlDir] = useState(null);
const [dlError, setDlError] = useState('');
// Read from the hub rather than written here: the two constants that used to
// sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on.
const [hubInfo, setHubInfo] = useState(null);
// On a desktop build, whether the OS is really holding the keys. Electron's
// safeStorage falls back to a fixed key when no keyring is running — a
// headless session, a minimal desktop — and does it silently. Somebody who
// believes the OS is protecting their keys deserves to be told when it is not.
const [keyBackend, setKeyBackend] = useState('');
// Changing the hub after the first run. Without this a typo on the first
// screen was permanent: the prompt only appears when no hub is set, so a
// wrong one left editing a JSON file by hand as the only way out.
const [hubInput, setHubInput] = useState('');
const [hubError, setHubError] = useState('');
useEffect(() => {
if (!platform.secrets.available) return;
platform.secrets.backend().then(setKeyBackend).catch(() => {});
}, []);
useEffect(() => {
hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {});
}, []);
useEffect(() => {
// The desktop build remembers a path; the browser remembers a handle. Both
// answer "where do downloads go", and the row below renders either.
if (platform.folder.available) platform.folder.get().then(setDlDir);
else downloads.savedDirectory().then(setDlDir);
}, []);
const pickFolder = useCallback(async () => {
try {
if (platform.folder.available) {
const dir = await platform.folder.choose();
if (dir) setDlDir(dir);
return;
}
const handle = await downloads.chooseDirectory();
setDlDir(handle);
} catch (err) {
// Reported where the folder controls are. This used to be written into
// the node-key status, two sections away, where nobody was looking.
if (err.name !== 'AbortError') setDlError(err.message);
}
}, []);
return html`
`;
}
function BlocklistForm({ onAdd }) {
const [hash, setHash] = useState('');
const [reason, setReason] = useState('');
const submit = (e) => {
e.preventDefault();
if (hash.length === 64 && reason) {
onAdd(hash, reason);
setHash('');
setReason('');
}
};
return html`
`;
}
// ── Node management (D5) ────────────────────────────────────────────────────
/**
* The systemd unit's own state, independent of the MNP connection below it.
*
* The rest of NodePage talks to the daemon over a signed MNP session, which
* requires the daemon to already be up and answering — no use at all for
* "the node is stopped, start it" or "it is crash-looping, tell me". This
* asks systemd directly (main process → `systemctl --user`), the same way
* `node:installed` and the wizard's `node:start` already do, so it works
* from every state the connection below can be in.
*/
function NodeServicePanel({ onChanged }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
const [err, setErr] = useState('');
const refresh = useCallback(async () => {
try {
const r = await platform.node.service.status();
setInfo(r);
setErr('');
} catch (e) {
setErr(platform.bridgeMessage(e));
}
}, []);
useEffect(() => {
if (!platform.node.service.available) return;
refresh();
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, [refresh]);
const act = useCallback(async (name, fn) => {
setBusy(name);
setErr('');
try {
await fn();
await refresh();
if (onChanged) onChanged();
} catch (e) {
setErr(platform.bridgeMessage(e));
} finally {
setBusy('');
}
}, [refresh, onChanged]);
if (!platform.node.service.available) return null;
if (!info || info.supported === false) {
return html`
`;
}
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
// 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);
const resolved = resolveTheme(theme);
// 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));
// 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]);
const fetchNotifications = useCallback(() => {
if (!user || notifDisabled) {
setNotifications([]); setUnreadCount(0); return;
}
hubFetch('/v1/notifications?limit=20', { 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));
hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', 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}/read`,
{ method: 'POST', 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 () => {
try {
// `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.
const saved = loadAuth();
const username = saved && saved.username;
if (!username) return;
const signed = await platform.device.sign(username);
if (!signed) return;
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 });
if (cancelled) return;
const u = { username, userId: me.user_id, token: data.access_token,
refreshToken: data.refresh_token, role: me.role };
setAuth(u);
setUser(u);
} catch {
// Falls through to the sign-in form, which is the honest outcome.
} finally {
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);
// 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: () => {
// Navigating away leaves transfers running; signing out does not. They
// are moving data on tokens that are about to stop being ours.
transfers.reset();
setAuth(null);
setUser(null);
setGroups([]);
navigate('/login');
},
};
// 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`
/>
`;
}
// ── Boot ─────────────────────────────────────────────────────────────────────
// 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.
const mount = () => render(html`<${App} />`, document.getElementById('app'));
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();
});