`;
}
// ── 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 }) {
if (groups.length === 0) {
return html`
`;
}
// ── 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 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,
onJoined, onGroupUpdated }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [error, setError] = useState('');
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState('');
const [savingDesc, setSavingDesc] = useState(false);
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 [ulState, setUlState] = useState(null);
// Directories are not index entries, so a new empty one needs a nudge
// to appear in the breadcrumb listing.
const [nodeDirs, setNodeDirs] = useState([]);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
// the second one should make the pairing form go away.
const [operatorPaired, setOperatorPaired] = 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]);
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
// the search page went on offering a file that no longer existed until the
// group was reconnected.
const applyIndex = useCallback((indexMsg) => {
const fresh = indexMsg.entries || [];
setEntries(fresh);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
cacheGroupIndex(groupId, group ? group.name : groupId, fresh);
}, [groupId, group]);
useEffect(() => {
let cancelled = false;
// The cache is written here and read only by the search page. It used to
// seed this list too, which put a stale index on screen and then raced the
// live one: IndexedDB is async, so a fast node could be overwritten by the
// cache landing afterwards. Files shows what the node says, or says it
// cannot reach the node.
const connect = async () => {
setStatus('discovering');
setError('');
gekRef.current = null;
if (!_bundleKey) _bundleKey = await _loadBundleKey();
try {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
if (cancelled) return;
if (!nodesData.nodes || nodesData.nodes.length === 0) {
setStatus('offline');
return;
}
// No keys are carried in: the transport fetches this node's identity
// from the node, or creates one there on a first join.
const sessionKeys = 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);
setOperatorPaired(transport.memberRole === 'operator');
// A first join to this node generated an identity for it; leave it with
// the node so any other browser can become the same person here with the
// passphrase. It is this node's key and no other's.
if (transport.connected && transport.newNodeBundle) {
try {
await transport.storeKeypairBundle(transport.newNodeBundle);
transport.newNodeBundle = null;
} catch (e) {
console.warn('[MeshBay] could not leave our key with the node:', 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;
applyIndex(msg);
};
// We are in: an invitation to this group has served its purpose.
if (onJoined) onJoined(groupId);
const indexMsg = await transport.fetchIndex();
if (cancelled) return;
applyIndex(indexMsg);
setStatus('connected');
} 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;
}
};
// applyIndex is deliberately not a dependency: its identity changes with the
// `group` object, which the hub poll re-creates, and re-running this effect
// means tearing down the WebRTC connection. groupId is here, so a real group
// change still re-captures it.
}, [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('');
setUlState({ name: file.name, sent: 0, total: file.size, indexing: false });
try {
await transport.uploadFile(file, {
// Bytes actually acknowledged by the node, not bytes read locally.
onProgress: (sent) => setUlState(prev => prev && { ...prev, sent }),
});
// The node re-indexes on a filesystem event; there is nothing to poll, so
// say what is happening instead of showing a finished bar and no file.
setUlState(prev => prev && { ...prev, indexing: true });
await new Promise(r => setTimeout(r, 2500));
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
setUlState(null);
}
}, []);
const makeDirectory = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
const name = prompt(t('group.mkdir_prompt'));
if (!name || !name.trim()) return;
try {
await transport.createDirectory(currentPath, name.trim());
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
} catch (err) {
setError(err.message);
}
}, [currentPath]);
const saveDescription = useCallback(async (e) => {
e.preventDefault();
setSavingDesc(true);
try {
const r = await hubFetch(`/v1/groups/${groupId}`, {
method: 'PATCH', token, body: { description: descDraft },
});
if (onGroupUpdated) onGroupUpdated(groupId, { description: r.description });
setEditingDesc(false);
} catch (err) {
setError(err.message);
} finally {
setSavingDesc(false);
}
}, [groupId, token, descDraft, onGroupUpdated]);
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.
// Signed with the identity this node pinned for us — the only one it
// will accept, and the only one we hold here.
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.deleteFile(entry.id, signFn);
applyIndex(await transport.fetchIndex());
} catch (err) {
setError(err.message);
}
}, [applyIndex]);
const refreshIndex = useCallback(async () => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
applyIndex(await transport.fetchIndex());
} catch {}
}, [applyIndex]);
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;
});
// The node's own listing, so an empty folder is visible, plus anything implied
// by a file path in case the two ever disagree.
for (const d of nodeDirs) {
if (!currentPath && !d.includes('/')) dirs.add(d);
else if (currentPath && d.startsWith(currentPath + '/')) {
const rest = d.slice(currentPath.length + 1);
if (!rest.includes('/')) dirs.add(rest);
}
}
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 = baseLabel;
const statusClass = status === 'connected' ? 'status-ok'
: status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
const breadcrumbs = currentPath ? currentPath.split('/') : [];
return html`
`;
}
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, operatorPaired, onPaired }) {
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');
// The node has pinned this key as an operator key; the form has nothing
// left to do. It used to stay put through a refresh, because what governed
// it was the account, which pairing does not change.
if (onPaired) onPaired();
} 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 });
// Signed with the identity this node pinned for us — the only one it
// will accept, and the only one we hold here.
const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
const signFn = (sk && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(sk, 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`
`;
}
// ── Search Page (cross-group file search) ───────────────────────────────────
/**
* "3 hours ago", in the reader's language.
*
* The search page needs it because its results come from a cache: a file that
* was deleted an hour ago is still listed until the group is opened again, and
* the honest thing is to say how old the answer is rather than to imply it is
* live.
*/
function formatAgo(ts) {
if (!ts) return '';
const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' });
let delta = (ts - Date.now()) / 1000;
const steps = [['second', 60], ['minute', 60], ['hour', 24],
['day', 7], ['week', 4.35], ['month', 12], ['year', Infinity]];
for (const [unit, span] of steps) {
if (Math.abs(delta) < span || span === Infinity) {
return rtf.format(Math.round(delta), unit);
}
delta /= span;
}
return '';
}
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [searched, setSearched] = useState(false);
const doSearch = useCallback(async (q) => {
const term = q.trim().toLowerCase();
if (!term) { setResults([]); setSearched(false); return; }
const indexes = await getAllCachedIndexes();
const hits = [];
for (const idx of indexes) {
for (const e of (idx.entries || [])) {
if (e.name.toLowerCase().includes(term) ||
(e.path && e.path.toLowerCase().includes(term))) {
hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName,
syncedAt: idx.cachedAt });
}
}
}
setResults(hits);
setSearched(true);
}, []);
const onInput = useCallback((e) => {
const q = e.target.value;
setQuery(q);
doSearch(q);
}, [doSearch]);
return html`