aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.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/transport.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/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js99
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');
}
}
}