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`
${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 }) { 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`

${t('firstrun.title')}

${t('firstrun.hint')}

setUrl(e.target.value)} />
${error && html`
${error}
`}

${t('firstrun.note')}

`; } function LoginPage() { const auth = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!username || !password) return; setError(''); setLoading(true); try { await auth.login(username, password); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required autofocus /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`}
`; } // ── Register Page ──────────────────────────────────────────────────────────── function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } if (password.length < PASSWORD_MIN_LEN) { setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; } // The floor can only live here: with the password split (T1) the hub never // sees the password, so it cannot enforce anything about it. if (passwordBits(password) < PASSWORD_MIN_BITS) { setError(t('register.err_too_weak')); return; } setError(''); setLoading(true); try { if (window.MeshBayKeys) { await window.MeshBayKeys.registerUser(username, email, password); } else { await hubFetch('/v1/users/register', { method: 'POST', body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, }); } setSuccess(true); } catch (err) { setError(err.message); } finally { setLoading(false); } }; if (success) { return html`

${t('register.success_title')}

${t('register.success_msg')}

${t('register.go_login')}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> ${password && html`

${t('register.strength', { bits: passwordBits(password) })}

`} setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`; } // ── 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 }) { 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')} ${' '}${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')}

`; } 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`}
`)}
`; } // ── Explore Page ───────────────────────────────────────────────────────────── function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); useEffect(() => { doSearch(''); }, [token]); const onSearch = useCallback((e) => { const q = e.target.value; setSearch(q); doSearch(q); }, [doSearch]); const joinGroup = useCallback(async (gid) => { setJoining(gid); try { await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); navigate(`/group/${gid}`); setTimeout(() => window.location.reload(), 100); } catch (err) { if (err.message.includes('Already a member')) { navigate(`/group/${gid}`); } else { alert(err.message); } } finally { setJoining(null); } }, [token]); const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); return html`

${t('explore.title')}

${platform.node.available && html` ${t('explore.create_group')} `}
${loading ? html`

${t('explore.loading')}

` : groups.length === 0 ? html`

${t('explore.empty')}

` : html`
${groups.map(g => html`

${g.name}

${g.description && html`

${g.description}

`}
${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} ${' '} ${isMember(g.id) ? html`${t('explore.member')}` : g.join_policy === 'open' && html` ` }
`)}
` }
`; } // ── 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')}
`; } // ── Create Group Page ──────────────────────────────────────────────────────── function CreateGroupPage(props) { if (platform.node.available) return html`<${CreateGroupWizard} ...${props} />`; return html`<${CreateGroupFormSimple} ...${props} />`; } function CreateGroupFormSimple({ token, onCreated }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!name.trim()) return; setLoading(true); setError(''); try { 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, }); if (onCreated) onCreated(); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('create_group.title')}

${t('create_group.hint')}

${error && html`
${error}
`}
setName(e.target.value)} required autofocus />