aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 22:54:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:54:16 +0200
commit1a495f5ed3f8a55222d406152c833882264dc377 (patch)
tree0c048544cba200fe5ba939d45edd311b2fc69e59 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent6803447a8a5cc7a612d08bb858394fd7ae1b049c (diff)
downloadmeshbay-1a495f5ed3f8a55222d406152c833882264dc377.tar.gz
feat(hub): client-side transfer leases and the transfers panel
Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots since step 2 and nothing asked for one; now the client does, and the panel shows what is happening. `transport.openTransfer()` returns a Lease: `acquire()` resolves when the node grants, `release()` gives it back exactly once, and nothing else in the client speaks to the node about slots. Whether a node hands out slots is read from the handshake ack rather than guessed from a timeout — "no answer yet" and "this node will never answer" are indistinguishable in time, and guessing wrong either stalls every download or defeats the cap. Two things exist only because a queue can lie: a watchdog re-asks when a pushed grant does not arrive (the node is idempotent on `tr`, so asking again is free), and a grant for a transfer the page has forgotten is handed straight back rather than held until the node's deadline. The slot is asked for **after** there is somewhere to write, and that ordering is load-bearing: opening a target takes thirty seconds of streamed-download timeouts, or as long as somebody leaves a Save As dialog open, and a grant not taken up in time is revoked. Moving it earlier looked better and broke three downloads into one. Pinned by a test. The panel groups by state — running, waiting, finished — rather than re-sorting a flat list, so a row moves only when its own state does. The ETA is withheld until the speed window holds real measurement: a figure from the first two chunks swings between four seconds and an hour, and people plan around the first number they see. One live region announces state changes and not progress. Three silent paths closed on the way: a download refused for want of a user gesture (a browser grants one file picker per gesture, and downloading three files is one gesture) now falls back to the streamed path, which needs none; a click with no connection says so instead of doing nothing at all; and a queued transfer counts as busy, so a transport is never closed under one that is waiting for a grant that could then never arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js173
1 files changed, 122 insertions, 51 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 9d64292..ff066cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -4,7 +4,7 @@ import {
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
-import { transfers, formatSpeed } from './transfers.js';
+import { transfers, formatSpeed, etaSeconds } from './transfers.js';
import * as platform from './platform.js';
import * as downloads from './downloads.js';
import { Icon } from './icon.js';
@@ -157,69 +157,67 @@ function TransferWidget() {
}, [open]);
const running = items.filter(i => i.status === 'running');
+ const waiting = items.filter(i => i.status === 'queued');
+ const finished = items.filter(
+ i => i.status !== 'running' && i.status !== 'queued');
+ const active = running.length + waiting.length;
+
+ // Grouped, and in this order: what is moving, what is waiting, what is over.
+ // Re-sorting the flat list on every emit made rows jump under the pointer
+ // each time a neighbour finished — the group is what changes, not the
+ // position within it, so a row only moves when its own state does.
+ const groups = [
+ ['running', running],
+ ['waiting', waiting],
+ ['finished', finished],
+ ].filter(([, rows]) => rows.length);
+
if (!items.length) return null;
return html`
<div class="transfer-wrap" ref=${ref}>
<button class="nav-notif transfer-btn ${running.length ? 'active' : ''}"
+ aria-label=${t('transfers.title')}
+ aria-expanded=${open ? 'true' : 'false'}
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>
+ ${active > 0 && html`
+ <span class="notif-badge">${active}</span>
`}
</button>
+ ${/* One live region for the panel, announcing what changed state rather
+ than every progress tick — a reader that says "62%… 63%… 64%" for a
+ four-gigabyte film is a reader nobody leaves on. */''}
+ <span class="sr-only" aria-live="polite">
+ ${t('transfers.summary', { running: running.length, waiting: waiting.length })}
+ </span>
${open && html`
- <div class="transfer-panel">
+ <div class="transfer-panel" role="group"
+ aria-label=${t('transfers.title')}>
<div class="transfer-head">
- ${t('transfers.title')}
- <button class="btn-secondary"
- onClick=${() => transfers.clearFinished()}>
- ${t('transfers.clear')}
- </button>
+ <span class="transfer-head-title">${t('transfers.title')}</span>
+ ${active > 0 && html`
+ <span class="transfer-head-summary">
+ ${t('transfers.summary', {
+ running: running.length, waiting: waiting.length })}
+ </span>
+ `}
+ ${finished.length > 0 && html`
+ <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>
- ${it.canOpen
- ? html`<a class="transfer-name" href="#" title=${it.name}
- onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
- : html`<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>
- ${it.canOpen && html`
- <button class="link-btn" onClick=${() => transfers.open(it.id)}>
- ${t('transfers.open')}
- </button>
- `}
- </div>
- `}
+ ${groups.map(([label, rows]) => html`
+ <div class="transfer-group" key=${label}>
+ ${groups.length > 1 && html`
+ <div class="transfer-group-head">
+ ${t('transfers.group_' + label, { n: rows.length })}
+ </div>
+ `}
+ ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)}
</div>
`)}
</div>
@@ -228,6 +226,79 @@ function TransferWidget() {
`;
}
+/** One row. Split out so the panel above reads as a layout and this as a state
+ * machine — they change for different reasons. */
+function TransferRow({ it }) {
+ const eta = etaSeconds(it);
+ return html`
+ <div class="transfer-item transfer-${it.status}">
+ <div class="transfer-line">
+ <span class="transfer-kind" aria-hidden="true">
+ <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
+ </span>
+ ${it.canOpen
+ ? html`<a class="transfer-name" href="#" title=${it.name}
+ onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
+ : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`}
+ ${(it.status === 'running' || it.status === 'queued') && html`
+ <button class="transfer-cancel"
+ aria-label=${t('transfers.cancel_one', { name: it.name })}
+ title=${t('transfers.cancel')}
+ onClick=${() => transfers.cancel(it.id)}>
+ <${Icon} name="close" />
+ </button>
+ `}
+ </div>
+ ${it.status === 'queued'
+ ? html`
+ <div class="dl-progress dl-waiting"></div>
+ <div class="transfer-meta">
+ <span>${it.queuedByOwnLimit
+ ? t('transfers.waiting_own_slots')
+ : t('transfers.waiting_node', { n: it.ahead })}</span>
+ <span>${formatSize(it.total)}</span>
+ </div>
+ `
+ : it.status === 'running'
+ ? html`
+ <div class="dl-progress" role="progressbar"
+ aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100">
+ <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),
+ it.settled && eta !== null ? formatEta(eta) : '']
+ .filter(Boolean).join(' · ')}</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>
+ ${it.canOpen && html`
+ <button class="link-btn" onClick=${() => transfers.open(it.id)}>
+ ${t('transfers.open')}
+ </button>
+ `}
+ </div>
+ `}
+ </div>
+ `;
+}
+
+/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose
+ * speed varies is a number that is wrong most of the time and looks precise. */
+function formatEta(seconds) {
+ if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) });
+ if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) });
+ return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 });
+}
+
// ── Nav ──────────────────────────────────────────────────────────────────────
function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,