diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 252 |
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. |