From 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 23 Aug 2026 15:15:35 +0200 Subject: feat(hub): split the group UI into a pluggable "applications" architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA --- .../src/meshbay_hub/static/files-app.js | 620 +++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/files-app.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js new file mode 100644 index 0000000..561d8a7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -0,0 +1,620 @@ +import { + html, useState, useEffect, useRef, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; +import { transfers } from './transfers.js'; +import { + FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, + _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, +} from './file-utils.js'; + +// ── Files ──────────────────────────────────────────────────────────────────── +// +// The group's file browser: toolbar (upload, mkdir, filter, select), the +// table itself, and every file/directory operation. `entries`/`nodeDirs`/ +// `nodeRoots` are owned by the group shell (group-page.js) — Chat needs the +// same index for its image attachments — and handed down here read-only +// alongside `applyIndex`/the raw setters to write back through, the same +// shape ChatPanel already takes for `onRefreshIndex`. +// +// `onPreview` opens a file in the shell's video/preview modal rather than +// this component owning that state itself, again because more than one tab +// (Chat's attachments) can trigger it. + +function FilesPanel({ + groupId, transportRef, gekRef, status, + entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, +}) { + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState(() => new Set()); + const [sortKey, setSortKey] = useState('name'); + const [sortAsc, setSortAsc] = useState(true); + const [filter, setFilter] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + + // A directory from the group just left rarely exists in the one just + // entered (e.g. "outputs" in one group, absent in another) — Files would + // otherwise show that stale path and list nothing. + useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); + + const downloadFile = useCallback(async (entry) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + // Both of these have to happen inside the click: a browser grants a file + // picker, and re-grants a folder, only from a user gesture — so this stays + // a click handler all the way down into downloadEntry's own picker call. + await downloadEntry(transfers, transport, gekRef.current, entry); + }, []); + + const uploadFile = useCallback((e) => { + const files = [...(e.target.files || [])]; + e.target.value = ''; + const transport = transportRef.current; + if (!files.length || !transport || !transport.connected) return; + setError(''); + + for (const file of files) { + transfers.start({ + kind: 'upload', name: file.name, total: file.size, transport, + run: async ({ signal, onProgress }) => { + await transport.uploadFile(file, { + // Bytes the node acknowledged, not bytes read locally. + onProgress: (sent) => onProgress(sent, file.size), + signal, + }); + // The node re-indexes on a filesystem event, so there is nothing to + // wait on but the clock. Refreshing here means the file appears in + // the list without anyone reloading. + await new Promise(r => setTimeout(r, 2500)); + if (transport.connected) applyIndex(await transport.fetchIndex()); + }, + }); + } + }, [applyIndex]); + + 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); + if (indexMsg.roots) setNodeRoots(indexMsg.roots); + } catch (err) { + setError(err.message); + } + }, [currentPath]); + + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. + const target = await _openDownloadTarget(suggested, totalBytes, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + const gek = gekRef.current; + const zipOpenRef = { url: null }; + + transfers.start({ + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, + open: target + ? (target.open || null) + : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const writable = target ? target.writable : null; + const parts = writable ? null : []; + let written = 0; + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gek, entry.id, totalChunks, + (bytes) => { written += bytes; onProgress(written, totalBytes); }, + // pipelinedDownload writes in order, which the archive needs. + { write: (plaintext) => zip.write(plaintext) }, signal); + await zip.end(); + } + await zip.finish(); + if (writable) await writable.close(); + else { + const blob = new Blob(parts, { type: 'application/zip' }); + _saveBlob(blob, suggested); + zipOpenRef.url = URL.createObjectURL(blob); + } + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + + 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 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(); + + // At the top of a group the folders on screen ARE the roots, so their state + // belongs there. Deeper in, everything shown lives inside one readable root + // and there is nothing to flag. + const rootState = new Map(nodeRoots.map(r => [r.name, r])); + const unavailableHere = currentPath + ? [] + : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); + // A member cannot create a folder at the top of a group: that level is the + // set of roots, which is the operator's configuration and not a directory on + // anyone's disk. The node refuses it, so offering it would only produce an + // error nobody can act on. + const canCreateDir = Boolean(currentPath) && isNodeAdmin; + + const breadcrumbs = currentPath ? currentPath.split('/') : []; + + // Selection is keyed globally — file ids, and 'dir:' plus a full path — so + // walking into another folder keeps what was already ticked. + const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); + const selectedFiles = entries.filter(e => selected.has(e.id)); + const selectedDirs = [...selected] + .filter(k => typeof k === 'string' && k.startsWith('dir:')) + .map(k => k.slice(4)); + const toggle = (key) => setSelected(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + + const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 + ? selectedFiles[0] : null; + const deletableFiles = selectedFiles.filter( + e => isNodeAdmin || (userId && e.uploader_id === userId)); + + const run = (fn) => { + setSelecting(false); + setSelected(new Set()); + Promise.resolve().then(fn).catch(err => { + if (err && err.name !== 'AbortError') setError(err.message); + }); + }; + + // Icon only, with the name in the tooltip: these sit in a toolbar that is + // already narrow, and every one of them is a verb the icon carries on its + // own. `title` gives the hover text and `aria-label` the accessible name — + // an icon button with neither is unusable with a screen reader. + // + // Every action is rendered as soon as Select is on, and the ones that do not + // apply are disabled rather than absent. Buttons appearing and vanishing as + // the selection changed made the bar jump about and gave no clue that an + // action existed at all before something was ticked. + const action = (icon, label, onClick, opts = {}) => html` + + `; + + const canPlay = !!(onlyFile && onlyFile.type === 'video'); + const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); + const deletableCount = deletableFiles.length + + (operatorPaired ? selectedDirs.length : 0); + // The operator can always delete; anyone else only ever sees the button if + // something here is theirs to remove. Hiding it from an uploader would take + // away a right the protocol grants them (draft-v5 §5.1), not just a control. + const mayEverDelete = isNodeAdmin + || (userId && entries.some(e => e.uploader_id === userId)); + + const actionItems = html` + ${action('play', t('group.play'), + () => run(() => onPreview(onlyFile)), { disabled: !canPlay })} + ${action('eye', t('group.view'), + () => run(() => onPreview(onlyFile)), { disabled: !canView })} + ${action('download', + selectedFiles.length + ? t('group.download_n', { n: selectedFiles.length }) + : t('group.download'), + () => run(async () => { + // Awaited one at a time, and each returns as soon as its transfer is + // registered — so the transfers still run together. Firing them without + // awaiting meant every file asked the browser for a save dialog at + // once, and a browser allows one: the rest were rejected and only the + // first file ever downloaded. + for (const e of selectedFiles) await downloadFile(e); + }), { disabled: selectedFiles.length === 0 })} + ${action('archive', + selectedDirs.length + ? t('group.download_zip_n', { n: selectedDirs.length }) + : t('group.download_zip_n', { n: 0 }), + () => run(async () => { + for (const d of selectedDirs) await downloadDirectory(d); + }), { disabled: selectedDirs.length === 0 })} + ${mayEverDelete && action('trash', + deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), + () => { + const names = [...deletableFiles.map(e => e.name), + ...(operatorPaired ? selectedDirs : [])]; + if (!confirm(t('group.delete_n_confirm', { n: names.length, + names: names.join(', ') }))) return; + run(() => { + for (const e of deletableFiles) deleteFile(e); + if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); + }); + }, + { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} + `; + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` +

${' '}${t('status.connecting_short')}

+ `} + ${status === 'offline' && html` +

${t('group.offline_title')} ${t('group.offline_hint')}

+ `} + + ${status === 'connected' && html` +
+
+ ${mayUpload && html` + + `} + ${canCreateDir && html` + + `} +
+ + + +
+ + + ${selecting && html`
${actionItems}
`} +
+
+ + + + ${selecting && html``} + + + + + + + + + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + + selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> + ${selecting && html` + + `} + + + + + + + `; })} + ${sorted.map(e => html` + selecting && toggle(e.id)}> + ${selecting && html` + + `} + + + + + + + `)} + ${sorted.length === 0 && subdirs.length === 0 && 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 ? '▲' : '▼') : ''} +
+ ev.stopPropagation()} + onChange=${() => toggle(dirKey(d))} /> + ${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}${d}${unavailableHere.includes(d) ? html` + ${t('group.root_unavailable')} + ` : ''}${inside.length ? formatSize(bytes) : ''}
+ ev.stopPropagation()} + onChange=${() => toggle(e.id)} /> + ${FILE_ICONS[e.type] || FILE_ICONS.other} + ${!selecting && canPreview(e) + ? html` onPreview(e)}>${e.name}` + : e.name + } + ${formatSize(e.size)}${e.type}${formatDate(e.added_at)}
+ ${filter ? t('group.empty_filter') : t('group.empty_dir')} +
+ `} + `; +} + +// ── 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, onDownload }) { + const [phase, setPhase] = useState('loading'); + const [progress, setProgress] = useState(0); + const [content, setContent] = useState(null); + const [error, setError] = useState(''); + const [downloading, setDownloading] = useState(false); + 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 (/\.pdf$/i.test(entry.name)) { + // Decrypted here and shown from a blob: URL — the bytes never leave + // the page, and the browser's own viewer renders them. + const blob = new Blob(chunks, { type: 'application/pdf' }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'pdf' }); + } else 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)}) + ${onDownload && html` + + `} + +
+ ${phase === 'loading' && html` +
+
${t('video.loading', { name: entry.name })}
+
+
+
+
+ `} + ${phase === 'ready' && content?.type === 'pdf' && html` + +

${t('preview.pdf_fallback')}

+
+ `} + ${phase === 'ready' && content?.type === 'image' && html` +
+ ${entry.name} +
+ `} + ${phase === 'ready' && content?.type === 'text' && html` +
+
${content.text}
+
+ `} + ${phase === 'error' && html` +
${error}
+ `} +
+ `; +} + +export { FilesPanel, FilePreview }; -- cgit v1.2.3