import {
html, render, useState, useEffect, useCallback, useRef,
createContext, useContext,
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, LOCALES } from './i18n.js';
// ── Constants ────────────────────────────────────────────────────────────────
const HUB = '';
const AUTH_KEY = 'mb_auth';
const THEME_KEY = 'mb_theme';
const IDB_NAME = 'meshbay';
const IDB_VERSION = 1;
const IDB_STORE = 'group_indexes';
// ── IndexedDB cache ─────────────────────────────────────────────────────────
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE, { keyPath: 'groupId' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function cacheGroupIndex(groupId, groupName, entries) {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put({
groupId, groupName, entries, cachedAt: Date.now(),
});
await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; });
db.close();
} catch { /* best-effort */ }
}
async function getCachedGroupIndex(groupId) {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).get(groupId);
const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
db.close();
return result || null;
} catch { return null; }
}
async function getAllCachedIndexes() {
try {
const db = await openDB();
const tx = db.transaction(IDB_STORE, 'readonly');
const req = tx.objectStore(IDB_STORE).getAll();
const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; });
db.close();
return result || [];
} catch { return []; }
}
// ── Auth persistence ─────────────────────────────────────────────────────────
let _sessionKeys = null;
let _bundleKey = null;
let _pendingBundlePush = null;
// A one-time pairing code the user just typed, consumed by the next connection
// attempt. Deliberately not persisted: it is single-use and short-lived.
let _pendingJoinCode = null;
function _openKeyDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('meshbay_keys', 1);
req.onupgradeneeded = () => req.result.createObjectStore('k');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function _storeBundleKey(key) {
try {
const db = await _openKeyDB();
const tx = db.transaction('k', 'readwrite');
tx.objectStore('k').put(key, 'bk');
await new Promise(r => { tx.oncomplete = r; });
db.close();
} catch {}
}
async function _loadBundleKey() {
try {
const db = await _openKeyDB();
const tx = db.transaction('k', 'readonly');
const g = tx.objectStore('k').get('bk');
const val = await new Promise(r => { g.onsuccess = () => r(g.result); });
db.close();
return val || null;
} catch { return null; }
}
async function _clearKeyDB() {
try {
const db = await _openKeyDB();
const tx = db.transaction('k', 'readwrite');
tx.objectStore('k').clear();
await new Promise(r => { tx.oncomplete = r; });
db.close();
} catch {}
}
function _saveSessionKeys() {
try {
if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys));
} catch {}
}
function _restoreSessionKeys() {
try {
if (!_sessionKeys) {
const sk = sessionStorage.getItem('meshbay_sk');
if (sk) _sessionKeys = JSON.parse(sk);
}
} catch {}
}
function loadAuth() {
try {
return JSON.parse(localStorage.getItem(AUTH_KEY));
} catch {
return null;
}
}
function saveAuth(auth) {
if (auth) {
localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
} else {
localStorage.removeItem(AUTH_KEY);
_sessionKeys = null;
_bundleKey = null;
_pendingBundlePush = null;
_clearKeyDB();
try { sessionStorage.removeItem('meshbay_sk'); } catch {}
}
}
// ── 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;
}
// ── Hub API ──────────────────────────────────────────────────────────────────
async function hubFetch(path, { method = 'GET', body, token } = {}) {
const headers = {};
if (body) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(HUB + path, opts);
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: r.statusText }));
const detail = Array.isArray(err.detail)
? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ')
: (err.detail || r.statusText);
throw new Error(String(detail));
}
return r.json();
}
// ── 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;
}
function navigate(path) {
window.location.hash = path;
}
// ── Context ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
function useAuth() { return useContext(AuthContext); }
// ── 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 }) {
return html`
`;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
function Sidebar({ groups, route, menuOpen, role }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
`;
}
// ── Login Page ───────────────────────────────────────────────────────────────
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`
`;
}
// ── 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 < 8) { setError(t('register.err_min_len')); 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`
`;
}
return html`
`;
}
// ── Home Page ────────────────────────────────────────────────────────────────
function NotificationFeed({ notifications, onMarkRead }) {
if (!notifications.length) return null;
return html`
${t('notif.title')}
${notifications.map(n => html`
{
if (!n.read) onMarkRead(n.id);
if (n.link) navigate(n.link);
}}>
${n.kind}
${n.title}
${new Date(n.created_at).toLocaleDateString()}
`)}
`;
}
function HomePage({ groups, notifications, onMarkRead }) {
if (groups.length === 0) {
return html`
${t('home.welcome')}
<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />
${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} />
`;
}
// ── 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`
${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`
joinGroup(g.id)}>
${joining === g.id ? '...' : t('explore.join')}
`
}
`)}
`
}
`;
}
// ── Create Group Page ────────────────────────────────────────────────────────
function CreateGroupPage({ token, onCreated }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [visibility, setVisibility] = useState('private');
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(), visibility, join_policy: joinPolicy };
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`
${error && html`
${error}
`}
`;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const FILE_ICONS = {
video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}',
};
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatDate(ts) {
return new Date(ts * 1000).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
}
// ── Group Page ──────────────────────────────────────────────────────────────
const CHUNK_SIZE = 1024 * 1024;
const UPLOAD_CHUNK_SIZE = 48 * 1024;
const PIPELINE_WINDOW = 8;
async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
const results = writable ? null : new Array(totalChunks);
let nextSend = 0, nextRecv = 0;
const inflight = new Array(totalChunks);
const fire = () => {
while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) {
inflight[nextSend] = transport.fetchChunk(fileId, nextSend);
nextSend++;
}
};
fire();
while (nextRecv < totalChunks) {
const chunkMsg = await inflight[nextRecv];
let plaintext;
if (gekKey && chunkMsg.ct) {
plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct);
} else if (gekKey && chunkMsg.ct_b64) {
plaintext = await window.MeshBayCrypto.decryptChunk(
gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64);
} else {
plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64);
}
if (writable) {
await writable.write(plaintext);
} else {
results[nextRecv] = plaintext;
}
nextRecv++;
fire();
if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks);
}
return results;
}
function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [cached, setCached] = useState(false);
const [error, setError] = useState('');
const [sortKey, setSortKey] = useState('name');
const [sortAsc, setSortAsc] = useState(true);
const [filter, setFilter] = useState('');
const [currentPath, setCurrentPath] = useState('');
const [dlState, setDlState] = useState(null);
const [videoEntry, setVideoEntry] = useState(null);
const [previewEntry, setPreviewEntry] = useState(null);
const [tab, setTab] = useState('files');
const [uploading, setUploading] = useState(false);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
const [needsCode, setNeedsCode] = useState(false);
const [codeInput, setCodeInput] = useState('');
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
// One refresh per mount: if a fresh token still says we are not a member, we
// really are not, and retrying forever would hide that.
const refreshedRef = useRef(false);
const submitJoinCode = useCallback((e) => {
e.preventDefault();
const code = codeInput.trim();
if (!code) return;
_pendingJoinCode = code;
setCodeInput('');
setNeedsCode(false);
setError('');
setRetryKey(k => k + 1);
}, [codeInput]);
useEffect(() => {
if (menuOpen === null) return;
const close = () => setMenuOpen(null);
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [menuOpen]);
useEffect(() => {
let cancelled = false;
getCachedGroupIndex(groupId).then(hit => {
if (hit && !cancelled) {
setEntries(hit.entries || []);
setCached(true);
}
});
const connect = async () => {
setStatus('discovering');
setError('');
gekRef.current = null;
if (!_bundleKey) _bundleKey = await _loadBundleKey();
_restoreSessionKeys();
try {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
if (cancelled) return;
if (!nodesData.nodes || nodesData.nodes.length === 0) {
setStatus('offline');
return;
}
// Session keys for the P2P key exchange. skEdB64 belongs here too: the
// node identifies us by the Ed25519 identity, and join_request signs both
// public keys with it — without it we can neither join nor pair.
const sessionKeys = _sessionKeys ? {
skXB64: _sessionKeys.skXB64,
skEdB64: _sessionKeys.skEdB64,
pkXB64: _sessionKeys.pkXB64,
} : null;
setStatus('connecting');
const nodeId = nodesData.nodes[0].node_id;
const transport = new window.MeshBayTransport('', token);
transportRef.current = transport;
const ack = await transport.connect(
nodeId, token, groupId, null, sessionKeys, _bundleKey, username,
userId, _pendingJoinCode);
_pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
// If transport recovered different session keys from node during handshake
if (transport.sessionKeys) {
const recovered = transport.sessionKeys;
if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) {
_sessionKeys = recovered;
if (!_sessionKeys.pkXB64) {
const pubkeys = await hubFetch(
`/v1/users/${username}/pubkeys`, { token });
_sessionKeys.pkXB64 = pubkeys.pk_x25519;
}
_pendingBundlePush = null;
try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
_saveSessionKeys();
}
}
// Push keypair bundle to node (new registration, localStorage → node)
if (_pendingBundlePush && transport.connected) {
try {
await transport.storeKeypairBundle(_pendingBundlePush);
try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
_pendingBundlePush = null;
} catch (e) {
console.warn('[MeshBay] Bundle push to node deferred:', e.message);
}
}
// Import GEK from transport (fetched from node during handshake)
if (transport.gekRaw && window.MeshBayCrypto) {
gekRef.current = await window.MeshBayCrypto.importGEK(
window.MeshBayCrypto.b64encode(transport.gekRaw));
}
setStatus('fetching');
transport.onIndexSync = (msg) => {
if (cancelled) return;
const synced = msg.entries || [];
setEntries(synced);
setCached(false);
cacheGroupIndex(groupId, group ? group.name : groupId, synced);
};
const indexMsg = await transport.fetchIndex();
if (cancelled) return;
const freshEntries = indexMsg.entries || [];
setEntries(freshEntries);
setCached(false);
setStatus('connected');
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
if (cancelled) return;
// Our token predates being added to this group. Refresh once and retry
// rather than telling someone who was just invited that they are not a
// member — which is what the node honestly sees, and is useless to them.
if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
refreshedRef.current = true;
try {
if (await onRefreshAuth()) return; // new token → effect re-runs
} catch { /* fall through to the message below */ }
}
// The node has never seen this browser for this account: it needs a
// one-time code from the operator before it will hand over the group
// key. Not an error to shout about — a step in joining.
if (err.reason === 'code_required') setNeedsCode(true);
setError(err.message);
setStatus('error');
}
};
if (token && window.MeshBayTransport) {
connect();
} else if (!window.MeshBayTransport) {
setStatus('error');
setError(t('group.err_transport'));
}
return () => {
cancelled = true;
if (transportRef.current) {
transportRef.current.onIndexSync = null;
transportRef.current.close();
transportRef.current = null;
}
};
}, [groupId, token, retryKey]);
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size });
try {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let downloaded = 0;
const onProgress = (bytes) => {
downloaded += bytes;
setDlState(prev => ({ ...prev, progress: downloaded }));
};
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({ suggestedName: entry.name });
const writable = await handle.createWritable();
try {
await pipelinedDownload(
transport, gekRef.current, entry.id, totalChunks, onProgress, writable);
await writable.close();
} catch (err) {
await writable.abort();
throw err;
}
} else {
const chunks = await pipelinedDownload(
transport, gekRef.current, entry.id, totalChunks, onProgress);
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = entry.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
setDlState(null);
} catch (err) {
setDlState(null);
if (err.name === 'AbortError') return;
setError(t('group.dl_failed', { err: err.message }));
}
}, []);
const uploadFile = useCallback(async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
const transport = transportRef.current;
if (!transport || !transport.connected) return;
setUploading(true);
setError('');
try {
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE);
const buf = new Uint8Array(await slice.arrayBuffer());
await transport.uploadChunk(file.name, i, totalChunks, buf);
}
await new Promise(r => setTimeout(r, 2500));
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
}, []);
const deleteFile = useCallback(async (entry) => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
// Signs an explicit transcript built by transport.js, not opaque bytes from
// the node — see MeshBayCrypto.adminTranscript and finding H5.
const signFn = (_sessionKeys && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
: null;
await transport.deleteFile(entry.id, signFn);
const indexMsg = await transport.fetchIndex();
setEntries(indexMsg.entries || []);
} catch (err) {
setError(err.message);
}
}, []);
const refreshIndex = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
const indexMsg = await transport.fetchIndex();
setEntries(indexMsg.entries || []);
} catch {}
}, []);
const toggleSort = useCallback((key) => {
setSortAsc(prev => sortKey === key ? !prev : true);
setSortKey(key);
}, [sortKey]);
const dirs = new Set();
const filteredEntries = entries.filter(e => {
const ePath = e.path || '';
if (ePath === currentPath) {
return !filter || e.name.toLowerCase().includes(filter.toLowerCase());
}
if (!currentPath && ePath) {
dirs.add(ePath.split('/')[0]);
} else if (currentPath && ePath.startsWith(currentPath + '/')) {
const rest = ePath.slice(currentPath.length + 1);
dirs.add(rest.split('/')[0]);
}
return false;
});
const sorted = [...filteredEntries].sort((a, b) => {
let cmp = 0;
if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
else if (sortKey === 'size') cmp = a.size - b.size;
else if (sortKey === 'type') cmp = a.type.localeCompare(b.type);
else if (sortKey === 'date') cmp = a.added_at - b.added_at;
return sortAsc ? cmp : -cmp;
});
const subdirs = [...dirs].sort();
const baseLabel = {
idle: t('status.idle'),
discovering: t('status.discovering'),
connecting: t('status.connecting'),
fetching: t('status.fetching'),
connected: t('status.files', { n: entries.length }),
offline: t('status.offline'),
error: t('status.error'),
}[status] || status;
const statusLabel = (cached && status !== 'connected')
? `${baseLabel} (${t('status.cached', { n: entries.length })})`
: baseLabel;
const statusClass = status === 'connected' ? 'status-ok'
: status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
const breadcrumbs = currentPath ? currentPath.split('/') : [];
return html`
${error && html`
${error}
`}
${needsCode && html`
${t('group.join_code_title')}
${t('group.join_code_hint')}
setCodeInput(e.target.value)} required />
${t('group.join_code_btn')}
`}
${dlState && html`
${dlState.name}
${formatSize(dlState.progress)} / ${formatSize(dlState.total)}
`}
${(status === 'connected' || (cached && entries.length > 0)) && html`
setTab('files')}>${t('group.tab_files')}
setTab('chat')}>${t('group.tab_chat')}
setTab('members')}>${t('group.tab_members')}
${tab === 'files' && html`
toggleSort('name')}>
${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
toggleSort('size')}>
${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''}
toggleSort('type')}>
${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''}
toggleSort('date')}>
${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
${subdirs.map(d => html`
setCurrentPath(currentPath ? currentPath + '/' + d : d)}>
\u{1F4C1}
${d}/
`)}
${sorted.map(e => {
const canPreview = ['image', 'video', 'document'].includes(e.type)
|| e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i);
return html`
${FILE_ICONS[e.type] || FILE_ICONS.other}
${canPreview
? html` {
if (e.type === 'video') setVideoEntry(e);
else setPreviewEntry(e);
}}>${e.name} `
: e.name
}
${formatSize(e.size)}
${e.type}
${formatDate(e.added_at)}
${menuOpen === e.id && html`
`}
`;
})}
${sorted.length === 0 && subdirs.length === 0 && html`
${filter ? t('group.empty_filter') : t('group.empty_dir')}
`}
`}
${tab === 'chat' && status === 'connected' && html`
<${ChatPanel} transportRef=${transportRef} username=${username}
entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex}
onPreview=${(entry) => {
if (entry.type === 'video') setVideoEntry(entry);
else setPreviewEntry(entry);
}} />
`}
${tab === 'chat' && status !== 'connected' && html`
${' '}${t('status.connecting')}
`}
${tab === 'members' && html`
<${MembersPanel} groupId=${groupId} group=${group} token=${token}
transportRef=${transportRef} gekRef=${gekRef}
isNodeAdmin=${isNodeAdmin} userId=${userId} />
`}
`}
${status === 'offline' && html`
${t('group.offline_title')}
${' '}${t('group.offline_hint')}
`}
${!cached && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
${statusLabel}
`}
${previewEntry && html`
<${FilePreview}
entry=${previewEntry}
transportRef=${transportRef}
gekRef=${gekRef}
onClose=${() => setPreviewEntry(null)} />
`}
${videoEntry && html`
<${VideoPlayer}
entry=${videoEntry}
transportRef=${transportRef}
gekRef=${gekRef}
onClose=${() => setVideoEntry(null)} />
`}
`;
}
// ── File Preview (text, images) ─────────────────────────────────────────
const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
function FilePreview({ entry, transportRef, gekRef, onClose }) {
const [phase, setPhase] = useState('loading');
const [progress, setProgress] = useState(0);
const [content, setContent] = useState(null);
const [error, setError] = useState('');
const blobUrlRef = useRef(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) {
setError(t('video.err_transport'));
setPhase('error');
return;
}
try {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let downloaded = 0;
const chunks = await pipelinedDownload(
transport, gekRef.current, entry.id, totalChunks,
(bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); },
);
if (cancelled) return;
if (entry.name.match(IMAGE_EXTS)) {
const ext = entry.name.split('.').pop().toLowerCase();
const mime = ext === 'svg' ? 'image/svg+xml'
: ext === 'png' ? 'image/png'
: ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp'
: 'image/jpeg';
const blob = new Blob(chunks, { type: mime });
blobUrlRef.current = URL.createObjectURL(blob);
setContent({ type: 'image' });
} else {
const decoder = new TextDecoder('utf-8', { fatal: false });
const text = chunks.map(c => decoder.decode(c, { stream: true })).join('');
setContent({ type: 'text', text: text.slice(0, 500000) });
}
setPhase('ready');
} catch (err) {
if (!cancelled) { setError(err.message); setPhase('error'); }
}
};
load();
return () => { cancelled = true; };
}, [entry]);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
return html`
{
if (e.target.classList.contains('video-overlay')) onClose();
}}>
${entry.name} (${formatSize(entry.size)})
✕
${phase === 'loading' && html`
${t('video.loading', { name: entry.name })}
`}
${phase === 'ready' && content?.type === 'image' && html`
`}
${phase === 'ready' && content?.type === 'text' && html`
`}
${phase === 'error' && html`
${error}
`}
`;
}
function _b64ToU8(b64) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
// ── Members Panel ────────────────────────────────────────────────────────
function MembersPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
const [inviteCode, setInviteCode] = useState(null);
const [pairCode, setPairCode] = useState('');
const [pairStatus, setPairStatus] = useState('');
const [pairing, setPairing] = useState(false);
// Pairing lives here rather than in Settings because this is where a live
// connection to the node exists — and it is offered only when the node itself
// says this account is its operator (is_node_admin comes from the authenticated
// handshake_ack, not from the hub).
const doPair = useCallback(async (e) => {
e.preventDefault();
const code = pairCode.trim();
if (!code) return;
setPairing(true);
setPairStatus('');
try {
const transport = transportRef && transportRef.current;
if (!transport || !transport.connected) throw new Error('Not connected to the node');
await transport.pairOperator(userId, code);
setPairCode('');
setPairStatus('paired');
} catch (err) {
setPairStatus(err.message);
} finally {
setPairing(false);
}
}, [pairCode, transportRef, userId]);
const loadMembers = useCallback(() => {
setLoading(true);
hubFetch(`/v1/groups/${groupId}/members`, { token })
.then(data => {
setMembers(data.members || []);
setAdminId(data.admin_id || '');
})
.catch(() => {})
.finally(() => setLoading(false));
}, [groupId, token]);
useEffect(() => { loadMembers(); }, [loadMembers]);
const isAdmin = group && group.is_admin;
const doInvite = useCallback(async (e) => {
e.preventDefault();
if (!inviteUser.trim()) return;
setInviting(true);
setError('');
setInviteCode(null);
try {
const transport = transportRef && transportRef.current;
const username = inviteUser.trim();
if (!transport || !transport.connected) {
throw new Error('Not connected to the node — it must be online to invite');
}
// The hub is asked for the account id, and nothing else. It is no longer
// asked for the invitee's public key: the node wraps the group key itself,
// for a key the invitee proves possession of when they connect (H3). A hub
// that answered with the wrong account here would produce an invite whose
// code it never learns — the code goes to a human, out of band.
const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
const signFn = (_sessionKeys && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
: null;
const result = await transport.createInvite(
account.user_id, groupId, username, signFn);
// Membership on the hub is what lets them reach the node at all; the code
// is what gets them the key.
await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
method: 'POST', token, body: {},
});
setInviteCode({ username, code: result.code, expires: result.expires_at });
setInviteUser('');
loadMembers();
} catch (err) {
setError(err.message);
} finally {
setInviting(false);
}
}, [groupId, token, inviteUser, loadMembers, transportRef]);
if (loading) return html`${t('explore.loading')}
`;
return html`
${t('admin.col_username')}
${t('members.group_role')}
${members.map(m => html`
${m.username}
${m.user_id === adminId
? html`${t('members.owner')} `
: html`${t('members.member')} `
}
`)}
${isAdmin && html`
${t('members.invite_title')}
${error && html`${error}
`}
${inviteCode && html`
${t('members.invite_code_ready', { user: inviteCode.username })}
${inviteCode.code}
${t('members.invite_code_hint')}
`}
setInviteUser(e.target.value)} required />
${inviting ? '...' : t('members.invite_btn')}
`}
${isNodeAdmin && html`
${t('members.pair_title')}
${t('members.pair_hint')}
${pairStatus && html`
${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
`}
setPairCode(e.target.value)} required />
${pairing ? '...' : t('members.pair_btn')}
`}
`;
}
// ── Chat Panel ──────────────────────────────────────────────────────────
function formatTime(ts) {
const d = new Date(ts * 1000);
const now = new Date();
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return time;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
}
function _parsePayload(raw) {
if (typeof raw === 'string' && raw.startsWith('{')) {
try { return JSON.parse(raw); } catch { /* not JSON */ }
}
return null;
}
function ChatImage({ filename, entries, transportRef, gekRef }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
const loadedRef = useRef(false);
useEffect(() => {
if (loadedRef.current) return;
let cancelled = false;
const load = async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) { setLoading(true); return; }
const entry = entries.find(e => e.name === filename);
if (!entry) { setLoading(true); return; }
try {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks);
if (cancelled) return;
const ext = filename.split('.').pop().toLowerCase();
const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif'
: ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg';
const blob = new Blob(chunks, { type: mime });
loadedRef.current = true;
setBlobUrl(URL.createObjectURL(blob));
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
};
load();
return () => { cancelled = true; };
}, [filename, entries.length]);
useEffect(() => {
return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); };
}, [blobUrl]);
if (loading) return html`
`;
if (!blobUrl) return html`${'\u{1F5BC}'} ${filename}
`;
return html` `;
}
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [attaching, setAttaching] = useState(false);
const listRef = useRef(null);
const bottomRef = useRef(null);
const loadedRef = useRef(false);
useEffect(() => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
if (!loadedRef.current) {
loadedRef.current = true;
transport.fetchChatHistory(0, 200)
.then(msgs => setMessages(msgs))
.catch(() => {});
}
transport.onChat = (msg) => {
setMessages(prev => [...prev, {
sender_id: msg.sender_id,
sender_name: msg.sender_name || '',
payload: msg.payload,
timestamp: msg.timestamp || Date.now() / 1000,
thread_id: msg.thread_id,
}]);
};
return () => { transport.onChat = null; };
}, [transportRef.current?.connected]);
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages.length]);
const sendMessage = useCallback(async () => {
const text = input.trim();
if (!text) return;
const transport = transportRef.current;
if (!transport || !transport.connected) return;
setSending(true);
setInput('');
try {
await transport.sendChat(text, 0, null, username);
setMessages(prev => [...prev, {
sender_id: username,
sender_name: username,
payload: text,
timestamp: Date.now() / 1000,
thread_id: null,
}]);
} catch {
setInput(text);
} finally {
setSending(false);
}
}, [input, username]);
const attachFile = useCallback(async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
const transport = transportRef.current;
if (!transport || !transport.connected) return;
setAttaching(true);
try {
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE);
const buf = new Uint8Array(await slice.arrayBuffer());
await transport.uploadChunk(file.name, i, totalChunks, buf);
}
await new Promise(r => setTimeout(r, 2500));
if (onRefreshIndex) await onRefreshIndex();
const ext = file.name.split('.').pop().toLowerCase();
const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image'
: ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file';
const structured = JSON.stringify({
text: '', attachment: { filename: file.name, size: file.size, type: ftype },
});
await transport.sendChat(structured, 0, null, username);
setMessages(prev => [...prev, {
sender_id: username, sender_name: username,
payload: structured, timestamp: Date.now() / 1000, thread_id: null,
}]);
} catch (err) {
alert(err.message);
} finally {
setAttaching(false);
}
}, [username, onRefreshIndex]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}, [sendMessage]);
return html`
${messages.length === 0 && html`
${t('chat.empty')}
`}
${messages.map((m, i) => {
const isOwn = m.sender_name === username || m.sender_id === username;
const displayName = m.sender_name || '?';
const showSender = !isOwn && (i === 0 ||
(messages[i - 1].sender_name || messages[i - 1].sender_id) !== (m.sender_name || m.sender_id));
const parsed = _parsePayload(m.payload);
const att = parsed && parsed.attachment;
return html`
${showSender && html`
${displayName}
`}
${att ? html`
{
if (!onPreview) return;
const entry = entries.find(e => e.name === att.filename);
if (entry) onPreview(entry);
}}>
${att.type === 'image'
? html`<${ChatImage} filename=${att.filename} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} />`
: att.type === 'video'
? html`
${'\u{1F3AC}'} ${att.filename}
`
: html`
${'\u{1F4CE}'} ${att.filename}
`
}
${formatSize(att.size)}
` : html`
${m.payload}
`}
${formatTime(m.timestamp)}
`;
})}
${attaching ? html` ` : '\u{1F4CE}'}
setInput(e.target.value)}
onKeyDown=${onKeyDown}
disabled=${sending} />
${t('chat.send')}
`;
}
// ── Video Player (MSE streaming) ────────────────────────────────────────
function _mseSupported(codec) {
if (!window.MediaSource) return false;
const mime = `video/mp4; codecs="${codec}"`;
return MediaSource.isTypeSupported(mime);
}
function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const videoRef = useRef(null);
const msRef = useRef(null);
const sbRef = useRef(null);
const blobUrlRef = useRef(null);
const queueRef = useRef([]);
const appendingRef = useRef(false);
const endedRef = useRef(false);
const durationRef = useRef(0);
const flushQueue = useCallback(() => {
const sb = sbRef.current;
if (!sb || appendingRef.current || sb.updating) return;
if (queueRef.current.length === 0) {
if (endedRef.current && msRef.current?.readyState === 'open') {
try { msRef.current.endOfStream(); } catch {}
}
return;
}
appendingRef.current = true;
const chunk = queueRef.current.shift();
try {
sb.appendBuffer(chunk);
} catch (e) {
appendingRef.current = false;
console.error('[MSE] appendBuffer error:', e);
}
}, []);
useEffect(() => {
let cancelled = false;
const transport = transportRef.current;
if (!transport || !transport.connected) {
setError(t('video.err_transport'));
setPhase('error');
return;
}
const onSeeking = () => {
const v = videoRef.current;
if (!v || !v.buffered.length) return;
const target = v.currentTime;
const end = v.buffered.end(v.buffered.length - 1);
const start = v.buffered.start(0);
if (target > end) v.currentTime = Math.max(end - 0.5, start);
else if (target < start) v.currentTime = start;
};
const startStream = async () => {
transport.onStreamInit = (msg) => {
if (cancelled) return;
const mime = `video/mp4; codecs="${msg.codec}"`;
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
setError(t('video.err_mse', { codec: msg.codec }));
setPhase('error');
return;
}
durationRef.current = msg.duration || 0;
const ms = new MediaSource();
msRef.current = ms;
const url = URL.createObjectURL(ms);
blobUrlRef.current = url;
ms.addEventListener('sourceopen', () => {
if (cancelled) return;
if (durationRef.current > 0) {
ms.duration = durationRef.current;
}
const sb = ms.addSourceBuffer(mime);
sbRef.current = sb;
sb.mode = 'sequence';
sb.addEventListener('updateend', () => {
appendingRef.current = false;
flushQueue();
});
setPhase('streaming');
flushQueue();
});
if (videoRef.current) {
videoRef.current.src = url;
videoRef.current.addEventListener('seeking', onSeeking);
}
};
transport.onStreamData = async (msg) => {
if (cancelled) return;
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
queueRef.current.push(plaintext);
flushQueue();
} catch (e) {
console.error('[MSE] decrypt error:', e);
}
};
transport.onStreamEnd = () => {
if (cancelled) return;
endedRef.current = true;
flushQueue();
};
transport.requestStream(entry.id);
};
startStream().catch(err => {
if (!cancelled) { setError(err.message); setPhase('error'); }
});
return () => {
cancelled = true;
if (videoRef.current) {
videoRef.current.removeEventListener('seeking', onSeeking);
}
if (transport) {
transport.onStreamInit = null;
transport.onStreamData = null;
transport.onStreamEnd = null;
}
};
}, [entry, flushQueue]);
useEffect(() => {
if (phase === 'streaming' && videoRef.current) {
videoRef.current.play().catch(() => {});
}
}, [phase]);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return html`
{
if (e.target.classList.contains('video-overlay')) onClose();
}}>
${entry.name} (${formatSize(entry.size)})
✕
${phase === 'loading' && html`
${' '}${t('video.buffering')}
`}
${(phase === 'streaming' || phase === 'loading') && html`
`}
${phase === 'error' && html`
${error}
`}
`;
}
// ── Search Page (cross-group file search) ───────────────────────────────────
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 });
}
}
}
setResults(hits);
setSearched(true);
}, []);
const onInput = useCallback((e) => {
const q = e.target.value;
setQuery(q);
doSearch(q);
}, [doSearch]);
return html`
${t('search.title')}
${searched && results.length === 0 && html`
${t('search.no_results')}
`}
${results.length > 0 && html`
${t('group.col_name')}
${t('group.col_size')}
${t('search.col_group')}
${t('group.col_type')}
${results.map(r => html`
${FILE_ICONS[r.type] || FILE_ICONS.other}
${r.name}
${formatSize(r.size)}
${r.groupName || r.groupId.slice(0, 8)}
${r.type}
`)}
${t('search.result_count', { n: results.length })}
`}
${!searched && html`
${t('search.hint')}
`}
`;
}
// ── Settings Page ───────────────────────────────────────────────────────────
const THEME_OPTIONS = ['light', 'dark', 'system'];
function SettingsPage({ user, theme, onThemeChange, groups }) {
const [locale, setLoc] = useState(getLocale);
const [muted, setMuted] = useState(() => {
try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); }
catch { return {}; }
});
const [nodeKey, setNodeKey] = useState('');
const [currentNodeKey, setCurrentNodeKey] = useState(null);
const [nodeKeyStatus, setNodeKeyStatus] = useState('');
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
// 11.5.8: node identity pins are refused strictly on change, so users need a
// deliberate way to accept a legitimate rotation (operator reinstalled a node).
const clearPins = useCallback(() => {
window.MeshBayTransport?.clearNodePin?.();
setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
}, []);
useEffect(() => {
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
.then(data => {
if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519);
})
.catch(() => {});
}, [user.username, user.token]);
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]);
const toggleMute = useCallback((gid) => {
setMuted(prev => {
const next = { ...prev, [gid]: !prev[gid] };
localStorage.setItem('mb_muted', JSON.stringify(next));
return next;
});
}, []);
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
setNodeKeyLoading(true);
setNodeKeyStatus('');
try {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token: user.token,
body: { pk_node_ed25519: key },
});
setCurrentNodeKey(key);
setNodeKey('');
setNodeKeyStatus(t('settings.node_key_success'));
} catch (e) {
setNodeKeyStatus(e.message);
} finally {
setNodeKeyLoading(false);
}
}, [nodeKey, user.token]);
return html`
${t('settings.title')}
${t('settings.profile')}
${t('settings.username')}
${user.username}
${t('settings.role')}
${user.role || 'user'}
${t('settings.node_key')}
${t('settings.node_key_desc')}
${currentNodeKey && html`
${t('settings.node_key_current')}
${currentNodeKey}
`}
setNodeKey(e.target.value)}
onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} />
${t('settings.node_key_submit')}
${nodeKeyStatus && html`
${nodeKeyStatus}
`}
${t('settings.node_pins')}
${t('settings.node_pins_hint')}
${t('settings.node_pins_count', { n: pinCount })}
${t('settings.node_pins_clear')}
${t('settings.appearance')}
${t('settings.theme')}
${t('settings.theme_light')}
${t('settings.theme_dark')}
${t('settings.theme_system')}
${t('settings.language')}
${LOCALES.map(l => html`
${l.name}
`)}
${groups.length > 0 && html`
${t('settings.groups')}
${groups.map(g => html`
${g.name}
toggleMute(g.id)} />
${' '}${t('settings.notifications')}
`)}
`}
${t('settings.about')}
${t('settings.version')}
0.1.0
${t('settings.protocol')}
MNP 0.1 / MHP 0.1
`;
}
// ── Admin Panel ─────────────────────────────────────────────────────────────
function AdminPage({ token }) {
const [tab, setTab] = useState('stats');
const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [userSearch, setUserSearch] = useState('');
const [groups, setGroups] = useState([]);
const [groupsTotal, setGroupsTotal] = useState(0);
const [logs, setLogs] = useState([]);
const [logEvent, setLogEvent] = useState('');
const [logOffset, setLogOffset] = useState(0);
const [blocklist, setBlocklist] = useState([]);
const [detailUser, setDetailUser] = useState(null);
const [error, setError] = useState('');
const headers = { Authorization: `Bearer ${token}` };
const loadStats = useCallback(async () => {
try {
const data = await hubFetch('/v1/admin/stats', { token });
setStats(data);
} catch (e) { setError(e.message); }
}, [token]);
const loadUsers = useCallback(async (q = '') => {
try {
const data = await hubFetch(`/v1/admin/users?q=${encodeURIComponent(q)}&limit=100`, { token });
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) { setError(e.message); }
}, [token]);
const loadGroups = useCallback(async () => {
try {
const data = await hubFetch('/v1/admin/groups?limit=100', { token });
setGroups(data.groups);
setGroupsTotal(data.total);
} catch (e) { setError(e.message); }
}, [token]);
const loadLogs = useCallback(async (event = '', offset = 0, append = false) => {
try {
let url = `/v1/admin/logs?limit=50&offset=${offset}`;
if (event) url += `&event=${encodeURIComponent(event)}`;
const data = await hubFetch(url, { token });
setLogs(prev => append ? [...prev, ...data.logs] : data.logs);
} catch (e) { setError(e.message); }
}, [token]);
const loadBlocklist = useCallback(async () => {
try {
const data = await hubFetch('/v1/admin/blocklist', { token });
setBlocklist(data.entries);
} catch (e) { setError(e.message); }
}, [token]);
useEffect(() => {
setError('');
if (tab === 'stats') loadStats();
else if (tab === 'users') loadUsers(userSearch);
else if (tab === 'groups') loadGroups();
else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
else if (tab === 'blocklist') loadBlocklist();
}, [tab]);
const patchUser = useCallback(async (userId, patch) => {
try {
await hubFetch(`/v1/admin/users/${userId}`, { method: 'PATCH', body: patch, token });
loadUsers(userSearch);
if (detailUser && detailUser.id === userId) setDetailUser(null);
} catch (e) { setError(e.message); }
}, [token, userSearch, detailUser]);
const patchGroup = useCallback(async (groupId, patch) => {
try {
await hubFetch(`/v1/admin/groups/${groupId}`, { method: 'PATCH', body: patch, token });
loadGroups();
} catch (e) { setError(e.message); }
}, [token]);
const showUserDetail = useCallback(async (userId) => {
try {
const data = await hubFetch(`/v1/admin/users/${userId}`, { token });
setDetailUser(data);
} catch (e) { setError(e.message); }
}, [token]);
const addToBlocklist = useCallback(async (hash, reason) => {
try {
await hubFetch('/v1/admin/blocklist', { method: 'POST', body: { content_hash: hash, reason }, token });
loadBlocklist();
} catch (e) { setError(e.message); }
}, [token]);
const removeFromBlocklist = useCallback(async (hash) => {
try {
await hubFetch(`/v1/admin/blocklist/${hash}`, { method: 'DELETE', token });
loadBlocklist();
} catch (e) { setError(e.message); }
}, [token]);
const TABS = ['stats', 'users', 'groups', 'logs', 'blocklist'];
return html`
${t('admin.title')}
${error && html`
${error}
`}
${TABS.map(k => html`
setTab(k)}>${t('admin.tab_' + k)}
`)}
${tab === 'stats' && stats && html`
${[['users', 'stat_users'], ['groups', 'stat_groups'],
['nodes', 'stat_nodes'], ['online_nodes', 'stat_online']].map(([k, label]) => html`
${stats[k]}
${t('admin.' + label)}
`)}
`}
${tab === 'users' && html`
{ setUserSearch(e.target.value); loadUsers(e.target.value); }} />
${usersTotal} total
${t('admin.col_username')}
${t('admin.col_role')}
${t('admin.col_status')}
${t('admin.col_created')}
${t('admin.col_actions')}
${users.length === 0 && html`${t('admin.no_users')} `}
${users.map(u => html`
${u.username}
patchUser(u.id, { role: e.target.value })}>
user
moderator
admin
${u.status}
${new Date(u.created_at).toLocaleDateString()}
showUserDetail(u.id)}>${t('admin.btn_details')}
${u.status === 'active'
? html` patchUser(u.id, { status: 'suspended' })}>${t('admin.btn_suspend')} `
: u.status === 'suspended'
? html` patchUser(u.id, { status: 'active' })}>${t('admin.btn_unsuspend')} `
: null
}
`)}
`}
${tab === 'groups' && html`
${groupsTotal} total
${t('admin.col_name')}
${t('admin.col_visibility')}
${t('admin.col_members')}
${t('admin.col_status')}
${t('admin.col_created')}
${t('admin.col_actions')}
${groups.length === 0 && html`${t('admin.no_groups')} `}
${groups.map(g => html`
${g.name}
${g.visibility}
${g.member_count}
${g.status}
${new Date(g.created_at).toLocaleDateString()}
${g.status === 'active'
? html` patchGroup(g.id, { status: 'suspended' })}>${t('admin.btn_suspend')} `
: g.status === 'suspended'
? html` patchGroup(g.id, { status: 'active' })}>${t('admin.btn_unsuspend')} `
: null
}
`)}
`}
${tab === 'logs' && html`
{
setLogEvent(e.target.value);
setLogOffset(0);
loadLogs(e.target.value, 0);
}}>
${t('admin.filter_all')}
${['login', 'login_fail', 'account_create', 'token_refresh', 'group_create',
'group_join', 'group_leave', 'node_announce', 'revoke_user', 'revoke_group',
'admin_user_update', 'admin_group_update'].map(ev => html`
${ev}
`)}
${t('admin.col_time')}
${t('admin.col_user')}
${t('admin.col_event')}
${t('admin.col_ip')}
${t('admin.col_detail')}
${logs.length === 0 && html`${t('admin.no_logs')} `}
${logs.map(lg => html`
${new Date(lg.timestamp).toLocaleString()}
${lg.username || ''}
${lg.event}
${lg.ip_address}
${lg.detail || ''}
`)}
${logs.length > 0 && logs.length % 50 === 0 && html`
{
const next = logOffset + 50;
setLogOffset(next);
loadLogs(logEvent, next, true);
}}>${t('admin.btn_load_more')}
`}
`}
${tab === 'blocklist' && html`
<${BlocklistForm} onAdd=${addToBlocklist} />
${t('admin.col_hash')}
${t('admin.col_reason')}
${t('admin.col_date')}
${t('admin.col_added_by')}
${t('admin.col_actions')}
${blocklist.length === 0 && html`${t('admin.no_blocked')} `}
${blocklist.map(b => html`
${b.hash.slice(0, 16)}...
${b.reason}
${new Date(b.added_at).toLocaleDateString()}
${b.added_by || ''}
removeFromBlocklist(b.hash)}>${t('admin.btn_unblock')}
`)}
`}
${detailUser && html`
{
if (e.target.classList.contains('admin-detail-overlay')) setDetailUser(null);
}}>
${t('admin.user_detail')}
${[
['admin.col_username', detailUser.username],
['admin.col_email', detailUser.email],
['admin.col_role', detailUser.role],
['admin.col_status', detailUser.status],
['admin.col_created', new Date(detailUser.created_at).toLocaleString()],
['admin.col_groups', detailUser.group_count],
].map(([label, val]) => html`
${t(label)}
${val}
`)}
setDetailUser(null)}>${t('admin.btn_close')}
`}
`;
}
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`
setHash(e.target.value)}
pattern="[0-9a-f]{64}" required />
setReason(e.target.value)} required />
${t('admin.btn_block')}
`;
}
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const resolved = resolveTheme(theme);
useEffect(() => {
document.documentElement.className = `theme-${resolved}`;
localStorage.setItem(THEME_KEY, theme);
}, [theme, resolved]);
const fetchNotifications = useCallback(() => {
if (!user) return;
hubFetch('/v1/notifications?limit=20', { token: user.token })
.then(data => {
setNotifications(data.notifications || []);
setUnreadCount(data.unread_count || 0);
})
.catch(() => {});
}, [user]);
useEffect(() => {
if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
fetchNotifications();
}, [user]);
const markRead = useCallback((id) => {
if (!user) return;
hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', token: user.token })
.then(() => fetchNotifications())
.catch(() => {});
}, [user, fetchNotifications]);
useEffect(() => { setMenuOpen(false); }, [route]);
const changeTheme = useCallback((val) => {
setTheme(val);
}, []);
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;
_bundleKey = data.bundleKey;
await _storeBundleKey(_bundleKey);
if (data.skXB64) {
_sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
_pendingBundlePush = data.keypairBundleEnc;
}
} 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 });
if (_sessionKeys) {
const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
_sessionKeys.pkXB64 = pubkeys.pk_x25519;
_saveSessionKeys();
}
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
setUser(u);
saveAuth(u);
},
logout: () => {
setUser(null);
saveAuth(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.
const refreshAuth = useCallback(async () => {
if (!user || !user.refreshToken) return null;
const data = await hubFetch('/v1/users/token/refresh', {
method: 'POST', body: { refresh_token: user.refreshToken },
});
const u = { ...user, token: data.access_token };
setUser(u);
saveAuth(u);
return data.access_token;
}, [user]);
let page;
if (route === '/login' || route === '/register') {
page = route === '/register'
? html`<${RegisterPage} />`
: html`<${LoginPage} />`;
} else if (!user) {
page = html`<${LoginPage} />`;
} else if (route === '/search') {
page = html`<${SearchPage} />`;
} else if (route === '/explore') {
page = html`<${ExplorePage} token=${user.token}
myGroupIds=${groups.map(g => g.id)} />`;
} else if (route === '/create-group') {
page = html`<${CreateGroupPage} token=${user.token}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => {});
}} />`;
} 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}
onRefreshAuth=${refreshAuth} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
: html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
onThemeChange=${setTheme} groups=${groups} />`;
} else {
page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`;
}
return html`
<${AuthContext.Provider} value=${authCtx}>
<${Nav}
user=${user}
theme=${theme}
onThemeChange=${changeTheme}
onLogout=${authCtx.logout}
onMenuToggle=${() => setMenuOpen(o => !o)}
unreadCount=${unreadCount} />
${user && html`<${Sidebar}
groups=${groups}
route=${route}
menuOpen=${menuOpen}
role=${user.role} />`}
${menuOpen && html`
setMenuOpen(false)} />`}
${page}
/>
`;
}
// ── Boot ─────────────────────────────────────────────────────────────────────
render(html`<${App} />`, document.getElementById('app'));