aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
commit84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch)
treed26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/src/meshbay_hub/static/downloads.js
parent188a76f52d2f30609147b1beee7754f2cbd1e778 (diff)
downloadmeshbay-84e778d5cdd1bb5cded8a7c0238797c17d48666c.tar.gz
feat(hub): chat, presence, a Profile page, and downloads that do not freeze
Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js46
1 files changed, 44 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 7f48fed..90f88b0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -192,7 +192,22 @@ async function serviceWorker() {
if (!_swReady) {
_swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' })
.then(() => navigator.serviceWorker.ready)
- .then(reg => reg.active || navigator.serviceWorker.controller)
+ .then(async (reg) => {
+ // `reg.active` is not enough. A worker can be active while this page is
+ // still uncontrolled, and an uncontrolled page's requests are never
+ // handed to its fetch handler — so the worker would take our stream and
+ // then never be asked for it. The iframe would 404, nothing would read
+ // the stream, and the first write() would block for good: a download
+ // stuck at one chunk.
+ if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller;
+ // sw.js claims clients on activate, so control usually arrives within a
+ // tick of registration. Wait briefly rather than give up at once.
+ return await new Promise((resolve) => {
+ const done = () => resolve(navigator.serviceWorker.controller || null);
+ navigator.serviceWorker.addEventListener('controllerchange', done, { once: true });
+ setTimeout(done, 3000);
+ });
+ })
.catch(err => {
console.warn('[MeshBay] service worker unavailable:', err.message);
return null;
@@ -221,8 +236,20 @@ export async function openStreamedDownload(filename, size = 0) {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const { readable, writable } = new TransformStream();
+ // The worker tells us when it actually answers the iframe. Without that
+ // confirmation this path fails silently: the write side blocks on
+ // backpressure that will never be relieved, which reads as a download frozen
+ // after one chunk rather than as an error.
+ const chan = new MessageChannel();
+ const serving = new Promise((resolve) => {
+ chan.port1.onmessage = (e) => {
+ if (e.data && e.data.type === 'mbdl-serving') resolve(true);
+ };
+ });
+
try {
- worker.postMessage({ type: 'mbdl', id, filename, size, readable }, [readable]);
+ worker.postMessage({ type: 'mbdl', id, filename, size, readable, port: chan.port2 },
+ [readable, chan.port2]);
} catch (err) {
// Transferable streams are what makes the backpressure work; without them
// this would be a memory buffer wearing a stream's clothes.
@@ -235,6 +262,21 @@ export async function openStreamedDownload(filename, size = 0) {
frame.src = `/_mbdl/${id}`;
document.body.appendChild(frame);
+ const answered = await Promise.race([
+ serving,
+ new Promise((r) => setTimeout(() => r(false), 8000)),
+ ]);
+ if (!answered) {
+ // Some browsers refuse a download started from a hidden iframe, and an
+ // uncontrolled page never reaches the worker at all. Say so and let the
+ // caller fall back rather than hand back a sink nothing drains.
+ console.warn('[MeshBay] the service worker never served the download; '
+ + 'falling back');
+ frame.remove();
+ try { await writable.abort('not served'); } catch { /* already gone */ }
+ return null;
+ }
+
const writer = writable.getWriter();
return {
name: filename,