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 platform from './platform.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 { 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'); if (!items.length) return null; return html`
${open && html`
${t('transfers.title')}
${items.map(it => html`
<${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> ${it.canOpen ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` : html`${it.name}`} ${it.status === 'running' && html` `}
${it.status === 'running' ? html`
${formatSize(it.done)}${it.total ? ' / ' + formatSize(it.total) : ''} ${formatSpeed(it.speed)}
` : html`
${it.status === 'done' ? t('transfers.done') : it.status === 'cancelled' ? t('transfers.cancelled') : it.error || t('transfers.failed')} ${it.canOpen && html` `}
`}
`)}
`}
`; } // ── 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`

${g.name}

${g.description && html`

${g.description}

`} ${g.visibility} ${' '} ${g.join_policy} ${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 ───────────────────────────────────────────────────────────────────── // 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(); });