import { html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, MEMORY_CEILING, pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; import { useStickyBand } from './sticky.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, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); const [currentPath, setCurrentPath] = useState(''); const [refreshing, setRefreshing] = useState(false); // The toolbar pins below the page's own band and tells the column heads how // far down to pin. Its height is not a constant — it wraps to three rows on // a phone and grows a field while a folder is being named — so it is // measured rather than written down (sticky.js). const toolbarBand = useStickyBand('--toolbar-h'); // Only the cross-group Search page shows this (`showRefresh`): it has no // live node connection pushing index deltas, so its file list really is // a one-shot cached fetch. In a real group the node streams every change // as it happens, so a manual re-fetch would just duplicate that. const doRefresh = useCallback(async () => { if (refreshing || !onRefreshIndex) return; setRefreshing(true); try { await onRefreshIndex(); } finally { setRefreshing(false); } }, [refreshing, onRefreshIndex]); // 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) => { let transport, gek; if (getTransport) { try { const conn = await getTransport(entry); transport = conn.transport; gek = conn.gek; } catch { setError(t('search.unreachable', { n: 1 })); return; } } else { transport = transportRef.current; gek = gekRef.current; } // Never a silent return. A click that produces nothing at all — no // transfer, no icon, no message — is indistinguishable from a broken // button, and it is what a download looks like whenever the WebRTC // connection is not up: on a screen lock, mid-reconnect, or after the node // restarted. Say so instead. if (!transport || !transport.connected) { setError(t('group.download_offline')); return; } await downloadEntry(transfers, transport, gek, entry); }, [getTransport]); const uploadFile = useCallback((e) => { const files = [...(e.target.files || [])]; e.target.value = ''; const transport = transportRef.current; if (!files.length || !transport || !transport.connected) return; // The folder on screen is the destination — not its root, and not a // subdirectory of the node's invention. Somebody dropping a file into the // folder they are looking at expects it to be in that folder. const uploadDir = currentPath; if (!uploadDir) return; const uploadRoot = uploadDir.split('/')[0]; setError(''); for (const file of files) { transfers.start({ kind: 'upload', name: file.name, total: file.size, transport, // `makeLease`, not `lease`: pausing gives the slot back, so resuming // has to be able to ask for another one, and a transfer handed a lease // it cannot re-create is refused the button rather than offered one // that would drop its slot for good. makeLease: () => transport.openTransfer({ kind: 'upload', bytes: file.size }), // A `File` is seekable and the node remembers how much it holds, so // there is no target tier to consult here — unlike a download. pausable: true, run: async ({ signal, onProgress, lease }) => { await transport.uploadFile(file, { // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, root: uploadRoot, dir: uploadDir, tr: lease && lease.tr, }); // 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, currentPath]); // `null` while not creating one; a draft string while the field is open. const [newDirName, setNewDirName] = useState(null); const [creatingDir, setCreatingDir] = useState(false); /** * Create a folder in the directory being browsed. * * The name comes from a field in the toolbar rather than `window.prompt`, * which **throws** in Electron — "prompt() is not supported" — and threw * outside this function's try, so clicking the button did nothing at all: * no folder, no error, nothing in the interface to react to. `confirm()` and * `alert()` do work there and are used elsewhere; `prompt` is the one * Chromium leaves to the embedder and Electron declines to implement. * * An inline field is better anyway — it can show the refusal next to the * input instead of after the dialog has closed. */ useEffect(() => { setNewDirName(null); }, [currentPath]); const makeDirectory = useCallback(async (rawName) => { const transport = transportRef.current; const name = (rawName || '').trim(); if (!name) return; if (!transport || !transport.connected) { setError(t('group.mkdir_offline')); return; } setCreatingDir(true); try { await transport.createDirectory(currentPath, name); // `list_dirs` walks the filesystem rather than the index, so a folder // with nothing in it is here on this very fetch. const indexMsg = await transport.fetchIndex(); if (indexMsg.entries) setEntries(indexMsg.entries); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); setNewDirName(null); } catch (err) { setError(err.message); } finally { setCreatingDir(false); } }, [currentPath]); // The implementation lives in file-utils.js (docs/photos.md §3) so // photos-app.js's own "zip this album" button can call the same code // rather than a second one. const downloadDirectory = useCallback(async (dir) => { const transport = transportRef.current; await sharedDownloadDirectory( transfers, transport, gekRef.current, entries, dir, { setError }); }, [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]); // With text in the field the user is searching, not browsing: match every // file in the whole group, wherever it lives, by name or by the folder it // sits in — and hide the folder rows, there is nothing to walk into. The // old code only ever filtered files whose folder was exactly `currentPath`, // so at the top of a group — where the rows are roots, never loose files — // typing did nothing at all. const q = filter.trim().toLowerCase(); const dirs = new Set(); const filteredEntries = entries.filter(e => { const ePath = e.path || ''; if (q) { return (e.name || '').toLowerCase().includes(q) || ePath.toLowerCase().includes(q); } if (ePath === currentPath) return true; 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. Skipped while searching — the // results are a flat list of matches, not a folder view. if (!q) 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); const currentRootName = currentPath ? currentPath.split('/')[0] : ''; const currentRoot = currentRootName ? rootState.get(currentRootName) : null; const currentRootWritable = currentRoot ? Boolean(currentRoot.writable) : 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. // // Otherwise the rule is the same as the Upload button's, and for the same // reason the node gives: "making a directory is not a privileged act — a // member who can add a file can organise where it goes". It used to require // `isNodeAdmin`, which contradicted the node and hid the control from // everyone who could actually use it. const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly; 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 allVisibleKeys = [...subdirs.map(d => dirKey(d)), ...sorted.map(e => e.id)]; const allSelected = allVisibleKeys.length > 0 && allVisibleKeys.every(k => selected.has(k)); const someSelected = allVisibleKeys.some(k => selected.has(k)); const toggleAll = () => { if (allSelected) { setSelected(new Set()); } else { setSelected(prev => { const next = new Set(prev); for (const k of allVisibleKeys) next.add(k); 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) => { 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' || onlyFile.type === 'audio')); const canView = !!(onlyFile && onlyFile.type !== 'video' && onlyFile.type !== 'audio' && 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 })} ${!readOnly && 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 && !readOnly && 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`
${currentPath && currentRootWritable && html` `} ${/* Icon only: the toolbar already carries a labelled primary action, and a second one beside it competes with it for the width a breadcrumb trail needs. The name lives in the tooltip and in aria-label, so it is not lost to anyone reading with something other than their eyes. */''} ${canCreateDir && newDirName === null && html` `} ${canCreateDir && newDirName !== null && html` setNewDirName(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') makeDirectory(newDirName); if (e.key === 'Escape') setNewDirName(null); }} /> `}
${!readOnly && html``}
${actionItems}
${showGroup && html``} ${currentPath && html` { const parts = currentPath.split('/'); setCurrentPath(parts.slice(0, -1).join('/')); }}> ${showGroup && 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); const rs = rootState.get(d); const isEjected = rs && rs.ejected; const isUnavail = unavailableHere.includes(d); const isRemovable = rs && rs.removable; return html` { if (!isEjected) setCurrentPath(full); }}> ${/* `file-name`, like a file's own name cell. Without it this was a bare ${showGroup && html``} `; })} ${sorted.map(e => html` ${showGroup && html``} `)} ${sorted.length === 0 && subdirs.length === 0 && html` `}
{ if (el) el.indeterminate = someSelected && !allSelected; }} onChange=${toggleAll} /> toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} ${t('search.col_group')} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
${'\u{1F4C1}'} ..
ev.stopPropagation()} onChange=${() => toggle(dirKey(d))} /> ${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}, so a folder called `Rage_Against_The_Machine_Discography_1992-2000_FLAC` — one unbreakable word, which is how music libraries are named — set the column's minimum width to the whole string. Measured on a phone: a 527px table in a 390px window, and on Android a page wider than the screen takes every pinned header out of the visible area with it. */''} ${d}${isEjected ? html` ${t('group.root_ejected')} ` : isUnavail ? html` ${t('group.root_unavailable')} ` : ''}${rs && rs.writable && !isEjected ? html` ✎ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html` ` : ''} ${inside.length ? formatSize(bytes) : ''}
ev.stopPropagation()} onChange=${() => toggle(e.id)} /> ${FILE_ICONS[e.type] || FILE_ICONS.other} ${canPreview(e) ? html` onPreview(e)}>${e.name}` : e.name } ${q && e.path && html` { setFilter(''); setCurrentPath(e.path); }}>${e.path}`} ${formatSize(e.size)} ${e.groupName || ''} ${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 () => { // Nothing here streams: a preview is decrypted whole, held as an array of // chunks, and turned into a blob. That is right for a page of text and a // photograph, and it is a dead tab for the things that also reach here — // a scanned PDF, a multi-gigabyte .csv or .log. There was no size test at // all, and the text branch is the sharpest illustration: it decoded the // entire file and then kept 500 000 characters of it. // // Films and music never arrive (group-page.js's onPreview routes video to // the MSE player and audio to the music queue), so this guard is only ever // met by a document somebody clicked without knowing how big it was. It // offers the download instead, which does stream. if (entry.size > MEMORY_CEILING) { setError(t('preview.too_large', { size: formatSize(entry.size), limit: formatSize(MEMORY_CEILING), })); setPhase('error'); return; } 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 };