summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js564
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css101
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js203
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js41
5 files changed, 663 insertions, 261 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 995e7c7..03b76d5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -4,6 +4,7 @@ import {
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
+import { transfers, formatSpeed } from './transfers.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -255,6 +256,10 @@ const ICON_PATHS = {
'M3.4 6.6L12 13.4l8.6-6.8'],
door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5',
'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'],
+ download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'],
+ upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'],
+ transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5',
+ 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'],
clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
'M14.5 6.5l3 3'],
@@ -341,6 +346,87 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) {
`;
}
+// ── Transfers widget ─────────────────────────────────────────────────────────
+
+function TransferWidget() {
+ const [items, setItems] = useState(() => transfers.list());
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => transfers.subscribe(setItems), []);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
+ };
+ document.addEventListener('click', close);
+ return () => document.removeEventListener('click', close);
+ }, [open]);
+
+ const running = items.filter(i => i.status === 'running');
+ if (!items.length) return null;
+
+ return html`
+ <div class="transfer-wrap" ref=${ref}>
+ <button class="nav-notif transfer-btn" title=${t('transfers.title')}
+ onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}>
+ <${Icon} name="transfer" />
+ ${running.length > 0 && html`
+ <span class="notif-badge">${running.length}</span>
+ `}
+ </button>
+ ${open && html`
+ <div class="transfer-panel">
+ <div class="transfer-head">
+ ${t('transfers.title')}
+ <button class="btn-secondary"
+ onClick=${() => transfers.clearFinished()}>
+ ${t('transfers.clear')}
+ </button>
+ </div>
+ ${items.map(it => html`
+ <div class="transfer-item" key=${it.id}>
+ <div class="transfer-line">
+ <span class="transfer-kind">
+ <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
+ </span>
+ <span class="transfer-name" title=${it.name}>${it.name}</span>
+ ${it.status === 'running' && html`
+ <button class="transfer-cancel" title=${t('transfers.cancel')}
+ onClick=${() => transfers.cancel(it.id)}>
+ <${Icon} name="close" />
+ </button>
+ `}
+ </div>
+ ${it.status === 'running'
+ ? html`
+ <div class="dl-progress">
+ <div class="dl-fill" style="width:${it.percent}%"></div>
+ </div>
+ <div class="transfer-meta">
+ <span>${formatSize(it.done)}${it.total
+ ? ' / ' + formatSize(it.total) : ''}</span>
+ <span>${formatSpeed(it.speed)}</span>
+ </div>
+ `
+ : html`
+ <div class="transfer-meta">
+ <span class=${it.status === 'failed' ? 'transfer-failed' : ''}>
+ ${it.status === 'done' ? t('transfers.done')
+ : it.status === 'cancelled' ? t('transfers.cancelled')
+ : it.error || t('transfers.failed')}
+ </span>
+ </div>
+ `}
+ </div>
+ `)}
+ </div>
+ `}
+ </div>
+ `;
+}
+
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) {
@@ -354,6 +440,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }
<a class="nav-brand" href="#/">MeshBay</a>
</div>
<div class="nav-right">
+ ${user && html`<${TransferWidget} />`}
${user && html`
<a class="nav-notif" href="#/" title=${t('notif.title')}>
<${Icon} name="bell" />${unreadCount > 0
@@ -832,10 +919,31 @@ function formatDate(ts) {
// ── Group Page ──────────────────────────────────────────────────────────────
+const PREVIEWABLE_TEXT =
+ /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
+
+function canPreview(e) {
+ return ['image', 'video', 'document'].includes(e.type)
+ || PREVIEWABLE_TEXT.test(e.name);
+}
+
const CHUNK_SIZE = 1024 * 1024;
const PIPELINE_WINDOW = 8;
-async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
+/** The download of last resort, for browsers with no way to stream to disk. */
+function _saveBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk,
+ writable, signal) {
const results = writable ? null : new Array(totalChunks);
let nextSend = 0, nextRecv = 0;
const inflight = new Array(totalChunks);
@@ -849,6 +957,11 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
fire();
while (nextRecv < totalChunks) {
+ if (signal && signal.aborted) {
+ const err = new Error('Cancelled');
+ err.name = 'AbortError';
+ throw err;
+ }
const chunkMsg = await inflight[nextRecv];
let plaintext;
if (gekKey && chunkMsg.ct) {
@@ -878,6 +991,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [entries, setEntries] = useState([]);
const [error, setError] = useState('');
+ const [selecting, setSelecting] = useState(false);
+ const [selected, setSelected] = useState(() => new Set());
+ const [actionsOpen, setActionsOpen] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState('');
const [savingDesc, setSavingDesc] = useState(false);
@@ -885,16 +1001,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
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
@@ -922,11 +1035,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}, [codeInput]);
useEffect(() => {
- if (menuOpen === null) return;
- const close = () => setMenuOpen(null);
+ if (!actionsOpen) return;
+ const close = () => setActionsOpen(false);
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
- }, [menuOpen]);
+ }, [actionsOpen]);
// 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
@@ -1042,8 +1155,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
return () => {
cancelled = true;
if (transportRef.current) {
- transportRef.current.onIndexSync = null;
- transportRef.current.close();
+ // Handed over rather than closed: a download running when you leave the
+ // group keeps its connection, and the last transfer using it closes it.
+ transfers.releaseWhenIdle(transportRef.current);
transportRef.current = null;
}
};
@@ -1056,77 +1170,71 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
+ const gek = gekRef.current;
- setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size });
+ // The file picker has to be opened here, in the click, before anything is
+ // handed to the store: a browser only grants one from a user gesture.
+ let handle = null;
+ if (window.showSaveFilePicker) {
+ try {
+ handle = await window.showSaveFilePicker({ suggestedName: entry.name });
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ throw err;
+ }
+ }
- try {
- const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
- let downloaded = 0;
- const onProgress = (bytes) => {
- downloaded += bytes;
- setDlState(prev => ({ ...prev, progress: downloaded }));
- };
+ transfers.start({
+ kind: 'download', name: entry.name, total: entry.size, transport,
+ run: async ({ signal, onProgress }) => {
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ let done = 0;
+ const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
- 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;
+ if (handle) {
+ const writable = await handle.createWritable();
+ try {
+ await pipelinedDownload(transport, gek, entry.id, totalChunks,
+ onChunk, writable, signal);
+ await writable.close();
+ } catch (err) {
+ await writable.abort().catch(() => {});
+ throw err;
+ }
+ } else {
+ const chunks = await pipelinedDownload(
+ transport, gek, entry.id, totalChunks, onChunk, null, signal);
+ _saveBlob(new Blob(chunks), entry.name);
}
- } 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;
+ const uploadFile = useCallback((e) => {
+ const files = [...(e.target.files || [])];
e.target.value = '';
const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- setUploading(true);
+ if (!files.length || !transport || !transport.connected) return;
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 }),
+
+ 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());
+ },
});
- // 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);
}
- }, []);
+ }, [applyIndex]);
const makeDirectory = useCallback(async () => {
const transport = transportRef.current;
@@ -1183,66 +1291,59 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
const suggested = (dir.split('/').pop() || 'files') + '.zip';
- let writable = null;
+ let handle = null;
if (window.showSaveFilePicker) {
- const handle = await window.showSaveFilePicker({
- suggestedName: suggested,
- types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }],
- });
- writable = await handle.createWritable();
+ try {
+ handle = await window.showSaveFilePicker({
+ suggestedName: suggested,
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ });
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ throw err;
+ }
} else if (!confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
}))) {
return;
}
+ const gek = gekRef.current;
- setDlState({ name: suggested, progress: 0, total: totalBytes });
- 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, gekRef.current, entry.id, totalChunks,
- (bytes) => {
- written += bytes;
- setDlState(prev => prev && { ...prev, progress: written });
- },
- // pipelinedDownload writes in order, which is what the archive needs.
- { write: (plaintext) => zip.write(plaintext) });
- await zip.end();
- }
- await zip.finish();
+ transfers.start({
+ kind: 'download', name: suggested, total: totalBytes, transport,
+ run: async ({ signal, onProgress }) => {
+ const writable = handle ? await handle.createWritable() : 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());
+ });
- if (writable) {
- await writable.close();
- } else {
- const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' }));
- const a = document.createElement('a');
- a.href = url;
- a.download = suggested;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
- setDlState(null);
- } catch (err) {
- setDlState(null);
- if (writable) await writable.abort().catch(() => {});
- if (err.name === 'AbortError') return;
- setError(t('group.dl_failed', { err: err.message }));
- }
+ 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 _saveBlob(new Blob(parts, { type: 'application/zip' }), suggested);
+ } catch (err) {
+ if (writable) await writable.abort().catch(() => {});
+ throw err;
+ }
+ },
+ });
}, [entries]);
const deleteDirectory = useCallback(async (dir) => {
@@ -1343,6 +1444,74 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
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) => { setActionsOpen(false); setSelecting(false);
+ setSelected(new Set()); fn(); };
+
+ const actionItems = html`
+ ${onlyFile && onlyFile.type === 'video' && html`
+ <button onClick=${() => run(() => setVideoEntry(onlyFile))}>
+ <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')}
+ </button>
+ `}
+ ${onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile) && html`
+ <button onClick=${() => run(() => setPreviewEntry(onlyFile))}>
+ <span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}
+ </button>
+ `}
+ ${selectedFiles.length > 0 && html`
+ <button onClick=${() => run(() => {
+ // Started together and left to run together: each is its own transfer,
+ // and the widget by the bell is where they are watched from now.
+ for (const e of selectedFiles) downloadFile(e);
+ })}>
+ <span class="fmi">${'\u{2B07}'}</span>
+ ${t('group.download_n', { n: selectedFiles.length })}
+ </button>
+ `}
+ ${selectedDirs.length > 0 && html`
+ <button onClick=${() => run(() => {
+ for (const d of selectedDirs) downloadDirectory(d);
+ })}>
+ <span class="fmi">${'\u{2B07}'}</span>
+ ${t('group.download_zip_n', { n: selectedDirs.length })}
+ </button>
+ `}
+ ${status === 'connected' && (deletableFiles.length > 0
+ || (operatorPaired && selectedDirs.length > 0)) && html`
+ <button class="danger" onClick=${() => {
+ 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);
+ });
+ }}>
+ <span class="fmi">${'\u{1F5D1}'}</span>
+ ${t('group.delete_n', { n: deletableFiles.length
+ + (operatorPaired ? selectedDirs.length : 0) })}
+ </button>
+ `}
+ `;
+
return html`
<div>
<div class="group-header">
@@ -1409,30 +1578,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
</div>
</form>
`}
- ${ulState && html`
- <div class="dl-bar">
- <span class="spinner"></span>
- <span class="dl-name">${ulState.name}</span>
- <div class="dl-progress">
- <div class="dl-fill" style=${`width:${ulState.total
- ? Math.round(ulState.sent / ulState.total * 100) : 0}%`}></div>
- </div>
- <span class="dl-pct">
- ${ulState.indexing
- ? t('group.upload_indexing')
- : `${ulState.total ? Math.round(ulState.sent / ulState.total * 100) : 0}%`}
- </span>
- </div>
- `}
- ${dlState && html`
- <div class="dl-bar">
- <span class="dl-name">${dlState.name}</span>
- <div class="dl-progress">
- <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div>
- </div>
- <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span>
- </div>
- `}
${status === 'connected' && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
@@ -1446,12 +1591,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${tab === 'files' && html`
<div class="file-toolbar">
<label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px">
- ${uploading ? t('group.uploading') : t('group.upload')}
- <input type="file" style="display:none" onChange=${uploadFile}
- disabled=${uploading} />
+ ${t('group.upload')}
+ <input type="file" multiple style="display:none"
+ onChange=${uploadFile} />
</label>
- <button class="admin-btn" style="margin-right:8px" onClick=${makeDirectory}
- disabled=${uploading}>${t('group.mkdir')}</button>
+ <button class="admin-btn" style="margin-right:8px"
+ onClick=${makeDirectory}>${t('group.mkdir')}</button>
<div class="breadcrumbs">
<a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
${breadcrumbs.map((seg, i) => {
@@ -1464,10 +1609,31 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
</div>
<input type="text" class="file-search" placeholder="${t('group.filter')}"
value=${filter} onInput=${e => setFilter(e.target.value)} />
+ <button class="admin-btn ${selecting ? 'active' : ''}"
+ style="margin-left:8px"
+ onClick=${() => {
+ setSelecting(v => !v);
+ setSelected(new Set());
+ setActionsOpen(false);
+ }}>
+ ${selecting ? t('group.select_done') : t('group.select')}
+ </button>
+ ${selecting && html`
+ <div class="actions-wrap">
+ <button class="admin-btn" disabled=${selected.size === 0}
+ onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}>
+ ${'\u{22EE}'} ${t('group.actions', { n: selected.size })}
+ </button>
+ ${actionsOpen && html`
+ <div class="file-menu">${actionItems}</div>
+ `}
+ </div>
+ `}
</div>
<table class="file-table">
<thead>
<tr>
+ ${selecting && html`<th class="sel-cell"></th>`}
<th></th>
<th class="sortable" onClick=${() => toggleSort('name')}>
${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''}
@@ -1481,7 +1647,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
<th class="sortable th-date" onClick=${() => toggleSort('date')}>
${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
</th>
- <th></th>
</tr>
</thead>
<tbody>
@@ -1491,52 +1656,34 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
return html`
<tr class="file-row dir-row" key=${full} onClick=${() =>
- setCurrentPath(full)}>
+ selecting ? toggle(dirKey(d)) : setCurrentPath(full)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(dirKey(d))}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(dirKey(d))} />
+ </td>
+ `}
<td>\u{1F4C1}</td>
<td>${d}/</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
<td class="td-type"></td>
<td class="td-date"></td>
- <td class="file-actions-cell">
- <button class="file-menu-btn" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full);
- }}>${'\u{22EE}'}</button>
- ${menuOpen === 'dir:' + full && html`
- <div class="file-menu">
- ${inside.length > 0 && html`
- <button onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(null);
- downloadDirectory(full);
- }}>
- <span class="fmi">${'\u{2B07}'}</span>
- ${t('group.download_zip', { n: inside.length })}
- </button>
- `}
- ${operatorPaired && status === 'connected' && html`
- <button class="danger" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(null);
- if (inside.length) { setError(t('group.rmdir_not_empty')); return; }
- if (confirm(t('group.rmdir_confirm', { name: d }))) {
- deleteDirectory(full);
- }
- }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button>
- `}
- </div>
- `}
- </td>
</tr>
`; })}
- ${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`
- <tr class="file-row" key=${e.id}>
+ ${sorted.map(e => html`
+ <tr class="file-row" key=${e.id}
+ onClick=${() => selecting && toggle(e.id)}>
+ ${selecting && html`
+ <td class="sel-cell">
+ <input type="checkbox" checked=${selected.has(e.id)}
+ onClick=${(ev) => ev.stopPropagation()}
+ onChange=${() => toggle(e.id)} />
+ </td>
+ `}
<td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
<td class="file-name">
- ${canPreview
+ ${!selecting && canPreview(e)
? html`<a class="file-link" onClick=${() => {
if (e.type === 'video') setVideoEntry(e);
else setPreviewEntry(e);
@@ -1547,42 +1694,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
<td class="file-size">${formatSize(e.size)}</td>
<td class="file-type td-type">${e.type}</td>
<td class="file-date td-date">${formatDate(e.added_at)}</td>
- <td class="file-actions-cell">
- <button class="file-menu-btn" onClick=${(ev) => {
- ev.stopPropagation();
- setMenuOpen(menuOpen === e.id ? null : e.id);
- }}>${'\u{22EE}'}</button>
- ${menuOpen === e.id && html`
- <div class="file-menu">
- ${canPreview && html`
- <button onClick=${() => {
- setMenuOpen(null);
- if (e.type === 'video') setVideoEntry(e);
- else setPreviewEntry(e);
- }}><span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}</button>
- `}
- <button onClick=${() => { setMenuOpen(null); downloadFile(e); }}>
- <span class="fmi">${'\u{2B07}'}</span> ${t('group.download')}
- </button>
- ${e.type === 'video' && html`
- <button onClick=${() => { setMenuOpen(null); setVideoEntry(e); }}>
- <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')}
- </button>
- `}
- ${(isNodeAdmin || (userId && e.uploader_id === userId)) && status === 'connected' && html`
- <button class="danger" onClick=${() => {
- setMenuOpen(null);
- if (confirm(t('group.delete_confirm', { name: e.name }))) deleteFile(e);
- }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.delete')}</button>
- `}
- </div>
- `}
- </td>
</tr>
- `;
- })}
+ `)}
${sorted.length === 0 && subdirs.length === 0 && html`
- <tr><td colspan="6" class="file-empty">
+ <tr><td colspan=${selecting ? 6 : 5} class="file-empty">
${filter ? t('group.empty_filter') : t('group.empty_dir')}
</td></tr>
`}
@@ -3142,6 +3257,9 @@ function App() {
saveAuth(u);
},
logout: () => {
+ // Navigating away leaves transfers running; signing out does not. They
+ // are moving data on tokens that are about to stop being ours.
+ transfers.reset();
setUser(null);
saveAuth(null);
setGroups([]);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 14a6c78..f43981b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -85,13 +85,11 @@ const en = {
'group.empty_dir': 'This directory is empty',
'group.download': 'Download',
'group.play': 'Play',
- 'group.dl_failed': 'Download failed: {err}',
'group.offline_title': 'No nodes are currently online for this group.',
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
'group.upload': 'Upload',
'group.mkdir': 'New folder',
'group.mkdir_prompt': 'Name of the new folder:',
- 'group.uploading': 'Uploading...',
'group.view': 'View',
'group.delete': 'Delete',
'group.delete_confirm': 'Delete {name}?',
@@ -278,6 +276,19 @@ const en = {
+ 'you normally talk. It works once, and it never passes through the hub.',
'members.invite_code_hint': 'They enter it the first time they open this group. '
+ 'You do not need to be online then.',
+ 'transfers.title': 'Transfers',
+ 'transfers.clear': 'Clear finished',
+ 'transfers.cancel': 'Cancel',
+ 'transfers.done': 'Finished',
+ 'transfers.cancelled': 'Cancelled',
+ 'transfers.failed': 'Failed',
+ 'group.select': 'Select',
+ 'group.select_done': 'Done',
+ 'group.actions': 'Actions ({n})',
+ 'group.download_n': 'Download ({n})',
+ 'group.download_zip_n': 'Download folders as zip ({n})',
+ 'group.delete_n': 'Delete ({n})',
+ 'group.delete_n_confirm': 'Delete {n} item(s)? {names}',
'group.download_zip': 'Download as zip ({n} files)',
'group.zip_empty': 'That folder has nothing in it to download.',
'group.zip_no_stream': 'This browser cannot write a download straight to disk, '
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index f83b433..24e8843 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -807,26 +807,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); }
.dl-btn:disabled { opacity: 0.3; cursor: not-allowed; }
-.dl-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: 8px;
- margin-bottom: 12px;
- font-size: 0.85em;
-}
-.dl-name {
- flex-shrink: 0;
- max-width: 200px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-weight: 500;
-}
.dl-progress {
flex: 1;
@@ -843,12 +824,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
transition: width 0.2s;
}
-.dl-pct {
- flex-shrink: 0;
- color: var(--text-secondary);
- font-size: 0.85em;
- white-space: nowrap;
-}
/* ── Settings page ───────────────────────────────────────────────────────── */
@@ -1204,6 +1179,82 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); }
.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; }
+/* ── Transfers widget ────────────────────────────────────────────────────── */
+
+.transfer-wrap { position: relative; display: flex; align-items: center; }
+.transfer-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+}
+.transfer-panel {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: -8px;
+ width: 330px;
+ max-height: 60vh;
+ overflow-y: auto;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg);
+ z-index: 60;
+ padding: 6px;
+}
+.transfer-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-weight: 600;
+ font-size: 0.85em;
+ color: var(--text);
+ padding: 4px 6px 6px;
+ border-bottom: 1px solid var(--border);
+}
+.transfer-item { padding: 8px 6px; border-bottom: 1px solid var(--border); }
+.transfer-item:last-child { border-bottom: none; }
+.transfer-line { display: flex; align-items: center; gap: 6px; }
+.transfer-kind { color: var(--text-dim); display: flex; }
+.transfer-name {
+ flex: 1;
+ font-size: 0.82em;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.transfer-cancel {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 0 2px;
+ display: flex;
+}
+.transfer-cancel:hover { color: var(--error); }
+.transfer-meta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.72em;
+ color: var(--text-dim);
+ margin-top: 3px;
+}
+.transfer-failed { color: var(--error); }
+
+/* ── File selection ──────────────────────────────────────────────────────── */
+
+.sel-cell { width: 28px; text-align: center; }
+.sel-cell input { cursor: pointer; }
+.actions-wrap { position: relative; display: inline-flex; margin-left: 8px; }
+/* Anchored to the right edge: the button sits at the end of the toolbar, so a
+ menu opening leftwards is a menu half off the screen. */
+.actions-wrap .file-menu { top: calc(100% + 4px); right: 0; left: auto; }
+.actions-wrap .file-menu button { white-space: nowrap; }
+.admin-btn.active { border-color: var(--accent); color: var(--accent); }
+
.admin-role-select {
padding: 3px 6px;
border: 1px solid var(--border);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
new file mode 100644
index 0000000..1ad1ec5
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -0,0 +1,203 @@
+/**
+ * Transfers that outlive the page that started them.
+ *
+ * Downloads and uploads used to be state inside GroupPage, which meant leaving
+ * a group killed them — the component unmounted, its effect closed the
+ * DataChannel, and a half-written file was all you had. They live here instead:
+ * a module-level store that nothing unmounts, with the group page as one of
+ * several possible views onto it.
+ *
+ * Two consequences worth stating, because they are the reason this exists:
+ *
+ * - The transport cannot be closed just because a page went away. A group
+ * page hands its transport over with `releaseWhenIdle()`, and the last
+ * transfer using it closes it.
+ * - Signing out is different from navigating. It cancels everything and
+ * closes what it was using, because the tokens those transfers are running
+ * on are about to stop being ours.
+ *
+ * No browser globals: exercised under Node by
+ * packages/meshbay-hub/tests/test_transfers.py.
+ */
+
+const SPEED_WINDOW_MS = 5000;
+
+let _nextId = 1;
+
+export class TransferStore {
+ constructor(now = () => Date.now()) {
+ this._now = now;
+ this._items = [];
+ this._subs = new Set();
+ this._releasing = new Set();
+ }
+
+ subscribe(fn) {
+ this._subs.add(fn);
+ return () => this._subs.delete(fn);
+ }
+
+ _emit() {
+ for (const fn of this._subs) fn(this.list());
+ }
+
+ /**
+ * What a view needs to render, as plain data — never the internals, so a
+ * render cannot accidentally hold a transport alive.
+ */
+ list() {
+ return this._items.map(it => ({
+ id: it.id,
+ kind: it.kind,
+ name: it.name,
+ total: it.total,
+ done: it.done,
+ status: it.status,
+ error: it.error || '',
+ speed: this._speed(it),
+ percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0,
+ }));
+ }
+
+ get active() {
+ return this._items.filter(it => it.status === 'running').length;
+ }
+
+ _speed(it) {
+ // Over a window rather than since the start: a transfer that stalls should
+ // read as slow immediately, not as its own historical average.
+ const s = it.samples;
+ if (s.length < 2) return 0;
+ const dt = (s[s.length - 1].t - s[0].t) / 1000;
+ if (dt <= 0) return 0;
+ return (s[s.length - 1].done - s[0].done) / dt;
+ }
+
+ /**
+ * Start a transfer.
+ *
+ * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a
+ * cancel that only sets a flag nobody reads is a button that lies.
+ */
+ start({ kind, name, total = 0, transport = null, run }) {
+ const item = {
+ id: _nextId++,
+ kind, name, total, transport,
+ done: 0,
+ status: 'running',
+ error: '',
+ samples: [{ t: this._now(), done: 0 }],
+ signal: { aborted: false },
+ };
+ this._items.push(item);
+ this._emit();
+
+ const onProgress = (done, total) => {
+ item.done = done;
+ if (total) item.total = total;
+ const t = this._now();
+ item.samples.push({ t, done });
+ while (item.samples.length > 2 && t - item.samples[0].t > SPEED_WINDOW_MS) {
+ item.samples.shift();
+ }
+ this._emit();
+ };
+
+ const finish = (status, error = '') => {
+ item.status = status;
+ item.error = error;
+ this._emit();
+ this._maybeRelease(item.transport);
+ };
+
+ const promise = Promise.resolve()
+ .then(() => run({ signal: item.signal, onProgress }))
+ .then(() => {
+ if (item.signal.aborted) finish('cancelled');
+ else {
+ if (item.total) item.done = item.total;
+ finish('done');
+ }
+ })
+ .catch(err => {
+ if (item.signal.aborted || err.name === 'AbortError') finish('cancelled');
+ else finish('failed', err.message || String(err));
+ });
+
+ item.promise = promise;
+ return item.id;
+ }
+
+ cancel(id) {
+ const item = this._items.find(it => it.id === id);
+ if (!item || item.status !== 'running') return;
+ item.signal.aborted = true;
+ // Marked at once. The work stops when it next looks, but a cancelled
+ // transfer should not keep reporting progress in the meantime.
+ item.status = 'cancelled';
+ this._emit();
+ this._maybeRelease(item.transport);
+ }
+
+ cancelAll() {
+ for (const it of this._items) {
+ if (it.status === 'running') this.cancel(it.id);
+ }
+ }
+
+ /** Drop everything finished, keeping what is still running. */
+ clearFinished() {
+ this._items = this._items.filter(it => it.status === 'running');
+ this._emit();
+ }
+
+ _busy(transport) {
+ return this._items.some(
+ it => it.transport === transport && it.status === 'running');
+ }
+
+ /**
+ * The group page is going away. Close its transport once nothing is using it,
+ * which may be now or may be in twenty minutes.
+ */
+ releaseWhenIdle(transport) {
+ if (!transport) return;
+ this._releasing.add(transport);
+ this._maybeRelease(transport);
+ }
+
+ _maybeRelease(transport) {
+ if (!transport || !this._releasing.has(transport)) return;
+ if (this._busy(transport)) return;
+ this._releasing.delete(transport);
+ try {
+ transport.onIndexSync = null;
+ transport.close();
+ } catch { /* already gone */ }
+ }
+
+ /** Signing out: stop everything and let go of every transport. */
+ reset() {
+ this.cancelAll();
+ for (const transport of [...this._releasing]) {
+ this._releasing.delete(transport);
+ try { transport.close(); } catch { /* already gone */ }
+ }
+ for (const it of this._items) {
+ if (it.transport) {
+ try { it.transport.close(); } catch { /* already gone */ }
+ }
+ }
+ this._items = [];
+ this._emit();
+ }
+}
+
+export const transfers = new TransferStore();
+
+/** Human-readable rate, for a widget that updates several times a second. */
+export function formatSpeed(bytesPerSecond) {
+ if (!bytesPerSecond || bytesPerSecond < 1) return '';
+ if (bytesPerSecond < 1024 * 1024) return `${Math.round(bytesPerSecond / 1024)} KB/s`;
+ return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 326aa8e..24c08b9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -42,6 +42,12 @@ const UPLOAD_CHUNK_SIZE = 48 * 1024;
const UPLOAD_WINDOW = 32;
const UPLOAD_BUFFER_HIGH = 1024 * 1024;
+function _aborted() {
+ const err = new Error('Cancelled');
+ err.name = 'AbortError';
+ return err;
+}
+
const JOIN_REFUSALS = {
code_required: 'This node does not know this browser yet. Ask the node operator '
+ 'for a pairing code (meshbay-node operator pair).',
@@ -73,7 +79,10 @@ class MeshBayTransport {
this._onStreamData = null;
this._onStreamEnd = null;
this._onIndexSync = null;
- this._onUploadAck = null;
+ // filename → the uploader waiting on it. Keyed rather than FIFO because
+ // several uploads may be in flight at once and their acks interleave; the
+ // node names the file in every one.
+ this._uploaders = new Map();
}
get connected() { return this._connected; }
@@ -533,10 +542,12 @@ class MeshBayTransport {
* free one rather than replacing anything. The ack says which, and that is what
* this returns.
*/
- async uploadFile(file, { chunkSize, onProgress } = {}) {
- // One at a time: the acks are matched by arrival, so two uploads sharing the
- // channel would credit each other's progress and finish at the wrong moment.
- if (this._onUploadAck) throw new Error('Another upload is already running');
+ async uploadFile(file, { chunkSize, onProgress, signal } = {}) {
+ // The same file twice at once would confuse the node, which keys its own
+ // upload state by name — and would race for the same destination.
+ if (this._uploaders.has(file.name)) {
+ throw new Error(`${file.name} is already being uploaded`);
+ }
const size = chunkSize || UPLOAD_CHUNK_SIZE;
const total = Math.max(1, Math.ceil(file.size / size));
let acked = 0;
@@ -544,7 +555,7 @@ class MeshBayTransport {
let failure = null;
const acks = [];
- this._onUploadAck = (msg) => {
+ this._uploaders.set(file.name, (msg) => {
if (msg.type === 'error') {
failure = new Error(msg.detail || 'Upload refused');
} else if (msg.stored_as) {
@@ -554,15 +565,17 @@ class MeshBayTransport {
if (onProgress) onProgress(Math.min(file.size, acked * size), file.size);
const waiter = acks.shift();
if (waiter) waiter();
- };
+ });
const nextAck = () => new Promise(r => acks.push(r));
try {
for (let i = 0; i < total; i++) {
+ if (signal && signal.aborted) throw _aborted();
// Backpressure: without it the whole file lands in the browser's send
// buffer in seconds and the progress bar becomes a work of fiction.
while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) {
+ if (signal && signal.aborted) throw _aborted();
await new Promise(r => setTimeout(r, 20));
}
while (i - acked >= UPLOAD_WINDOW) {
@@ -587,7 +600,7 @@ class MeshBayTransport {
if (failure) throw failure;
}
} finally {
- this._onUploadAck = null;
+ this._uploaders.delete(file.name);
}
return stored || {};
}
@@ -772,9 +785,15 @@ class MeshBayTransport {
// While an upload is in flight the acks are its own, and there are many of
// them: they must not be handed to whatever request happens to be oldest in
// the pending map.
- if (this._onUploadAck
- && (msg.type === 'file_upload_ack' || msg.type === 'error')) {
- this._onUploadAck(msg);
+ if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.filename)) {
+ this._uploaders.get(msg.filename)(msg);
+ return;
+ }
+ // An error carries no filename. With one upload running it is that
+ // upload's; with several there is no way to tell, so they all hear it and
+ // stop — which is the safe reading of an error on a shared channel.
+ if (msg.type === 'error' && this._uploaders.size) {
+ for (const handler of [...this._uploaders.values()]) handler(msg);
return;
}
if (msg.type === 'chat_msg' && this._onChat) {