diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:29:11 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 15:29:11 +0200 |
| commit | 84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch) | |
| tree | d26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/src/meshbay_hub/static/transport.js | |
| parent | 188a76f52d2f30609147b1beee7754f2cbd1e778 (diff) | |
| download | meshbay-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/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 99 |
1 files changed, 87 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index df2abb0..7a3943e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -82,6 +82,7 @@ class MeshBayTransport { this._onStreamInit = null; this._onStreamData = null; this._onStreamEnd = null; + this._onStreamError = null; this._onIndexSync = 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 @@ -95,6 +96,7 @@ class MeshBayTransport { set onStreamInit(fn) { this._onStreamInit = fn; } set onStreamData(fn) { this._onStreamData = fn; } set onStreamEnd(fn) { this._onStreamEnd = fn; } + set onStreamError(fn) { this._onStreamError = fn; } set onIndexSync(fn) { this._onIndexSync = fn; } get sessionKeys() { return this._sessionKeys; } @@ -437,15 +439,41 @@ class MeshBayTransport { return _b64decode(msg.data_b64); } - async fetchChatHistory(since, limit) { + /** + * A page of chat history, newest first by default. + * + * `before` is a message id, not a timestamp: it pages backwards from the + * newest, which is the direction a conversation is read. Asking without it + * used to mean `since: 0`, which paged *forwards* from the very first message + * — so a busy group opened on its oldest page and never showed the recent + * exchange. + * + * Returns { messages, hasMore } — hasMore says whether anything older exists, + * so the "load older" control knows when to stop offering. + */ + async fetchChatHistory({ before = null, limit = 100 } = {}) { const msg = await this._sendAndWait({ type: 'chat_hist', - v: '0.1', - since: since || 0, - limit: limit || 100, + v: '0.2', + before: before, + limit: limit, }); if (msg.type === 'error') throw new Error(msg.detail); - return msg.messages || []; + return { messages: msg.messages || [], hasMore: !!msg.has_more }; + } + + /** + * Liveness on this already-open channel. Resolves with the round trip in ms, + * rejects on timeout — a DataChannel whose peer vanished without closing + * still reads as connected, and nothing else here notices until a real + * request hangs. + */ + async ping(timeoutMs = 5000) { + const token = Math.random().toString(36).slice(2); + const started = performance.now(); + const msg = await this._sendAndWait({ type: 'ping', v: '0.2', token }, timeoutMs); + if (msg.type === 'error') throw new Error(msg.detail); + return Math.round(performance.now() - started); } async sendChat(payload, iteration, threadId, senderName) { @@ -781,20 +809,26 @@ class MeshBayTransport { // ── Internal ────────────────────────────────────────────────────────────── - _sendAndWait(obj) { + _sendAndWait(obj, timeoutMs = 30000) { return new Promise((resolve, reject) => { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); reject(new Error('Response timeout')); - }, 30000); + }, timeoutMs); this._pending.set(id, { _reqType: obj.type, // Chunks are the one request that runs several at a time and can be // interleaved with anything else on the channel. Matching them by // arrival order was only ever true by luck; this makes it true. + // + // A ping is keyed for the same reason and a sharper one: it is sent + // *while* other traffic is in flight, so the fallback below would hand + // a pong to whatever was waiting — resolving a history request with a + // message that has no messages in it, and emptying the conversation. _key: obj.type === 'file_req' - ? `chunk:${obj.file_id}:${obj.chunk_index}` : null, + ? `chunk:${obj.file_id}:${obj.chunk_index}` + : obj.type === 'ping' ? `ping:${obj.token}` : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -841,11 +875,21 @@ class MeshBayTransport { 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. + // An upload refusal names the file it is about, so only that upload fails. + // It did not use to, and there was no way to tell whose error it was, so + // every upload in flight was failed together — send a second file whose + // name the node dislikes and both died. The broadcast is kept for a node + // that does not name it, where guessing wrong is worse than stopping. if (msg.type === 'error' && this._uploaders.size) { - for (const handler of [...this._uploaders.values()]) handler(msg); + if (msg.filename && this._uploaders.has(msg.filename)) { + this._uploaders.get(msg.filename)(msg); + return; + } + if (!msg.filename) { + for (const handler of [...this._uploaders.values()]) handler(msg); + return; + } + // Named, but for an upload that is no longer running — not ours to act on. return; } if (msg.type === 'chat_msg' && this._onChat) { @@ -889,13 +933,44 @@ class MeshBayTransport { } // Nobody asked for it any more — a cancelled download, most likely. It // must not be handed to whatever request happens to be waiting. + console.warn('[MeshBay] file_chunk for nobody', msg.file_id, msg.chunk_index); + return; + } + + // "Server busy, retry shortly" and friends arrive as a bare error while a + // stream is being set up, with no request waiting for them. They used to + // fall through to the oldest pending handler — usually nobody — so the + // player sat on "buffering" with the answer already in hand. + if (msg.type === 'error' && this._onStreamError) { + this._onStreamError(msg); return; } + if (msg.type === 'pong') { + const key = `ping:${msg.token}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + // A pong for a probe that already timed out. It must not fall through to + // the oldest pending request. + return; + } + + // Everything above is routed by something in the message. What is left is + // matched by arrival order, which is only ever a guess — and a wrong guess + // here hands one request's answer to another, which then waits for a reply + // that already came. Logged so that guess is visible. const oldest = this._pending.entries().next(); if (!oldest.done) { const [, handler] = oldest.value; + if (msg.type !== handler._reqType + '_resp' && handler._reqType !== 'index_sync') { + console.warn('[MeshBay] unrouted', msg.type, + '-> oldest pending', handler._reqType, + '(pending:', this._pending.size, ')'); + } handler.resolve(msg); + } else { + console.warn('[MeshBay] unrouted', msg.type, 'with nothing waiting'); } } } |