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, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, refreshAccessToken, } from './hub-client.js'; import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.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'; // ── 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`
${t('transfers.title')} ${active > 0 && html` ${t('transfers.summary', { running: running.length, waiting: waiting.length })} `} ${finished.length > 0 && html` `}
${groups.map(([label, rows]) => html`
${groups.length > 1 && html`
${t('transfers.group_' + label, { n: rows.length })}
`} ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)}
`)}
`}
`; } /** 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`
${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. */''}
${t('transfers.preparing')} ${formatSize(it.total)}
` : it.status === 'queued' ? html`
${it.queuedByOwnLimit ? t('transfers.waiting_own_slots') : t('transfers.waiting_node', { n: it.ahead })} ${formatSize(it.total)}
` : 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. */''}
${t('transfers.paused')} ${formatSize(it.done)}${it.total ? ' / ' + formatSize(it.total) : ''}
` : it.status === 'running' ? html`
${formatSize(it.done)}${it.total ? ' / ' + formatSize(it.total) : ''} ${[formatSpeed(it.speed), it.settled && eta !== null ? formatEta(eta) : ''] .filter(Boolean).join(' · ')}
` : html`
${it.status === 'done' ? t('transfers.done') : it.status === 'cancelled' ? t('transfers.cancelled') : it.error || t('transfers.failed')} ${it.canOpen && html` `}
`}
`; } /** "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` `; } // ── 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()}
`)}
`; } function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroups = true }) { const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { if (platform.isNative && !setupDismissed) { return html`<${SetupWelcome} onDismiss=${() => setSetupDismissed(true)} />`; } return html`

${t('home.welcome')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} />

${t('home.no_groups')} ${' '}${allowPublicGroups ? html`${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')}` : t('home.invite_only')}

`; } return html`

${t('home.my_groups')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} />
${groups.map(g => html` ${/* The host, not the visibility. "private" and "invite" were on every card because they are the default everywhere — two badges that never varied and so never told anyone anything. Who hosts a group does vary, and is the thing that tells two groups of the same name apart (the hub only enforces uniqueness per owner). GroupName renders it as a smaller @handle beside the name, which is how the group header and Explore already write it. "admin" stays: it does vary. */''}

<${GroupName} name=${g.name} owner=${g.owner_username} />

${g.description && html`

${g.description}

`} ${g.is_admin && html`admin`}
`)}
`; } // ── First-run welcome (Electron-only, shown once on empty home) ───────────── function SetupWelcome({ onDismiss }) { return html`

${t('setup.welcome_title')}

${t('setup.welcome_message')}

${t('setup.create_group')}
`; } // ── 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`<${_CreateGroupPage} ...${props} />`; } // ── Settings Page ─────────────────────────────────────────────────────────── const THEME_OPTIONS = ['light', 'dark', 'system']; // ── Profile Page ──────────────────────────────────────────────────────────── // // ── Lazy-loaded Admin page (admin/moderator only) ───────────────────────── let _AdminPage = null; function LazyAdminPage(props) { const [loaded, setLoaded] = useState(!!_AdminPage); useEffect(() => { if (!_AdminPage) { import('./admin-page.js').then(m => { _AdminPage = m.AdminPage; setLoaded(true); }); } }, []); if (!loaded) return html`

`; return html`<${_AdminPage} ...${props} />`; } // ── Lazy-loaded Node page (Electron only) ─────────────────────────────────── let _NodePage = null; function LazyNodePage(props) { const [loaded, setLoaded] = useState(!!_NodePage); useEffect(() => { if (!_NodePage) { import('./node-page.js').then(m => { _NodePage = m.NodePage; 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); // 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 }; }, []); const handlePlayQueue = useCallback((tracks, startIndex, source) => { 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() }); }, []); const handleStopMusic = useCallback(() => setMusicQueue(null), []); 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]); 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 () => { 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`

${t('status.connecting')}

`; } else if (route === '/login' || route === '/register' || route === '/reset') { page = route === '/register' ? html`<${RegisterPage} />` : route === '/reset' ? html`<${ResetPasswordPage} onLogin=${authCtx.login} />` : html`<${LoginPage} onLogin=${authCtx.login} />`; } 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} />`; } 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`<${Sidebar} groups=${groups} presence=${presence} indexProgressPct=${indexProgressPct} route=${route} menuOpen=${menuOpen} role=${user.role} allowPublicGroups=${allowPublicGroups} hasNodeKey=${hasNodeKey} />`} ${menuOpen && html`
setMenuOpen(false)} />`}
${page}
${musicQueue && html` <${MusicPlayerBar} getConnection=${getMusicConnection} queue=${musicQueue} userPrefs=${userPrefs} 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. const mount = () => { 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(); });