summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js252
1 files changed, 201 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 c2858f4..dfd0aeb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -4,8 +4,9 @@ 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';
import { formatSize } from './file-utils.js';
import {
@@ -156,69 +157,80 @@ function TransferWidget() {
}, [open]);
const running = items.filter(i => i.status === 'running');
+ const waiting = items.filter(
+ i => i.status === 'queued' || i.status === 'preparing');
+ // Its own group, and not a leftover.
+ //
+ // "Finished" used to be defined as everything that is not running, queued or
+ // preparing — a definition by exclusion, which quietly swallowed `paused` the
+ // day pausing shipped. A transfer somebody stopped on purpose then sat under
+ // "Finished", beside the ones that are actually over, offering a resume
+ // button in the section of things that cannot be resumed.
+ const paused = items.filter(i => i.status === 'paused');
+ const finished = items.filter(
+ i => i.status !== 'running' && i.status !== 'queued'
+ && i.status !== 'preparing' && i.status !== 'paused');
+ // Paused counts as active: it is not over, the person means to come back to
+ // it, and the badge saying nothing is happening would be a lie.
+ const active = running.length + waiting.length + paused.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],
+ ['paused', paused],
+ ['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>
@@ -227,6 +239,136 @@ 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.pausable && (it.status === 'running' || it.status === 'paused')
+ && html`
+ ${/* Offered only where the target can actually do it: a
+ service-worker stream is a download the browser already owns,
+ and a pause there would restart from zero. */''}
+ <button class="transfer-pause"
+ aria-label=${t(it.status === 'paused'
+ ? 'transfers.resume_one' : 'transfers.pause_one',
+ { name: it.name })}
+ title=${t(it.status === 'paused'
+ ? 'transfers.resume' : 'transfers.pause')}
+ onClick=${() => (it.status === 'paused'
+ ? transfers.resume(it.id) : transfers.pause(it.id))}>
+ <${Icon} name=${it.status === 'paused' ? 'play' : 'pause'} />
+ </button>
+ `}
+ ${!it.pausable && downloads.SUPPORTED && it.kind === 'download'
+ && (it.status === 'running' || it.status === 'queued') && html`
+ ${/* Say why, rather than leaving a gap where a button is on the row
+ above. Without a granted folder this browser writes through the
+ service worker — a download it already owns, which cannot be
+ paused — so the button is absent for a reason nobody can see,
+ and an upload beside it has one. Shown only where choosing a
+ folder is actually possible: on Firefox and Safari there is no
+ folder to choose and this hint would be a lie. */''}
+ <span class="transfer-nopause" title=${t('transfers.not_pausable')}
+ aria-label=${t('transfers.not_pausable')}>
+ <${Icon} name="pause" />
+ </span>
+ `}
+ ${(it.status === 'running' || it.status === 'queued'
+ || it.status === 'preparing' || it.status === 'paused') && 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 === 'preparing'
+ ? html`
+ ${/* Not a progress bar at 0%: nothing is wrong and nothing is
+ stalled, the download is still finding somewhere to write. The
+ row exists from the click precisely so this state is visible
+ instead of being an empty panel. */''}
+ <div class="dl-progress dl-waiting"></div>
+ <div class="transfer-meta">
+ <span>${t('transfers.preparing')}</span>
+ <span>${formatSize(it.total)}</span>
+ </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 === 'paused'
+ ? html`
+ ${/* The bar keeps its fill: what has been written is still there,
+ and resuming continues from it rather than starting again. */''}
+ <div class="dl-progress" role="progressbar"
+ aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100">
+ <div class="dl-fill dl-paused" style="width:${it.percent}%"></div>
+ </div>
+ <div class="transfer-meta">
+ <span>${t('transfers.paused')}</span>
+ <span>${formatSize(it.done)}${it.total
+ ? ' / ' + 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,
@@ -941,6 +1083,14 @@ const trayLabels = () => ({
// falls back to English rather than rejecting, so this cannot strand the page.
const mount = () => {
render(html`<${App} />`, document.getElementById('app'));
+ // Get the download worker registered and this page under its control now,
+ // rather than inside the first click on Download. On Firefox and Safari it is
+ // the only unbounded way to write a file to disk, and it used to be
+ // registered lazily — so the first download of a session paid install,
+ // activate and claim while somebody watched, and a claim that missed its
+ // budget sent the file to a path that cannot hold a film. Fire-and-forget:
+ // nothing renders differently for it, and a failure is retried on demand.
+ downloads.primeServiceWorker();
// After the catalogue, so the labels are in the right language. A no-op in a
// browser and on macOS. A language change reloads the page, which comes back
// through here, so nothing else has to watch for it.