summaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js770
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css306
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js99
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py41
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py159
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py7
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py218
-rw-r--r--packages/meshbay-hub/tests/test_video_stream_switch.py160
20 files changed, 1693 insertions, 291 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 6a5a13f..7968284 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1,5 +1,5 @@
import {
- html, render, useState, useEffect, useCallback, useRef,
+ html, render, useState, useEffect, useLayoutEffect, useCallback, useRef,
createContext, useContext,
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
@@ -245,6 +245,16 @@ const ICON_PATHS = {
globe: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18',
'M3.4 9.2h17.2M3.4 14.8h17.2',
'M12 3c-2.6 2.4-4 5.6-4 9s1.4 6.6 4 9c2.6-2.4 4-5.6 4-9s-1.4-6.6-4-9'],
+ archive: ['M3 7.5h18v3H3z', 'M4.5 10.5V19a1.5 1.5 0 0 0 1.5 1.5h12a1.5 1.5 0 0 0 1.5-1.5v-8.5',
+ 'M10 14h4'],
+ play: ['M8 5.5v13l11-6.5z'],
+ eye: ['M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12',
+ 'M12 14.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5'],
+ trash: ['M4 7h16', 'M10 11v6M14 11v6',
+ 'M6 7l1 12.5A1.5 1.5 0 0 0 8.5 21h7a1.5 1.5 0 0 0 1.5-1.5L18 7',
+ 'M9.5 7V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v2'],
+ user: ['M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8',
+ 'M4.5 20a7.5 7.5 0 0 1 15 0'],
gear: ['M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6',
'M19.2 14.4a1.7 1.7 0 0 0 .3 1.9 2 2 0 1 1-2.8 2.8 1.7 1.7 0 0 0-2.9 1.2 2 2 0 0 1-4 0 1.7 1.7 0 0 0-2.9-1.2 2 2 0 1 1-2.8-2.8 1.7 1.7 0 0 0-1.2-2.9 2 2 0 0 1 0-4 1.7 1.7 0 0 0 1.2-2.9 2 2 0 1 1 2.8-2.8 1.7 1.7 0 0 0 2.9-1.2 2 2 0 0 1 4 0 1.7 1.7 0 0 0 2.9 1.2 2 2 0 1 1 2.8 2.8 1.7 1.7 0 0 0 1.2 2.9 2 2 0 0 1 0 4 1.7 1.7 0 0 0-1.5 1.1z'],
sun: ['M12 8.2a3.8 3.8 0 1 0 0 7.6 3.8 3.8 0 0 0 0-7.6',
@@ -335,6 +345,9 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) {
&& html`<${Icon} name="check" cls="umi-check" />`}
</button>
`)}
+ <button class="user-menu-item" onClick=${() => { setOpen(false); navigate('/profile'); }}>
+ <${Icon} name="user" cls="umi-icon" /> ${t('usermenu.profile')}
+ </button>
<button class="user-menu-item" onClick=${() => { setOpen(false); navigate('/settings'); }}>
<${Icon} name="gear" cls="umi-icon" /> ${t('usermenu.settings')}
</button>
@@ -473,7 +486,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, route, menuOpen, role }) {
+function Sidebar({ groups, presence, route, menuOpen, role }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
<aside class="sidebar ${menuOpen ? 'open' : ''}">
@@ -497,13 +510,25 @@ function Sidebar({ groups, route, menuOpen, role }) {
<div class="sidebar-heading">${t('sidebar.my_groups')}</div>
${groups.length === 0
? html`<div class="sidebar-empty">${t('sidebar.no_groups')}</div>`
- : groups.map(g => html`
- <a key=${g.id}
- class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}"
- href="#/group/${g.id}">
- ${g.name}
- </a>
- `)
+ : groups.map(g => {
+ // Three states, each backed by something. `node_online` comes from
+ // the hub's signaling registry and rides on the group list itself,
+ // so there is no poll and no timer; a connection this browser tried
+ // and failed overrides it, because that is the fact the reader
+ // actually cares about. Anything else is "not known yet".
+ const state = presence[g.id] ?? (g.node_online === true ? 'online'
+ : g.node_online === false ? 'offline' : 'unknown');
+ return html`
+ <a key=${g.id}
+ class="sidebar-item ${route === '/group/' + g.id ? 'active' : ''}"
+ href="#/group/${g.id}">
+ <span class="presence presence-${state}"
+ title="${t('presence.' + state)}"
+ aria-label="${t('presence.' + state)}"></span>
+ <span class="sidebar-item-name">${g.name}</span>
+ </a>
+ `;
+ })
}
</div>
</aside>
@@ -842,21 +867,20 @@ function CreateGroupPage({ token, onCreated }) {
};
return html`
- <div class="create-group-page">
- <div class="create-group-card">
- <div class="create-group-header">
- <h2>${t('create_group.title')}</h2>
- <p class="create-group-hint">${t('create_group.hint')}</p>
- </div>
- ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
- <form onSubmit=${onSubmit}>
+ <div>
+ <h2>${t('create_group.title')}</h2>
+ <p class="page-message">${t('create_group.hint')}</p>
+ ${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
+
+ <form onSubmit=${onSubmit}>
+ <div class="settings-section">
<div class="form-field">
<label class="form-label">${t('create_group.name')}</label>
<input type="text" placeholder="${t('create_group.name_placeholder')}"
value=${name} onInput=${e => setName(e.target.value)} required autofocus />
</div>
- <div class="form-field">
+ <div class="form-field" style="margin-bottom:0">
<label class="form-label">${t('create_group.description')}</label>
<textarea class="form-textarea" rows="3" maxlength="512"
placeholder="${t('create_group.description_hint')}"
@@ -864,48 +888,66 @@ function CreateGroupPage({ token, onCreated }) {
onInput=${e => setDescription(e.target.value)} />
<div class="form-char-count">${description.length}/512</div>
</div>
+ </div>
- <div class="form-field">
- <label class="form-label">${t('create_group.visibility')}</label>
- <div class="option-cards">
- <button type="button" class="option-card ${visibility === 'private' ? 'selected' : ''}"
- onClick=${() => setVisibility('private')}>
- <${Icon} name="lock" cls="option-icon" />
- <span class="option-title">${t('create_group.private')}</span>
- <span class="option-desc">${t('create_group.private_desc')}</span>
- </button>
- <button type="button" class="option-card ${visibility === 'public' ? 'selected' : ''}"
- onClick=${() => setVisibility('public')}>
- <${Icon} name="globe" cls="option-icon" />
- <span class="option-title">${t('create_group.public')}</span>
- <span class="option-desc">${t('create_group.public_desc')}</span>
- </button>
- </div>
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('create_group.visibility')}</h3>
+ <div class="choice-list">
+ <label class="choice ${visibility === 'private' ? 'selected' : ''}">
+ <input type="radio" name="visibility" checked=${visibility === 'private'}
+ onChange=${() => { setVisibility('private'); setJoinPolicy('invite'); }} />
+ <${Icon} name="lock" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.private')}</span>
+ <span class="choice-desc">${t('create_group.private_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${visibility === 'public' ? 'selected' : ''}">
+ <input type="radio" name="visibility" checked=${visibility === 'public'}
+ onChange=${() => { setVisibility('public'); setJoinPolicy('open'); }} />
+ <${Icon} name="globe" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.public')}</span>
+ <span class="choice-desc">${t('create_group.public_desc')}</span>
+ </span>
+ </label>
</div>
- <div class="form-field">
- <label class="form-label">${t('create_group.join_policy')}</label>
- <div class="option-cards">
- <button type="button" class="option-card ${joinPolicy === 'invite' ? 'selected' : ''}"
- onClick=${() => setJoinPolicy('invite')}>
- <${Icon} name="envelope" cls="option-icon" />
- <span class="option-title">${t('create_group.invite')}</span>
- <span class="option-desc">${t('create_group.invite_desc')}</span>
- </button>
- <button type="button" class="option-card ${joinPolicy === 'open' ? 'selected' : ''}"
- onClick=${() => setJoinPolicy('open')}>
- <${Icon} name="door" cls="option-icon" />
- <span class="option-title">${t('create_group.open')}</span>
- <span class="option-desc">${t('create_group.open_desc')}</span>
- </button>
- </div>
- </div>
+ ${visibility === 'public'
+ ? html`<p class="settings-hint" style="margin-top:12px">
+ ${t('create_group.public_is_open')}
+ </p>`
+ : html`
+ <h3 class="settings-heading" style="margin-top:20px">
+ ${t('create_group.join_policy')}
+ </h3>
+ <div class="choice-list">
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
+ <${Icon} name="envelope" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
+ </span>
+ </label>
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
+ <${Icon} name="door" cls="choice-icon" />
+ <span class="choice-text">
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
+ </span>
+ </label>
+ </div>
+ `}
+ </div>
- <button class="create-group-submit" type="submit" disabled=${loading}>
- ${loading ? t('create_group.creating') : t('create_group.submit')}
- </button>
- </form>
- </div>
+ <button class="btn-primary" type="submit" disabled=${loading}>
+ ${loading ? t('create_group.creating') : t('create_group.submit')}
+ </button>
+ </form>
</div>
`;
}
@@ -1042,14 +1084,13 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
}
function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
- onJoined, onGroupUpdated }) {
+ onJoined, onGroupUpdated, onPresence, onLeft }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [error, setError] = useState('');
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState(() => new Set());
- const [actionsOpen, setActionsOpen] = useState(false);
const [editingDesc, setEditingDesc] = useState(false);
const [descDraft, setDescDraft] = useState('');
const [savingDesc, setSavingDesc] = useState(false);
@@ -1090,13 +1131,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
setRetryKey(k => k + 1);
}, [codeInput]);
- useEffect(() => {
- if (!actionsOpen) return;
- const close = () => setActionsOpen(false);
- document.addEventListener('click', close);
- return () => document.removeEventListener('click', close);
- }, [actionsOpen]);
-
// One place that takes an index from the node and puts it everywhere it has to
// go. Deleting a file used to refresh the table and leave the cache alone, so
// the search page went on offering a file that no longer existed until the
@@ -1127,6 +1161,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
if (cancelled) return;
if (!nodesData.nodes || nodesData.nodes.length === 0) {
setStatus('offline');
+ if (onPresence) onPresence(groupId, 'offline');
return;
}
@@ -1179,6 +1214,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
if (cancelled) return;
applyIndex(indexMsg);
setStatus('connected');
+ // First-hand evidence, and the strongest available: this browser spoke
+ // to the node. It outranks whatever the hub said in the group list.
+ if (onPresence) onPresence(groupId, 'online');
} catch (err) {
if (cancelled) return;
@@ -1198,6 +1236,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
if (err.reason === 'code_required') setNeedsCode(true);
setError(err.message);
setStatus('error');
+ // A refusal means the node answered, so it is up; only a failure to
+ // reach it at all is evidence of absence.
+ if (onPresence) {
+ onPresence(groupId, err.reason ? 'online' : 'offline');
+ }
}
};
@@ -1508,7 +1551,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const deletableFiles = selectedFiles.filter(
e => isNodeAdmin || (userId && e.uploader_id === userId));
const run = (fn) => {
- setActionsOpen(false);
setSelecting(false);
setSelected(new Set());
Promise.resolve().then(fn).catch(err => {
@@ -1516,41 +1558,60 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
});
};
+ // Icon only, with the name in the tooltip: these sit in a toolbar that is
+ // already narrow, and every one of them is a verb the icon carries on its
+ // own. `title` gives the hover text and `aria-label` the accessible name —
+ // an icon button with neither is unusable with a screen reader.
+ //
+ // Every action is rendered as soon as Select is on, and the ones that do not
+ // apply are disabled rather than absent. Buttons appearing and vanishing as
+ // the selection changed made the bar jump about and gave no clue that an
+ // action existed at all before something was ticked.
+ const action = (icon, label, onClick, opts = {}) => html`
+ <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
+ title=${label} aria-label=${label}
+ disabled=${!!opts.disabled} onClick=${onClick}>
+ <${Icon} name=${icon} />
+ </button>
+ `;
+
+ const canPlay = !!(onlyFile && onlyFile.type === 'video');
+ const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile));
+ const deletableCount = deletableFiles.length
+ + (operatorPaired ? selectedDirs.length : 0);
+ // The operator can always delete; anyone else only ever sees the button if
+ // something here is theirs to remove. Hiding it from an uploader would take
+ // away a right the protocol grants them (draft-v5 §5.1), not just a control.
+ const mayEverDelete = isNodeAdmin
+ || (userId && entries.some(e => e.uploader_id === userId));
+
const actionItems = html`
- ${onlyFile && onlyFile.type === 'video' && html`
- <button onClick=${() => run(() => setVideoEntry(onlyFile))}>
- <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')}
- </button>
- `}
- ${onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile) && html`
- <button onClick=${() => run(() => setPreviewEntry(onlyFile))}>
- <span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}
- </button>
- `}
- ${selectedFiles.length > 0 && html`
- <button onClick=${() => run(async () => {
+ ${action('play', t('group.play'),
+ () => run(() => setVideoEntry(onlyFile)), { disabled: !canPlay })}
+ ${action('eye', t('group.view'),
+ () => run(() => setPreviewEntry(onlyFile)), { disabled: !canView })}
+ ${action('download',
+ selectedFiles.length
+ ? t('group.download_n', { n: selectedFiles.length })
+ : t('group.download'),
+ () => run(async () => {
// Awaited one at a time, and each returns as soon as its transfer is
// registered — so the transfers still run together. Firing them without
// awaiting meant every file asked the browser for a save dialog at
// once, and a browser allows one: the rest were rejected and only the
// first file ever downloaded.
for (const e of selectedFiles) await downloadFile(e);
- })}>
- <span class="fmi">${'\u{2B07}'}</span>
- ${t('group.download_n', { n: selectedFiles.length })}
- </button>
- `}
- ${selectedDirs.length > 0 && html`
- <button onClick=${() => run(async () => {
+ }), { disabled: selectedFiles.length === 0 })}
+ ${action('archive',
+ selectedDirs.length
+ ? t('group.download_zip_n', { n: selectedDirs.length })
+ : t('group.download_zip_n', { n: 0 }),
+ () => run(async () => {
for (const d of selectedDirs) await downloadDirectory(d);
- })}>
- <span class="fmi">${'\u{2B07}'}</span>
- ${t('group.download_zip_n', { n: selectedDirs.length })}
- </button>
- `}
- ${status === 'connected' && (deletableFiles.length > 0
- || (operatorPaired && selectedDirs.length > 0)) && html`
- <button class="danger" onClick=${() => {
+ }), { disabled: selectedDirs.length === 0 })}
+ ${mayEverDelete && action('trash',
+ deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
+ () => {
const names = [...deletableFiles.map(e => e.name),
...(operatorPaired ? selectedDirs : [])];
if (!confirm(t('group.delete_n_confirm', { n: names.length,
@@ -1559,12 +1620,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
for (const e of deletableFiles) deleteFile(e);
if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d);
});
- }}>
- <span class="fmi">${'\u{1F5D1}'}</span>
- ${t('group.delete_n', { n: deletableFiles.length
- + (operatorPaired ? selectedDirs.length : 0) })}
- </button>
- `}
+ },
+ { danger: true, disabled: status !== 'connected' || deletableCount === 0 })}
`;
return html`
@@ -1620,6 +1677,19 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
} catch (err) { setError(err.message); }
}}>${t('group.delete_group')}</button>
`}
+ ${group && !group.is_admin && html`
+ <button class="admin-btn danger" style="margin-left:auto"
+ onClick=${async () => {
+ if (!confirm(t('group.leave_confirm', { name: group.name }))) return;
+ try {
+ await hubFetch('/v1/groups/' + groupId + '/leave',
+ { method: 'POST', token });
+ // Dropped from the list here rather than reloading: a reload
+ // would tear down the WebRTC connections other groups hold.
+ if (onLeft) onLeft(groupId);
+ } catch (err) { setError(err.message); }
+ }}>${t('group.leave')}</button>
+ `}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${needsCode && html`
@@ -1679,21 +1749,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
onClick=${() => {
setSelecting(v => !v);
setSelected(new Set());
- setActionsOpen(false);
}}>
<${Icon} name=${selecting ? 'check' : 'checkbox'} />
${selecting ? t('group.select_done') : t('group.select')}
</button>
- ${selecting && html`
- <div class="actions-wrap">
- <button class="tb-btn" disabled=${selected.size === 0}
- onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}>
- <${Icon} name="dots" />
- ${t('group.actions', { n: selected.size })}
- </button>
- ${actionsOpen && html`<div class="file-menu">${actionItems}</div>`}
- </div>
- `}
+ ${selecting && html`<div class="tb-actions">${actionItems}</div>`}
</div>
</div>
<table class="file-table">
@@ -2198,9 +2258,11 @@ function linkify(text) {
function formatTime(ts) {
const d = new Date(ts * 1000);
const now = new Date();
- const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ // getLocale() rather than the browser default: the user may have picked a
+ // language here that differs from the one their OS reports.
+ const time = d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
if (d.toDateString() === now.toDateString()) return time;
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time;
+ return d.toLocaleDateString(getLocale(), { month: 'short', day: 'numeric' }) + ' ' + time;
}
function _parsePayload(raw) {
@@ -2210,6 +2272,37 @@ function _parsePayload(raw) {
return null;
}
+// How much history a group opens with, and how much each "older" click adds.
+const CHAT_PAGE = 100;
+const CHAT_OLDER_PAGE = 50;
+
+// Breathing room under the panel, and the floor below which shrinking it stops
+// helping — past that the page may scroll after all, which beats a chat two
+// lines tall.
+const CHAT_BOTTOM_GAP = 16;
+const CHAT_MIN_HEIGHT = 240;
+
+function _sameDay(a, b) {
+ const da = new Date(a * 1000), db = new Date(b * 1000);
+ return da.getFullYear() === db.getFullYear()
+ && da.getMonth() === db.getMonth()
+ && da.getDate() === db.getDate();
+}
+
+/** "Today" / "Yesterday" / a written date, in the reader's language. */
+function _dayLabel(ts) {
+ const d = new Date(ts * 1000);
+ const now = new Date();
+ if (_sameDay(ts, now.getTime() / 1000)) return t('chat.today');
+ const yesterday = new Date(now);
+ yesterday.setDate(now.getDate() - 1);
+ if (_sameDay(ts, yesterday.getTime() / 1000)) return t('chat.yesterday');
+ return d.toLocaleDateString(getLocale(), {
+ weekday: 'long', day: 'numeric', month: 'long',
+ year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric',
+ });
+}
+
function ChatImage({ filename, entries, transportRef, gekRef }) {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(true);
@@ -2251,12 +2344,20 @@ function ChatImage({ filename, entries, transportRef, gekRef }) {
function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, onPreview }) {
const [messages, setMessages] = useState([]);
+ const [hasMore, setHasMore] = useState(false);
+ const [loadingOlder, setLoadingOlder] = useState(false);
+ const [atBottom, setAtBottom] = useState(true);
+ const [unreadFrom, setUnreadFrom] = useState(null);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [attaching, setAttaching] = useState(false);
const listRef = useRef(null);
- const bottomRef = useRef(null);
+ const panelRef = useRef(null);
const loadedRef = useRef(false);
+ // Set just before older messages are prepended; read once, after the DOM has
+ // them but before the browser paints.
+ const anchorRef = useRef(null);
+ const atBottomRef = useRef(true);
useEffect(() => {
const transport = transportRef.current;
@@ -2264,29 +2365,123 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
if (!loadedRef.current) {
loadedRef.current = true;
- transport.fetchChatHistory(0, 200)
- .then(msgs => setMessages(msgs))
+ // The newest page. This used to be fetchChatHistory(0, 200), which paged
+ // forwards from the very first message ever sent, so a busy group opened
+ // on its oldest screen and the recent conversation was unreachable.
+ transport.fetchChatHistory({ limit: CHAT_PAGE })
+ .then(({ messages: msgs, hasMore: more }) => {
+ setMessages(msgs);
+ setHasMore(more);
+ })
.catch(() => {});
}
transport.onChat = (msg) => {
+ // A live message has no row id until it is re-read from the node, so it
+ // gets a local one. Keys have to be stable and unique or prepending a
+ // page makes Preact reuse the wrong bubbles. Computed once and reused
+ // below: the unread marker points at a message by id, so generating a
+ // second one there would point it at nothing.
+ const id = msg.id
+ || `live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
setMessages(prev => [...prev, {
+ id,
sender_id: msg.sender_id,
sender_name: msg.sender_name || '',
payload: msg.payload,
timestamp: msg.timestamp || Date.now() / 1000,
thread_id: msg.thread_id,
}]);
+ // Somebody wrote while you were reading further up: mark where you were
+ // rather than yanking the view down.
+ if (!atBottomRef.current) setUnreadFrom(prev => prev ?? id);
};
return () => { transport.onChat = null; };
}, [transportRef.current?.connected]);
- useEffect(() => {
- if (bottomRef.current) {
- bottomRef.current.scrollIntoView({ behavior: 'smooth' });
+ const loadOlder = useCallback(async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected || loadingOlder || !messages.length) return;
+ setLoadingOlder(true);
+ const list = listRef.current;
+ // Keeping the reading position means restoring the distance from the
+ // *bottom*, not scrollTop: everything above the viewport just grew.
+ anchorRef.current = list ? list.scrollHeight - list.scrollTop : null;
+ try {
+ const { messages: older, hasMore: more } =
+ await transport.fetchChatHistory({ before: messages[0].id, limit: CHAT_OLDER_PAGE });
+ setMessages(prev => [...older, ...prev]);
+ setHasMore(more);
+ } catch {
+ anchorRef.current = null;
+ } finally {
+ setLoadingOlder(false);
+ }
+ }, [messages, loadingOlder]);
+
+ useLayoutEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ if (anchorRef.current !== null) {
+ list.scrollTop = list.scrollHeight - anchorRef.current;
+ anchorRef.current = null;
+ return;
}
- }, [messages.length]);
+ // Only follow the conversation if the reader was already at the bottom.
+ // Scrolling unconditionally fought every attempt to read back through it.
+ //
+ // scrollTop rather than bottomRef.scrollIntoView: the sentinel has no
+ // height, so aligning it to the bottom of the viewport leaves the list's
+ // own padding below it and the bar stops just short of the end.
+ if (atBottomRef.current) list.scrollTop = list.scrollHeight;
+ }, [messages]);
+
+ // The panel was `calc(100vh - 220px)`: a guess at how much sits above it. On a
+ // phone the group header — title, description, edit link, delete button, tabs
+ // — is closer to 430px, so the panel ran past the fold and the composer ended
+ // up off screen with the whole page scrolling to reach it.
+ //
+ // Measured instead, from the panel's own position in the document, so the
+ // header can be any height. `visualViewport` rather than innerHeight where it
+ // exists: on Android the on-screen keyboard shrinks the visual viewport
+ // without changing innerHeight, and the composer would go back under it.
+ useLayoutEffect(() => {
+ const el = panelRef.current;
+ if (!el) return;
+ const fit = () => {
+ const vh = window.visualViewport?.height || window.innerHeight;
+ // Document-relative, so a page that happens to be scrolled does not skew
+ // the result — the answer must be the same either way.
+ const top = el.getBoundingClientRect().top + window.scrollY;
+ el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`;
+ };
+ fit();
+ window.addEventListener('resize', fit);
+ window.addEventListener('orientationchange', fit);
+ window.visualViewport?.addEventListener('resize', fit);
+ return () => {
+ window.removeEventListener('resize', fit);
+ window.removeEventListener('orientationchange', fit);
+ window.visualViewport?.removeEventListener('resize', fit);
+ };
+ }, []);
+
+ const onScroll = useCallback((e) => {
+ const el = e.target;
+ const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+ atBottomRef.current = bottom;
+ setAtBottom(bottom);
+ if (bottom) setUnreadFrom(null);
+ }, []);
+
+ const jumpToBottom = useCallback(() => {
+ atBottomRef.current = true;
+ setAtBottom(true);
+ setUnreadFrom(null);
+ const list = listRef.current;
+ if (list) list.scrollTo({ top: list.scrollHeight, behavior: 'smooth' });
+ }, []);
const sendMessage = useCallback(async () => {
const text = input.trim();
@@ -2299,18 +2494,20 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
try {
await transport.sendChat(text, 0, null, username);
setMessages(prev => [...prev, {
+ id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
sender_id: username,
sender_name: username,
payload: text,
timestamp: Date.now() / 1000,
thread_id: null,
}]);
+ jumpToBottom();
} catch {
setInput(text);
} finally {
setSending(false);
}
- }, [input, username]);
+ }, [input, username, jumpToBottom]);
const attachFile = useCallback(async (e) => {
const file = e.target.files?.[0];
@@ -2334,15 +2531,17 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
});
await transport.sendChat(structured, 0, null, username);
setMessages(prev => [...prev, {
+ id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
sender_id: username, sender_name: username,
payload: structured, timestamp: Date.now() / 1000, thread_id: null,
}]);
+ jumpToBottom();
} catch (err) {
alert(err.message);
} finally {
setAttaching(false);
}
- }, [username, onRefreshIndex]);
+ }, [username, onRefreshIndex, jumpToBottom]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -2352,20 +2551,44 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
}, [sendMessage]);
return html`
- <div class="chat-panel">
- <div class="chat-messages" ref=${listRef}>
+ <div class="chat-panel" ref=${panelRef}>
+ <div class="chat-messages" ref=${listRef} onScroll=${onScroll}>
+ ${hasMore && html`
+ <div class="chat-older-row">
+ <button class="chat-older-btn" onClick=${loadOlder} disabled=${loadingOlder}>
+ ${loadingOlder
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="chevron" cls="chat-older-icon" />`}
+ ${' '}${t('chat.load_older', { n: CHAT_OLDER_PAGE })}
+ </button>
+ </div>
+ `}
+ ${!hasMore && messages.length > 0 && html`
+ <div class="chat-start">${t('chat.start_of_history')}</div>
+ `}
${messages.length === 0 && html`
<div class="chat-empty">${t('chat.empty')}</div>
`}
${messages.map((m, i) => {
const isOwn = m.sender_name === username || m.sender_id === username;
const displayName = m.sender_name || '?';
+ const prev = messages[i - 1];
const showSender = !isOwn && (i === 0 ||
- (messages[i - 1].sender_name || messages[i - 1].sender_id) !== (m.sender_name || m.sender_id));
+ (prev.sender_name || prev.sender_id) !== (m.sender_name || m.sender_id));
+ // A conversation read over several days is unreadable without them.
+ const daySep = i === 0 || !_sameDay(prev.timestamp, m.timestamp)
+ ? _dayLabel(m.timestamp) : null;
const parsed = _parsePayload(m.payload);
const att = parsed && parsed.attachment;
return html`
- <div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}">
+ ${daySep && html`
+ <div class="chat-day" key=${'d' + m.id}><span>${daySep}</span></div>
+ `}
+ ${unreadFrom && unreadFrom === m.id && html`
+ <div class="chat-unread" key=${'u' + m.id}><span>${t('chat.unread')}</span></div>
+ `}
+ <div key=${m.id} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}
+ ${showSender || daySep ? '' : 'chat-msg-tight'}">
${showSender && html`
<div class="chat-sender">${displayName}</div>
`}
@@ -2396,8 +2619,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
</div>
`;
})}
- <div ref=${bottomRef} />
</div>
+ ${!atBottom && messages.length > 0 && html`
+ <button class="chat-jump ${unreadFrom ? 'unread' : ''}" onClick=${jumpToBottom}>
+ <${Icon} name="chevron" cls="chat-jump-icon" />
+ ${' '}${unreadFrom ? t('chat.jump_new') : t('chat.jump_latest')}
+ </button>
+ `}
<div class="chat-input-row">
<label class="chat-attach" title="${t('chat.attach')}">
${attaching ? html`<span class="spinner"></span>`
@@ -2493,6 +2721,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
useEffect(() => {
let cancelled = false;
+ // Reset here, not in the teardown of the run before: switching video while
+ // an append was in flight left `appendingRef` true, and flushQueue bails
+ // out on it. The new SourceBuffer then never appended anything, so no
+ // `updateend` ever cleared the flag, no credit went back to the node, and
+ // the player sat on "buffering" for good. `endedRef` surviving is the same
+ // shape of bug — the next stream would call endOfStream() the first time
+ // its queue ran dry and truncate the film.
+ appendingRef.current = false;
+ endedRef.current = false;
+ queueRef.current = [];
const transport = transportRef.current;
if (!transport || !transport.connected) {
setError(t('video.err_transport'));
@@ -2511,8 +2749,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
};
const startStream = async () => {
+ transport.onStreamError = (msg) => {
+ if (cancelled) return;
+ // Say what the node said. Sitting on "buffering" with the reason
+ // already delivered is the worst of both.
+ setError(msg.detail || t('video.err_transport'));
+ setPhase('error');
+ };
+
transport.onStreamInit = (msg) => {
if (cancelled) return;
+ if (msg.file_id && msg.file_id !== entry.id) return;
const mime = `video/mp4; codecs="${msg.codec}"`;
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
@@ -2558,6 +2805,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
transport.onStreamData = async (msg) => {
if (cancelled) return;
+ // Late segments from the stream we just left. The DataChannel is
+ // ordered, so they arrive before the new stream's first segment and
+ // would otherwise be decrypted against the wrong file — which fails,
+ // loudly, in the console, for something that is simply not ours.
+ if (msg.file_id && msg.file_id !== entry.id) return;
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
@@ -2568,8 +2820,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
}
};
- transport.onStreamEnd = () => {
+ transport.onStreamEnd = (msg) => {
if (cancelled) return;
+ // The end of the previous film is not the end of this one.
+ if (msg && msg.file_id && msg.file_id !== entry.id) return;
endedRef.current = true;
flushQueue();
};
@@ -2577,12 +2831,35 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
transport.requestStream(entry.id);
};
+ // Closing the tab, or backgrounding it on a phone, never runs a React
+ // cleanup — so the node hears nothing and keeps transcoding. `pagehide`
+ // fires in both cases and is the one event mobile browsers honour on the
+ // way out; `visibilitychange` covers switching apps. The node stops the
+ // stream by itself when the connection drops, but that costs a round of
+ // detection, and this message is a single datagram already in flight.
+ const leave = (why) => {
+ const t = transportRef.current;
+ console.log('[MeshBay] stopStream:', why);
+ if (t && t.connected) t.stopStream();
+ };
+ const onPageHide = () => leave('pagehide');
+ // NOT wired to stopStream. Android fires visibilitychange when a video goes
+ // fullscreen, so cutting the stream here killed the film the moment it was
+ // watched properly. Logged only, until that is confirmed or ruled out.
+ const onVisibility = () => {
+ console.log('[MeshBay] visibilitychange:', document.visibilityState);
+ };
+ window.addEventListener('pagehide', onPageHide);
+ document.addEventListener('visibilitychange', onVisibility);
+
startStream().catch(err => {
if (!cancelled) { setError(err.message); setPhase('error'); }
});
return () => {
cancelled = true;
+ window.removeEventListener('pagehide', onPageHide);
+ document.removeEventListener('visibilitychange', onVisibility);
if (videoRef.current) {
videoRef.current.removeEventListener('seeking', onSeeking);
}
@@ -2593,6 +2870,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
transport.onStreamInit = null;
transport.onStreamData = null;
transport.onStreamEnd = null;
+ transport.onStreamError = null;
}
// The queue can hold several megabytes of decrypted video.
queueRef.current = [];
@@ -2774,11 +3052,14 @@ function SearchPage() {
const THEME_OPTIONS = ['light', 'dark', 'system'];
-function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
- const [locale, setLoc] = useState(getLocale);
- // Comes from the hub with the group list, so it is the same on every device.
- const [muted, setMuted] = useState(
- () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
+// ── Profile Page ────────────────────────────────────────────────────────────
+//
+// Split out of Settings: these four are about *you* — who the account is, the
+// node you operate, the node identities this browser has pinned, and closing
+// the account. Settings is about how the application behaves. Mixing them put
+// an irreversible button two scrolls under a theme picker.
+
+function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
const [currentNodeKey, setCurrentNodeKey] = useState(null);
const [nodeKeyStatus, setNodeKeyStatus] = useState('');
@@ -2824,46 +3105,6 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
.catch(() => {});
}, [user.username, user.token]);
- const onLocaleChange = useCallback((e) => {
- const code = e.target.value;
- setLocale(code);
- setLoc(code);
- window.location.reload();
- }, []);
-
- const onThemeSelect = useCallback((e) => {
- onThemeChange(e.target.value);
- }, [onThemeChange]);
-
- const toggleMute = useCallback(async (gid) => {
- // Server-side: this used to write to localStorage, which nothing read, so
- // muting a group had no effect on anything. The hub now declines to create
- // the notification at all.
- const next = !muted[gid];
- setMuted(prev => ({ ...prev, [gid]: next }));
- try {
- await hubFetch(`/v1/groups/${gid}/mute`, {
- method: 'POST', token: user.token, body: { muted: next },
- });
- } catch (err) {
- setMuted(prev => ({ ...prev, [gid]: !next }));
- }
- }, [muted, user.token]);
-
- const [dlMode, setDlMode] = useState(() => downloads.getMode());
- const [dlDir, setDlDir] = useState(null);
-
- useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []);
-
- const pickFolder = useCallback(async () => {
- try {
- const handle = await downloads.chooseDirectory();
- setDlDir(handle);
- } catch (err) {
- if (err.name !== 'AbortError') setNodeKeyStatus(err.message);
- }
- }, []);
-
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
@@ -2886,49 +3127,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
return html`
<div>
- <h2>${t('settings.title')}</h2>
-
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings.downloads')}</h3>
- ${!downloads.SUPPORTED
- ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
- : html`
- <label class="settings-choice">
- <input type="radio" name="dlmode" checked=${dlMode === 'auto'}
- onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
- <span>
- <strong>${t('settings.dl_auto')}</strong>
- <span class="settings-hint">${t('settings.dl_auto_hint')}</span>
- </span>
- </label>
- <label class="settings-choice">
- <input type="radio" name="dlmode" checked=${dlMode === 'ask'}
- onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} />
- <span>
- <strong>${t('settings.dl_ask')}</strong>
- <span class="settings-hint">${t('settings.dl_ask_hint')}</span>
- </span>
- </label>
- <div class="settings-row" style="margin-top:10px">
- <span class="settings-label">
- ${dlDir ? t('settings.dl_folder', { name: dlDir.name })
- : t('settings.dl_no_folder')}
- </span>
- <span>
- <button class="admin-btn" onClick=${pickFolder}>
- ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')}
- </button>
- ${dlDir && html`
- <button class="btn-secondary" onClick=${async () => {
- await downloads.forgetDirectory();
- setDlDir(null);
- }}>${t('settings.dl_forget')}</button>
- `}
- </span>
- </div>
- <p class="settings-hint">${t('settings.dl_path_note')}</p>
- `}
- </div>
+ <h2>${t('profile.title')}</h2>
<div class="settings-section">
<h3 class="settings-heading">${t('settings.profile')}</h3>
@@ -2962,7 +3161,7 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
</button>
</div>
${nodeKeyStatus && html`
- <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--green, #22c55e)' : 'var(--red, #ef4444)'}">
+ <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}">
${nodeKeyStatus}
</p>
`}
@@ -3006,6 +3205,113 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
`}
</div>
+ </div>
+ `;
+}
+
+function SettingsPage({ user, theme, onThemeChange, groups }) {
+ const [locale, setLoc] = useState(getLocale);
+ // Comes from the hub with the group list, so it is the same on every device.
+ const [muted, setMuted] = useState(
+ () => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
+ const onLocaleChange = useCallback((e) => {
+ const code = e.target.value;
+ setLocale(code);
+ setLoc(code);
+ window.location.reload();
+ }, []);
+
+ const onThemeSelect = useCallback((e) => {
+ onThemeChange(e.target.value);
+ }, [onThemeChange]);
+
+ const toggleMute = useCallback(async (gid) => {
+ // Server-side: this used to write to localStorage, which nothing read, so
+ // muting a group had no effect on anything. The hub now declines to create
+ // the notification at all.
+ const next = !muted[gid];
+ setMuted(prev => ({ ...prev, [gid]: next }));
+ try {
+ await hubFetch(`/v1/groups/${gid}/mute`, {
+ method: 'POST', token: user.token, body: { muted: next },
+ });
+ } catch (err) {
+ setMuted(prev => ({ ...prev, [gid]: !next }));
+ }
+ }, [muted, user.token]);
+
+ const [dlMode, setDlMode] = useState(() => downloads.getMode());
+ const [dlDir, setDlDir] = useState(null);
+ const [dlError, setDlError] = useState('');
+ // Read from the hub rather than written here: the two constants that used to
+ // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on.
+ const [hubInfo, setHubInfo] = useState(null);
+
+ useEffect(() => {
+ hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {});
+ }, []);
+
+ useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []);
+
+ const pickFolder = useCallback(async () => {
+ try {
+ const handle = await downloads.chooseDirectory();
+ setDlDir(handle);
+ } catch (err) {
+ // Reported where the folder controls are. This used to be written into
+ // the node-key status, two sections away, where nobody was looking.
+ if (err.name !== 'AbortError') setDlError(err.message);
+ }
+ }, []);
+
+
+ return html`
+ <div>
+ <h2>${t('settings.title')}</h2>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.downloads')}</h3>
+ ${!downloads.SUPPORTED
+ ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
+ : html`
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'auto'}
+ onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
+ <span>
+ <strong>${t('settings.dl_auto')}</strong>
+ <span class="settings-hint">${t('settings.dl_auto_hint')}</span>
+ </span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'ask'}
+ onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} />
+ <span>
+ <strong>${t('settings.dl_ask')}</strong>
+ <span class="settings-hint">${t('settings.dl_ask_hint')}</span>
+ </span>
+ </label>
+ <div class="settings-row" style="margin-top:10px">
+ <span class="settings-label">
+ ${dlDir ? t('settings.dl_folder', { name: dlDir.name })
+ : t('settings.dl_no_folder')}
+ </span>
+ <span>
+ <button class="admin-btn" onClick=${pickFolder}>
+ ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')}
+ </button>
+ ${dlDir && html`
+ <button class="btn-secondary" onClick=${async () => {
+ await downloads.forgetDirectory();
+ setDlDir(null);
+ }}>${t('settings.dl_forget')}</button>
+ `}
+ </span>
+ </div>
+ ${dlError && html`<p class="error-msg">${dlError}</p>`}
+ <p class="settings-hint">${t('settings.dl_path_note')}</p>
+ `}
+ </div>
+
<div class="settings-section">
<h3 class="settings-heading">${t('settings.appearance')}</h3>
<div class="settings-row">
@@ -3046,11 +3352,13 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
<h3 class="settings-heading">${t('settings.about')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.version')}</span>
- <span class="settings-value">0.1.0</span>
+ <span class="settings-value">${hubInfo ? hubInfo.hub : '—'}</span>
</div>
<div class="settings-row">
<span class="settings-label">${t('settings.protocol')}</span>
- <span class="settings-value">MNP 0.1 / MHP 0.1</span>
+ <span class="settings-value">
+ ${hubInfo ? `MNP ${hubInfo.mnp} / MHP ${hubInfo.mhp}` : '—'}
+ </span>
</div>
</div>
</div>
@@ -3490,6 +3798,24 @@ function App() {
setGroups(prev => prev.map(g => (g.id === gid ? { ...g, ...patch } : g)));
}, []);
+ // What this browser saw for itself, which beats what the hub reported. Held
+ // for the session only: it is a cache of observations, not a source of truth,
+ // and a reload should go back to asking.
+ const [presence, setPresence] = useState({});
+ const notePresence = useCallback((gid, state) => {
+ setPresence(prev => (prev[gid] === state ? prev : { ...prev, [gid]: state }));
+ }, []);
+
+ const handleLeftGroup = useCallback((gid) => {
+ setGroups(prev => prev.filter(g => g.id !== gid));
+ setPresence(prev => {
+ const next = { ...prev };
+ delete next[gid];
+ return next;
+ });
+ navigate('/');
+ }, []);
+
const markRead = useCallback((id) => {
if (!user) return;
// Drop it here and now. Waiting for the round trip leaves it on screen while
@@ -3602,7 +3928,8 @@ function App() {
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} userId=${user.userId}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
- onGroupUpdated=${updateGroup} />`;
+ onGroupUpdated=${updateGroup} onPresence=${notePresence}
+ onLeft=${handleLeftGroup} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
@@ -3610,7 +3937,9 @@ function App() {
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} groups=${groups} onLogout=${authCtx.logout} />`;
+ onThemeChange=${setTheme} groups=${groups} />`;
+ } else if (route === '/profile') {
+ page = html`<${ProfilePage} user=${user} onLogout=${authCtx.logout} />`;
} else {
page = html`<${HomePage} groups=${groups} notifications=${notifications}
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
@@ -3628,6 +3957,7 @@ function App() {
<div class="layout">
${user && html`<${Sidebar}
groups=${groups}
+ presence=${presence}
route=${route}
menuOpen=${menuOpen}
role=${user.role} />`}
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,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 1ff4687..f045243 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -404,4 +404,21 @@ export default {
other: '{n} Dateien gefunden',
},
'search.hint': 'Durchsucht Dateinamen in allen zwischengespeicherten Gruppenindexen.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Ein Node, der diese Gruppe hostet, ist online',
+ 'presence.offline': 'Kein Node für diese Gruppe erreichbar',
+ 'presence.unknown': 'Noch nicht geprüft',
+ 'chat.load_older': '{n} ältere Nachrichten laden',
+ 'chat.start_of_history': 'Anfang des Gesprächs',
+ 'chat.today': 'Heute',
+ 'chat.yesterday': 'Gestern',
+ 'chat.unread': 'Neue Nachrichten',
+ 'chat.jump_latest': 'Zu den neuesten springen',
+ 'chat.jump_new': 'Neue Nachrichten',
+ 'group.leave': 'Gruppe verlassen',
+ 'group.leave_confirm': '„{name}“ verlassen? Sie verlieren den Zugang zu ihren Dateien und zum Chat. Hochgeladene Dateien bleiben auf dem Node, und der Node behält die für Sie gemerkte Identität, bis sein Betreiber sie entfernt.',
+ 'profile.title': 'Profil',
+ 'usermenu.profile': 'Profil',
+ 'create_group.public_is_open': 'Eine öffentliche Gruppe kann jeder finden und ihr beitreten. Wenn Sie auswählen möchten, wer hereinkommt, erstellen Sie sie privat.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 0e4427d..25c1f6c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -389,4 +389,21 @@ export default {
other: '{n} files found',
},
'search.hint': 'Search file names across all cached group indexes.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'A node serving this group is online',
+ 'presence.offline': 'No node is reachable for this group',
+ 'presence.unknown': 'Not checked yet',
+ 'chat.load_older': 'Load {n} older messages',
+ 'chat.start_of_history': 'Beginning of the conversation',
+ 'chat.today': 'Today',
+ 'chat.yesterday': 'Yesterday',
+ 'chat.unread': 'New messages',
+ 'chat.jump_latest': 'Jump to latest',
+ 'chat.jump_new': 'New messages',
+ 'group.leave': 'Leave group',
+ 'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.',
+ 'profile.title': 'Profile',
+ 'usermenu.profile': 'Profile',
+ 'create_group.public_is_open': 'Anyone can find and join a public group. To choose who comes in, make it private.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index c09175b..0799ec5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -399,4 +399,21 @@ export default {
other: '{n} archivos encontrados',
},
'search.hint': 'Busca nombres de archivo en todos los índices de grupo en caché.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Un node que sirve este grupo está en línea',
+ 'presence.offline': 'Ningún node accesible para este grupo',
+ 'presence.unknown': 'Aún sin comprobar',
+ 'chat.load_older': 'Cargar {n} mensajes anteriores',
+ 'chat.start_of_history': 'Inicio de la conversación',
+ 'chat.today': 'Hoy',
+ 'chat.yesterday': 'Ayer',
+ 'chat.unread': 'Mensajes nuevos',
+ 'chat.jump_latest': 'Ir a lo más reciente',
+ 'chat.jump_new': 'Mensajes nuevos',
+ 'group.leave': 'Salir del grupo',
+ 'group.leave_confirm': '¿Salir de «{name}»? Perderá el acceso a sus archivos y a su chat. Los archivos que subió permanecen en el node, y este conserva la identidad que fijó para usted hasta que su operador la retire.',
+ 'profile.title': 'Perfil',
+ 'usermenu.profile': 'Perfil',
+ 'create_group.public_is_open': 'Cualquiera puede encontrar un grupo público y unirse a él. Para elegir quién entra, créelo privado.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 845e7ce..32d0461 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -404,4 +404,21 @@ export default {
other: '{n} fichiers trouvés',
},
'search.hint': 'Recherche les noms de fichiers dans tous les index de groupe en cache.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Un node servant ce groupe est en ligne',
+ 'presence.offline': 'Aucun node joignable pour ce groupe',
+ 'presence.unknown': 'Pas encore vérifié',
+ 'chat.load_older': 'Charger {n} messages plus anciens',
+ 'chat.start_of_history': 'Début de la conversation',
+ 'chat.today': 'Aujourd’hui',
+ 'chat.yesterday': 'Hier',
+ 'chat.unread': 'Nouveaux messages',
+ 'chat.jump_latest': 'Aller au plus récent',
+ 'chat.jump_new': 'Nouveaux messages',
+ 'group.leave': 'Quitter le groupe',
+ 'group.leave_confirm': 'Quitter « {name} » ? Vous perdrez l’accès à ses fichiers et à sa discussion. Les fichiers que vous avez envoyés restent sur le node, et celui-ci conserve l’identité qu’il a épinglée pour vous jusqu’à ce que son opérateur la retire.',
+ 'profile.title': 'Profil',
+ 'usermenu.profile': 'Profil',
+ 'create_group.public_is_open': 'N’importe qui peut trouver un groupe public et le rejoindre. Pour choisir qui entre, créez-le en privé.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index e3c92ef..32d7922 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -401,4 +401,21 @@ export default {
other: '{n} file trovati',
},
'search.hint': 'Cerca i nomi dei file in tutti gli indici di gruppo in cache.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Un node che ospita questo gruppo è online',
+ 'presence.offline': 'Nessun node raggiungibile per questo gruppo',
+ 'presence.unknown': 'Non ancora verificato',
+ 'chat.load_older': 'Carica {n} messaggi precedenti',
+ 'chat.start_of_history': 'Inizio della conversazione',
+ 'chat.today': 'Oggi',
+ 'chat.yesterday': 'Ieri',
+ 'chat.unread': 'Nuovi messaggi',
+ 'chat.jump_latest': 'Vai ai più recenti',
+ 'chat.jump_new': 'Nuovi messaggi',
+ 'group.leave': 'Esci dal gruppo',
+ 'group.leave_confirm': 'Uscire da «{name}»? Perderà l’accesso ai suoi file e alla chat. I file che ha caricato restano sul node, e il node mantiene l’identità che ha fissato per lei finché il suo operatore non la rimuove.',
+ 'profile.title': 'Profilo',
+ 'usermenu.profile': 'Profilo',
+ 'create_group.public_is_open': 'Chiunque può trovare un gruppo pubblico e parteciparvi. Per scegliere chi entra, lo crei privato.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f9fd5ac..0adc51c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -390,4 +390,21 @@ export default {
other: '{n} 個のファイルが見つかりました',
},
'search.hint': 'キャッシュされたすべてのグループインデックスからファイル名を検索します。',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'このグループをホストする node が稼働中です',
+ 'presence.offline': 'このグループに到達できる node がありません',
+ 'presence.unknown': '未確認',
+ 'chat.load_older': '以前のメッセージを {n} 件読み込む',
+ 'chat.start_of_history': '会話のはじまり',
+ 'chat.today': '今日',
+ 'chat.yesterday': '昨日',
+ 'chat.unread': '新しいメッセージ',
+ 'chat.jump_latest': '最新へ移動',
+ 'chat.jump_new': '新しいメッセージ',
+ 'group.leave': 'グループを退出',
+ 'group.leave_confirm': '「{name}」を退出しますか?ファイルとチャットへのアクセスがなくなります。アップロードしたファイルは node に残り、node は固定した識別情報を運営者が削除するまで保持します。',
+ 'profile.title': 'プロフィール',
+ 'usermenu.profile': 'プロフィール',
+ 'create_group.public_is_open': '公開グループは誰でも見つけて参加できます。参加者を選びたい場合は、非公開で作成してください。',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2f778b3..947724f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -403,4 +403,21 @@ export default {
other: '{n} bestanden gevonden',
},
'search.hint': 'Zoekt bestandsnamen in alle groepsindexen in de cache.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Een node die deze groep host is online',
+ 'presence.offline': 'Geen node bereikbaar voor deze groep',
+ 'presence.unknown': 'Nog niet gecontroleerd',
+ 'chat.load_older': '{n} oudere berichten laden',
+ 'chat.start_of_history': 'Begin van het gesprek',
+ 'chat.today': 'Vandaag',
+ 'chat.yesterday': 'Gisteren',
+ 'chat.unread': 'Nieuwe berichten',
+ 'chat.jump_latest': 'Naar de nieuwste',
+ 'chat.jump_new': 'Nieuwe berichten',
+ 'group.leave': 'Groep verlaten',
+ 'group.leave_confirm': '„{name}” verlaten? U verliest de toegang tot de bestanden en de chat ervan. Bestanden die u hebt geüpload blijven op de node, en de node houdt de voor u vastgezette identiteit tot zijn beheerder die weghaalt.',
+ 'profile.title': 'Profiel',
+ 'usermenu.profile': 'Profiel',
+ 'create_group.public_is_open': 'Iedereen kan een openbare groep vinden en eraan deelnemen. Wilt u kiezen wie binnenkomt, maak de groep dan privé.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index d77a160..687b93b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -416,4 +416,21 @@ export default {
other: 'Znaleziono {n} pliku',
},
'search.hint': 'Przeszukuje nazwy plików we wszystkich zbuforowanych indeksach grup.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Node hostujący tę grupę jest dostępny',
+ 'presence.offline': 'Brak osiągalnego node dla tej grupy',
+ 'presence.unknown': 'Jeszcze nie sprawdzono',
+ 'chat.load_older': 'Wczytaj {n} starszych wiadomości',
+ 'chat.start_of_history': 'Początek rozmowy',
+ 'chat.today': 'Dziś',
+ 'chat.yesterday': 'Wczoraj',
+ 'chat.unread': 'Nowe wiadomości',
+ 'chat.jump_latest': 'Przejdź do najnowszych',
+ 'chat.jump_new': 'Nowe wiadomości',
+ 'group.leave': 'Opuść grupę',
+ 'group.leave_confirm': 'Opuścić grupę „{name}”? Utraci Pan(i) dostęp do jej plików i czatu. Wysłane pliki pozostaną na node, a node zachowa przypiętą dla Pana/Pani tożsamość do czasu, aż jego operator ją usunie.',
+ 'profile.title': 'Profil',
+ 'usermenu.profile': 'Profil',
+ 'create_group.public_is_open': 'Grupę publiczną każdy może znaleźć i do niej dołączyć. Aby decydować, kto wchodzi, proszę utworzyć grupę prywatną.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 9d00ece..56bfbdd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -400,4 +400,21 @@ export default {
other: '{n} arquivos encontrados',
},
'search.hint': 'Pesquisa nomes de arquivo em todos os índices de grupo em cache.',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': 'Um node que hospeda este grupo está on-line',
+ 'presence.offline': 'Nenhum node acessível para este grupo',
+ 'presence.unknown': 'Ainda não verificado',
+ 'chat.load_older': 'Carregar {n} mensagens anteriores',
+ 'chat.start_of_history': 'Início da conversa',
+ 'chat.today': 'Hoje',
+ 'chat.yesterday': 'Ontem',
+ 'chat.unread': 'Mensagens novas',
+ 'chat.jump_latest': 'Ir para a mais recente',
+ 'chat.jump_new': 'Mensagens novas',
+ 'group.leave': 'Sair do grupo',
+ 'group.leave_confirm': 'Sair de “{name}”? Você perderá o acesso aos arquivos e à conversa. Os arquivos que você enviou permanecem no node, e ele mantém a identidade que fixou para você até que o operador dele a remova.',
+ 'profile.title': 'Perfil',
+ 'usermenu.profile': 'Perfil',
+ 'create_group.public_is_open': 'Qualquer pessoa pode encontrar um grupo público e entrar nele. Para escolher quem entra, crie-o como privado.',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index bfa6d05..40a40a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -373,4 +373,21 @@ export default {
other: '找到 {n} 个文件',
},
'search.hint': '在所有已缓存的群组索引中搜索文件名。',
+
+ // Presence, chat paging, leaving a group, the profile page
+ 'presence.online': '有一个服务本群组的 node 在线',
+ 'presence.offline': '本群组没有可达的 node',
+ 'presence.unknown': '尚未检测',
+ 'chat.load_older': '加载更早的 {n} 条消息',
+ 'chat.start_of_history': '对话开头',
+ 'chat.today': '今天',
+ 'chat.yesterday': '昨天',
+ 'chat.unread': '新消息',
+ 'chat.jump_latest': '跳到最新',
+ 'chat.jump_new': '新消息',
+ 'group.leave': '退出群组',
+ 'group.leave_confirm': '退出“{name}”?您将失去其文件和聊天的访问权。您上传的文件仍留在 node 上,node 也会保留它为您固定的身份,直到其运营者将其移除。',
+ 'profile.title': '个人资料',
+ 'usermenu.profile': '个人资料',
+ 'create_group.public_is_open': '任何人都可以找到并加入公开群组。若要挑选谁能进来,请创建为私密群组。',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 11c108b..a041f66 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -232,7 +232,9 @@ a:hover { text-decoration: underline; }
}
.sidebar-item {
- display: block;
+ display: flex;
+ align-items: center;
+ gap: 8px;
padding: 7px 12px;
border-radius: 6px;
color: var(--text);
@@ -442,10 +444,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Chat panel ──────────────────────────────────────────────────────────── */
.chat-panel {
+ position: relative;
display: flex;
flex-direction: column;
- height: calc(100vh - 220px);
- min-height: 300px;
+ /* Overwritten from JS with the space actually left below the group header,
+ which varies with the description and the buttons. These two are the value
+ used for the first paint and the fallback if that never runs. `dvh` rather
+ than `vh` so a phone's collapsing URL bar does not add a scrollbar. */
+ height: calc(100dvh - 260px);
+ min-height: 240px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
@@ -565,7 +572,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 8px;
flex-wrap: wrap;
}
-.toolbar-group { display: flex; align-items: center; gap: 6px; }
+.toolbar-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
.toolbar-group.right { margin-left: auto; }
.tb-btn {
@@ -610,6 +623,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-dim);
+ /* A flex item defaults to min-width:auto and refuses to shrink below its
+ content — which is how a 150px field pushed the toolbar off a phone. */
+ min-width: 0;
}
.tb-search:focus-within { border-color: var(--border-focus); }
.tb-search .icon { width: 14px; height: 14px; flex-shrink: 0; }
@@ -619,6 +635,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
outline: none;
padding: 0;
width: 150px;
+ min-width: 0;
font-size: 0.83em;
color: var(--text);
}
@@ -627,6 +644,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
display: flex;
align-items: center;
gap: 2px;
+ flex-wrap: wrap;
+ min-width: 0;
font-size: 0.85em;
min-width: 0;
flex-wrap: wrap;
@@ -1180,7 +1199,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-style: italic;
}
-.admin-actions { display: flex; gap: 6px; }
+/* A row whose actions cell is empty — the group owner, who cannot be removed,
+ or your own account in the admin table — used to sit lower than the rest:
+ `display: flex` makes the cell the height of its button, and nothing when
+ there is no button. Reserving the height keeps every row aligned without
+ rendering a disabled button nobody can use. */
+.admin-actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ min-height: 30px;
+}
.admin-btn {
padding: 4px 10px;
@@ -1443,6 +1472,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.page-center { padding: 16px; }
.th-type, .td-type { display: none; }
.th-date, .td-date { display: none; }
+
+ /* The toolbar's three groups each take a line rather than competing for one.
+ `margin-left: auto` on the right-hand group is what pushed it off the
+ screen once it no longer fitted. */
+ .toolbar-group,
+ .toolbar-group.right { width: 100%; margin-left: 0; }
+ .tb-search { flex: 1 1 120px; }
+ .tb-search input { width: 100%; }
+ /* The actions sit on their own line under the toggle, and wrap within it. */
+ .tb-actions { width: 100%; }
+
+ /* Every pixel here is one the conversation does not get. */
+ .group-header { margin-bottom: 10px; gap: 8px; }
+ .group-tabs { margin-bottom: 10px; }
+ .chat-messages { padding: 12px; }
}
/* ── Notification bell ───────────────────────────────────────────────────── */
@@ -1513,29 +1557,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Create Group page ───────────────────────────────────────────────────── */
-.create-group-page {
- display: flex;
- justify-content: center;
- padding-top: 8px;
-}
-.create-group-card {
- width: 100%;
- max-width: 520px;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 32px;
- box-shadow: var(--shadow);
-}
-.create-group-header { margin-bottom: 24px; }
-.create-group-header h2 { margin-bottom: 8px; font-size: 1.3em; }
-.create-group-hint {
- color: var(--text-secondary);
- font-size: 0.88em;
- line-height: 1.5;
-}
.form-field { margin-bottom: 20px; }
.form-label {
@@ -1548,37 +1571,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
margin-bottom: 8px;
}
-.option-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
-.option-card {
- display: flex; flex-direction: column; align-items: center; gap: 4px;
- padding: 14px 10px;
- background: var(--bg-base);
- border: 2px solid var(--border);
- border-radius: 10px;
- cursor: pointer;
- text-align: center;
- transition: border-color 0.12s, background 0.12s;
-}
-.option-card:hover { border-color: var(--accent); background: var(--bg-base); }
-.option-card.selected .option-icon { color: var(--accent); }
-.option-card.selected {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 8%, var(--bg-base));
-}
-.option-icon { width: 26px; height: 26px; margin-bottom: 2px; color: var(--text-secondary); }
-.option-title { font-weight: 600; font-size: 0.9em; color: var(--text); }
-.option-desc { font-size: 0.75em; color: var(--text-dim); line-height: 1.3; }
-.create-group-submit {
- width: 100%;
- padding: 12px;
- font-size: 1em;
- font-weight: 600;
- margin-top: 8px;
- border-radius: 8px;
-}
.form-textarea {
width: 100%;
@@ -1638,3 +1633,206 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.notif-feed h3 { display: flex; align-items: center; justify-content: space-between; }
.notif-purge { font-size: 0.8em; padding: 4px 10px; }
+
+
+/* ── Presence ──────────────────────────────────────────────────────────────
+ Three states, and the third is deliberately not a colour: "unknown" must
+ not read as a verdict. Colour alone never carries the meaning — each dot
+ also has a title and an aria-label, since red and green are exactly the
+ pair most colour-blind readers cannot separate. */
+
+.presence {
+ flex-shrink: 0;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: 1px solid transparent;
+ transition: background 0.2s, border-color 0.2s;
+}
+.presence-online { background: var(--success); }
+.presence-offline { background: var(--error); }
+.presence-unknown { background: transparent; border-color: var(--text-dim); }
+
+.sidebar-item-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ── Chat: paging and orientation ─────────────────────────────────────────── */
+
+.chat-older-row { display: flex; justify-content: center; padding: 4px 0 10px; }
+
+.chat-older-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 14px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: var(--bg-raised);
+ color: var(--text-secondary);
+ font-size: 0.8em;
+ cursor: pointer;
+}
+.chat-older-btn:hover:not(:disabled) { background: var(--bg-surface); color: var(--text); }
+.chat-older-btn:disabled { opacity: 0.6; cursor: default; }
+.chat-older-icon { width: 13px; height: 13px; transform: rotate(180deg); }
+
+.chat-start {
+ text-align: center;
+ color: var(--text-dim);
+ font-size: 0.75em;
+ padding: 2px 0 12px;
+}
+
+/* A rule with the date sitting on it — cheaper to scan than a date on every
+ bubble, which is why the per-message stamp stays as just a time. */
+.chat-day, .chat-unread {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 12px 0 6px;
+ font-size: 0.72em;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+.chat-day::before, .chat-day::after,
+.chat-unread::before, .chat-unread::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+}
+.chat-day { color: var(--text-dim); }
+.chat-day span, .chat-unread span { white-space: nowrap; }
+.chat-unread { color: var(--accent); }
+.chat-unread::before, .chat-unread::after { background: var(--accent); opacity: 0.4; }
+
+/* Consecutive messages from one person: tighten the gap instead of repeating
+ the name, so a burst reads as one turn in the conversation. */
+.chat-msg-tight { margin-top: -2px; }
+
+.chat-jump {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 72px;
+ z-index: 5;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border-radius: 16px;
+ border: 1px solid var(--border);
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ font-size: 0.8em;
+ cursor: pointer;
+ box-shadow: var(--shadow-lg);
+}
+.chat-jump:hover { color: var(--text); }
+.chat-jump.unread {
+ background: var(--accent);
+ color: var(--accent-text);
+ border-color: var(--accent);
+}
+.chat-jump-icon { width: 13px; height: 13px; }
+
+
+/* ── File toolbar actions ─────────────────────────────────────────────────────
+ These replaced a dropdown labelled "Actions (n)". With at most four verbs,
+ hiding them behind a menu cost a click and a guess about what was inside. */
+
+.tb-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+
+.tb-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
+}
+.tb-icon-btn:hover:not(:disabled) {
+ background: var(--bg-raised);
+ color: var(--text);
+ border-color: var(--accent);
+}
+/* Present but inert: the bar shows what exists here, and greys out what this
+ selection cannot do. `not-allowed` rather than `default` so the reason is
+ legible before the click. */
+.tb-icon-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+.tb-icon-btn .icon { width: 16px; height: 16px; }
+.tb-icon-btn.danger { color: var(--error); }
+.tb-icon-btn.danger:hover:not(:disabled) {
+ background: var(--error-bg);
+ border-color: var(--error);
+ color: var(--error);
+}
+
+
+/* ── Choice lists ─────────────────────────────────────────────────────────────
+ Replaces two side-by-side <button> panels. They carried the selection in a
+ border colour and nothing else: a button conveys no chosen/not-chosen state
+ to a screen reader, and side by side they read as two independent actions
+ rather than one either/or. A radio group says exactly one of these, is
+ navigable with the arrow keys, and can be labelled. */
+
+.choice-list { display: flex; flex-direction: column; gap: 8px; }
+
+.choice {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-base);
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+.choice:hover { border-color: var(--accent); }
+.choice.selected { border-color: var(--accent); background: var(--bg-raised); }
+.choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.choice input[type="radio"] {
+ margin: 3px 0 0;
+ accent-color: var(--accent);
+ flex-shrink: 0;
+}
+
+.choice-icon {
+ width: 18px;
+ height: 18px;
+ margin-top: 1px;
+ flex-shrink: 0;
+ color: var(--text-secondary);
+}
+.choice.selected .choice-icon { color: var(--accent); }
+
+.choice-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.choice-title { font-size: 0.9em; font-weight: 600; color: var(--text); }
+.choice-desc { font-size: 0.82em; color: var(--text-secondary); line-height: 1.4; }
+
+.btn-primary {
+ padding: 10px 22px;
+ border: none;
+ border-radius: 8px;
+ background: var(--accent);
+ color: var(--accent-text);
+ font-size: 0.95em;
+ font-weight: 600;
+ cursor: pointer;
+}
+.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
index d0a8805..119687d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/sw.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js
@@ -29,6 +29,11 @@ self.addEventListener('message', (event) => {
readable: data.readable,
filename: data.filename || 'download',
size: Number(data.size) || 0,
+ // Answered when the navigation actually reaches us. The page waits for it:
+ // if this worker is never asked for the URL — an uncontrolled page, or a
+ // browser that blocks a download from a hidden iframe — nothing drains the
+ // stream and the page's first write blocks for good.
+ port: data.port || null,
});
// A tab that is closed before it navigates would leave a stream here for the
// life of the worker.
@@ -44,6 +49,9 @@ self.addEventListener('fetch', (event) => {
const entry = pending.get(url.pathname.slice(PREFIX.length));
if (!entry) return;
pending.delete(url.pathname.slice(PREFIX.length));
+ if (entry.port) {
+ try { entry.port.postMessage({ type: 'mbdl-serving', id: url.pathname }); } catch { /* gone */ }
+ }
const headers = {
'Content-Type': 'application/octet-stream',
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');
}
}
}
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
index 41d61bf..85bf488 100644
--- a/packages/meshbay-hub/tests/test_downloads.py
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -161,6 +161,45 @@ def test_backpressure_is_real(tmp_path):
src = DOWNLOADS.read_text()
fn = src[src.index("export async function openStreamedDownload"):]
assert "new TransformStream()" in fn
- assert "[readable]" in fn, "the readable half must be transferred, not copied"
+ # The transfer list may carry more than the stream — a reply port rides
+ # along now — so this asserts that `readable` is transferred, not the exact
+ # shape of the list.
+ transfer = fn[fn.index("worker.postMessage("):]
+ transfer = transfer[transfer.index("["):transfer.index("]") + 1]
+ assert "readable" in transfer, "the readable half must be transferred, not copied"
assert "writer.write(bytes)" in fn
assert "return null" in fn, "a browser that cannot transfer streams must say so"
+
+
+def test_the_streamed_path_gives_up_rather_than_blocking_for_ever():
+ """Reported 2026-08-16: a download frozen at exactly one chunk.
+
+ The writable half applies real backpressure, which is the whole point — and
+ the trap. If nothing ever reads the readable half, `writer.write()` waits
+ for room that never comes, and the transfer stops dead after the stream's
+ internal queue fills. Two ways that happens on a phone: the page is not yet
+ *controlled* by the worker, so the iframe's request is never handed to its
+ fetch handler; or the browser refuses a download started from a hidden
+ iframe. Both are silent.
+
+ So the worker confirms that it actually answered, and this path reports
+ failure instead of returning a sink nobody drains.
+ """
+ src = DOWNLOADS.read_text()
+ fn = src[src.index("export async function openStreamedDownload"):]
+ assert "mbdl-serving" in fn, "the worker has to confirm it served the request"
+ assert "Promise.race" in fn, "the confirmation needs a deadline"
+ assert "writable.abort" in fn, "give up cleanly so the caller can fall back"
+
+ sw = (DOWNLOADS.parent / "sw.js").read_text()
+ assert "mbdl-serving" in sw, "and the worker has to send that confirmation"
+
+
+def test_an_uncontrolled_page_is_not_treated_as_ready():
+ """`registration.active` says a worker exists, not that it will see our fetch."""
+ src = DOWNLOADS.read_text()
+ fn = src[src.index("async function serviceWorker()"):]
+ fn = fn[:fn.index("\n}")]
+ assert "navigator.serviceWorker.controller" in fn
+ assert "controllerchange" in fn, (
+ "control can arrive a tick after registration; waiting beats refusing")
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
new file mode 100644
index 0000000..741e728
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -0,0 +1,159 @@
+"""
+The rules that keep the Files toolbar inside a phone screen.
+
+Reported from a real handset: the toolbar ran off the right edge, the download
+button worst of all. The cause was arithmetic rather than subtle. At a 360 px
+viewport the toolbar has 310 px of usable width, and its right-hand group asked
+for 440 px:
+
+ filter 172 + Select 90 + five 30 px actions 166 + gaps 12 = 440
+
+`.toolbar-group` had no `flex-wrap`, so that group could not break, and
+`margin-left: auto` pushed the excess off the right-hand side rather than the
+left — which is exactly how it was seen.
+
+These assertions read the stylesheet. That is weak evidence and it is what is
+available: there is no browser in this suite, so a layout cannot be measured
+here, only its inputs pinned. What they buy is that the four rules holding the
+toolbar together cannot be removed without something saying so.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+CSS = STATIC / "style.css"
+
+pytestmark = pytest.mark.skipif(
+ not CSS.exists(), reason="the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def css():
+ return CSS.read_text()
+
+
+def _rule(css: str, selector: str) -> str:
+ """The body of the first rule whose selector list starts with `selector`."""
+ m = re.search(r"^" + re.escape(selector) + r"[^{]*\{([^}]*)\}", css, re.M)
+ assert m, f"no rule found for {selector}"
+ return m.group(1)
+
+
+@pytest.fixture(scope="module")
+def mobile(css):
+ """The body of the max-width: 768px block."""
+ i = css.index("@media (max-width: 768px)")
+ depth, j = 0, css.index("{", i)
+ for k in range(j, len(css)):
+ if css[k] == "{":
+ depth += 1
+ elif css[k] == "}":
+ depth -= 1
+ if depth == 0:
+ return css[j:k]
+ pytest.fail("the mobile media query is not closed")
+
+
+def test_a_toolbar_group_can_break(css):
+ """Without this the right-hand group is a single unbreakable 440 px row."""
+ assert "flex-wrap: wrap" in _rule(css, ".toolbar-group")
+
+
+def test_the_filter_can_shrink(css):
+ """A fixed 150 px input keeps its width and pushes everything after it out."""
+ assert "min-width: 0" in _rule(css, ".tb-search")
+ assert "min-width: 0" in _rule(css, ".tb-search input")
+
+
+def test_the_action_buttons_wrap(css):
+ assert "flex-wrap: wrap" in _rule(css, ".tb-actions")
+
+
+def test_breadcrumbs_wrap(css):
+ """A deep path is the other way this row grows without limit."""
+ assert "flex-wrap: wrap" in _rule(css, ".breadcrumbs")
+
+
+def test_the_right_hand_group_stops_being_pushed_right_on_a_phone(mobile):
+ """`margin-left: auto` is what sent the overflow off-screen to the right."""
+ assert "margin-left: 0" in mobile
+ assert ".toolbar-group.right" in mobile
+
+
+def test_the_groups_take_a_line_each_on_a_phone(mobile):
+ assert "width: 100%" in mobile
+
+
+def test_the_toolbar_still_fits_a_360px_screen(css):
+ """The measurements the rules above are chosen against.
+
+ Recomputed from the stylesheet rather than restated, so a change to the
+ button height or the toolbar padding is caught here instead of on a phone.
+ """
+ icon_btn = _rule(css, ".tb-icon-btn")
+ size = int(re.search(r"width:\s*(\d+)px", icon_btn).group(1))
+ gap = int(re.search(r"gap:\s*(\d+)px", _rule(css, ".tb-actions")).group(1))
+
+ usable = 360 - 2 * 16 - 2 * 8 - 2 # viewport − .main − .file-toolbar − borders
+ actions = 5 * size + 4 * gap # play, view, download, zip, delete
+ assert actions <= usable, (
+ f"five actions need {actions}px and the toolbar offers {usable}px on a "
+ "360px screen — they no longer fit on their own line")
+
+
+# ── The chat panel's height ───────────────────────────────────────────────────
+
+APP = STATIC / "app.js"
+
+
+@pytest.fixture(scope="module")
+def app():
+ return APP.read_text()
+
+
+def test_the_chat_panel_is_measured_not_guessed(app):
+ """`calc(100vh - 220px)` was wrong twice over on a phone.
+
+ `100vh` is the viewport with the URL bar *hidden*, so with it showing the
+ panel is already taller than the screen. And 220px is a guess at the group
+ header, which carries a title, a description of any length, an edit link,
+ a delete button and the tabs. Between them the composer ended up below the
+ fold and the whole page scrolled to reach it.
+ """
+ assert "el.getBoundingClientRect().top + window.scrollY" in app, (
+ "the height must come from where the panel actually sits")
+ assert "window.visualViewport?.height || window.innerHeight" in app, (
+ "innerHeight alone ignores the on-screen keyboard on Android")
+
+
+def test_the_measurement_survives_a_scrolled_page(app):
+ """Document-relative, so the answer does not depend on the scroll offset."""
+ fit = app[app.index("const fit = () => {"):]
+ fit = fit[:fit.index("};")]
+ assert "window.scrollY" in fit
+
+
+def test_the_panel_refits_when_the_viewport_changes(app):
+ for event in ("resize", "orientationchange"):
+ assert f"addEventListener('{event}', fit)" in app
+ assert "visualViewport?.addEventListener('resize', fit)" in app
+ assert "removeEventListener('resize', fit)" in app, "the listener must be released"
+
+
+def test_the_css_floor_does_not_fight_the_measurement(css, app):
+ """A min-height above the computed value would put the scrollbar back."""
+ panel = _rule(css, ".chat-panel")
+ css_floor = int(re.search(r"min-height:\s*(\d+)px", panel).group(1))
+ js_floor = int(re.search(r"const CHAT_MIN_HEIGHT = (\d+)", app).group(1))
+ assert css_floor == js_floor, (
+ f"CSS floor {css_floor}px and JS floor {js_floor}px disagree — the "
+ "larger one silently wins and the page scrolls again")
+
+
+def test_the_fallback_height_uses_dvh(css):
+ """The value before the measurement runs, and if it never does."""
+ panel = _rule(css, ".chat-panel")
+ assert "dvh" in panel, "vh is the URL-bar-hidden viewport and overshoots"
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index 79d7c9a..9d02d42 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -210,8 +210,11 @@ def test_a_multi_file_download_waits_for_each_picker():
selected, one file downloaded.
"""
app = APP.read_text()
- block = app[app.index("${selectedFiles.length > 0 && html`"):]
- block = block[:block.index("`}")]
+ # Anchored on the loop rather than on the markup around it: the toolbar
+ # moved from a dropdown to icon buttons and took the old wrapper with it,
+ # while the property under test — one picker at a time — did not change.
+ block = app[app.index("for (const e of selectedFiles)"):]
+ block = block[:block.index("\n")]
assert "await downloadFile(e)" in block, (
"downloads are fired without awaiting again; only the first will ask "
"for a save location and the others will be rejected")
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
new file mode 100644
index 0000000..fb92fcd
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -0,0 +1,218 @@
+"""
+Two wire contracts in transport.js that fail quietly when broken.
+
+Neither can be reached from Python, and `QE/deploy/e2e.py` is a second
+implementation of the client rather than a test of this one, so these read the
+source. That is weak evidence in general, and the right kind here: both defects
+below produce a plausible screen rather than an error.
+
+ - History paged forward from the oldest message, so a group with more than
+ 200 of them opened on its first screen and the recent conversation could
+ not be reached. Nothing threw; the wrong messages were simply shown.
+ - A reply that is not routed falls through to "resolve the oldest pending
+ request". Adding a ping made that dangerous: a pong handed to a waiting
+ history request satisfies it with a message that has no `messages` field,
+ and the conversation renders empty.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+APP = STATIC / "app.js"
+
+pytestmark = pytest.mark.skipif(
+ not TRANSPORT.exists(), reason="the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def transport():
+ return TRANSPORT.read_text()
+
+
+@pytest.fixture(scope="module")
+def app():
+ return APP.read_text()
+
+
+def test_chat_history_pages_backwards(transport):
+ body = transport[transport.index("async fetchChatHistory"):]
+ body = body[:body.index("\n }")]
+ assert "before" in body, "the request must carry a backward cursor"
+ assert "since" not in body, (
+ "`since` pages forward from the oldest message — that was the bug")
+
+
+def test_chat_history_reports_whether_more_exists(transport):
+ body = transport[transport.index("async fetchChatHistory"):]
+ body = body[:body.index("\n }")]
+ assert "has_more" in body, (
+ "without it the 'load older' control cannot know when to stop offering")
+
+
+def test_the_browser_asks_for_the_newest_page_first(app):
+ """A group opens on the newest messages, not the oldest."""
+ assert "fetchChatHistory({ limit: CHAT_PAGE })" in app
+ assert re.search(r"const CHAT_PAGE\s*=\s*100", app)
+ assert re.search(r"const CHAT_OLDER_PAGE\s*=\s*50", app)
+
+
+def test_older_pages_are_requested_with_a_cursor_not_an_offset(app):
+ assert "before: messages[0].id" in app, (
+ "paging by offset repeats or skips messages when one arrives mid-scroll")
+
+
+def test_pong_is_routed_by_its_echoed_token(transport):
+ """Not left to the oldest-pending fallback, which would empty a chat."""
+ assert "if (msg.type === 'pong')" in transport
+ routing = transport[transport.index("if (msg.type === 'pong')"):]
+ routing = routing[:routing.index("const oldest")]
+ assert "handler._key === key" in routing
+ assert "return;" in routing, (
+ "a pong for a timed-out probe must stop here, not fall through")
+
+
+def test_ping_requests_are_keyed(transport):
+ assert "`ping:${obj.token}`" in transport, (
+ "an unkeyed ping cannot be matched to its pong")
+
+
+def test_ping_can_time_out_sooner_than_a_transfer(transport):
+ """30 s is right for a chunk and useless for a liveness probe."""
+ assert "_sendAndWait(obj, timeoutMs = 30000)" in transport
+ body = transport[transport.index(" async ping("):]
+ body = body[:body.index("\n }")]
+ assert "timeoutMs" in body
+
+
+def test_scroll_position_is_anchored_when_older_messages_are_prepended(app):
+ """Everything above the viewport grows, so scrollTop alone is not enough."""
+ assert "scrollHeight - list.scrollTop" in app, "the anchor is measured from the bottom"
+ assert "list.scrollTop = list.scrollHeight - anchorRef.current" in app
+ assert "useLayoutEffect" in app, (
+ "correcting after paint shows the jump it is meant to prevent")
+
+
+def test_the_view_only_follows_new_messages_when_already_at_the_bottom(app):
+ assert "if (atBottomRef.current) list.scrollTop = list.scrollHeight" in app, (
+ "scrolling unconditionally fights someone reading back through history")
+
+
+def test_the_bottom_is_reached_by_scrollTop_not_a_sentinel(app):
+ """scrollIntoView on a zero-height marker stops short of the true bottom.
+
+ The list has padding and a flex gap below the last bubble, so aligning an
+ empty div to the viewport bottom left the bar a few pixels from the end —
+ visible on opening a group and again after sending a message.
+ """
+ # The call, not the word: a comment explaining why it is gone should not
+ # be able to fail this.
+ assert ".scrollIntoView(" not in app
+ assert "list.scrollTo({ top: list.scrollHeight" in app, (
+ "jumping to the latest should also land on the real bottom")
+
+
+def test_messages_are_keyed_by_id_not_index(app):
+ """Index keys plus prepending makes Preact reuse the wrong bubbles."""
+ assert "key=${m.id}" in app
+ assert "key=${i}" not in app.split("function ChatPanel")[1].split("\n}")[0]
+
+
+def test_presence_has_three_states_and_a_label_for_each(app):
+ for state in ("online", "offline", "unknown"):
+ assert f"presence-{state}" in (STATIC / "style.css").read_text()
+ assert "t('presence.' + state)" in app, (
+ "red and green are the pair colour-blind readers cannot separate, so "
+ "the dot needs a title and an aria-label, not just a colour")
+
+
+def test_a_refusal_from_the_node_counts_as_present(app):
+ """The node answering "no" proves it is up; only silence proves nothing."""
+ assert "err.reason ? 'online' : 'offline'" in app
+
+
+# ── The create-group form ─────────────────────────────────────────────────────
+
+def test_choosing_public_settles_the_admission_question(app):
+ """Public implies open, so the policy selector has nothing left to ask.
+
+ Enforced twice on purpose: the API refuses public+invite with a 422, and the
+ form never offers the combination. A form that can build a request the server
+ rejects is a form that produces an error message instead of a group.
+ """
+ assert "setVisibility('public'); setJoinPolicy('open');" in app, (
+ "picking Public must settle the policy, not leave the previous one")
+ assert "setVisibility('private'); setJoinPolicy('invite');" in app, (
+ "going back to Private must not leave the group open by accident")
+
+ form = app[app.index("function CreateGroupPage"):]
+ form = form[:form.index("\n}\n")]
+ selector = form.index("t('create_group.join_policy')")
+ guard = form.rindex("visibility === 'public'", 0, selector)
+ assert guard != -1, "the policy selector must sit behind a visibility guard"
+ assert "create_group.public_is_open" in form[guard:selector], (
+ "a public group should say why there is nothing to choose")
+
+
+def test_the_form_starts_on_a_combination_the_api_accepts(app):
+ form = app[app.index("function CreateGroupPage"):]
+ assert "useState('private')" in form[:form.index("return html")]
+ assert "useState('invite')" in form[:form.index("return html")]
+
+
+# ── Dead references in the SPA ────────────────────────────────────────────────
+
+def test_no_setter_survives_the_state_it_belonged_to(app):
+ """A removed useState leaves its setter behind, and nothing complains.
+
+ `setActionsOpen` outlived `actionsOpen` when the Actions dropdown became a
+ row of buttons, and shipped: every action in the Files panel threw
+ ReferenceError on click. No Python test could see it and e2e.py does not
+ drive the SPA, so the shape is checked here instead.
+
+ A grep for the state name does not find it — `setActionsOpen` does not
+ contain `actionsOpen`, the capital breaks the match. That is exactly how it
+ got through.
+ """
+ import re
+ declared = set(re.findall(r"const \[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState", app))
+ # Names brought in from another module are defined, just not here.
+ imported = set()
+ for names in re.findall(r"import\s*\{([^}]*)\}\s*from", app):
+ imported.update(n.strip().split(" as ")[-1].strip() for n in names.split(","))
+ # A bare call only: `downloads.setMode(...)` and `view.setUint32(...)` belong
+ # to their object, not to this component.
+ called = set(re.findall(r"(?<![.\w])(set[A-Z]\w*)\s*\(", app))
+ builtin = {"setTimeout", "setInterval"}
+
+ orphans = sorted(called - declared - imported - builtin)
+ assert not orphans, (
+ f"setter(s) called with no useState behind them: {orphans} — "
+ "each one is a ReferenceError the moment that code path runs")
+
+
+# ── Parallel uploads ──────────────────────────────────────────────────────────
+
+def test_an_upload_refusal_names_the_file_it_is_about(transport):
+ """Reported 2026-08-16: a second upload started in parallel killed both.
+
+ An error used to carry no filename, so the client could not tell whose it
+ was and failed every upload in flight — one name the node disliked took the
+ other file with it. The node names the file now, and only that upload stops.
+ """
+ body = transport[transport.index("if (msg.type === 'error' && this._uploaders.size)"):]
+ body = body[:body.index("\n if (msg.type === 'chat_msg'")]
+ assert "this._uploaders.has(msg.filename)" in body, (
+ "a named refusal must reach one uploader, not all of them")
+ assert "if (!msg.filename)" in body, (
+ "an unnamed error from an older node must still stop everything — "
+ "guessing which upload it belongs to would be worse")
+
+
+def test_uploads_are_tracked_per_file(transport):
+ """Acks interleave when two files are in flight."""
+ assert "this._uploaders = new Map()" in transport
+ assert "this._uploaders.set(file.name" in transport
diff --git a/packages/meshbay-hub/tests/test_video_stream_switch.py b/packages/meshbay-hub/tests/test_video_stream_switch.py
new file mode 100644
index 0000000..464d1f7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_video_stream_switch.py
@@ -0,0 +1,160 @@
+"""
+Switching video before the first one finishes.
+
+Reported from a phone: play a video, do not wait for the end, open another —
+the player sits on "buffering" and never recovers. Two independent causes, both
+in how one stream hands over to the next.
+
+**The flag that outlived its stream.** `flushQueue` returns early while an
+append is in flight (`appendingRef`). Teardown reset the queue, the SourceBuffer
+and the MediaSource, but not that flag. Switch while an append was running and
+it stayed true: the new SourceBuffer never received anything, so no `updateend`
+ever cleared it, no credit ever went back to the node, the node stopped sending
+and the phase never left "buffering". More likely on a phone, where an append
+takes long enough to still be running when a finger moves.
+
+`endedRef` had the same shape: left true, the next stream calls `endOfStream()`
+the first time its queue runs dry and truncates the film.
+
+**Segments from the film you left.** The DataChannel is ordered, so whatever the
+node had already sent arrives after the switch. Every stream message carries a
+`file_id` and nothing looked at it, so those segments were decrypted against the
+new file — failing, in the console, for data that was simply not ours.
+
+The first half is checked by running the real `flushQueue` guard against the
+real reset, in node. The second is read from the source, since it is a shape
+rather than a behaviour.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def app():
+ return APP.read_text()
+
+
+def _player(app: str) -> str:
+ i = app.index("function VideoPlayer(")
+ return app[i:app.index("\nfunction ", i + 1)]
+
+
+# ── The flag that stalled everything ──────────────────────────────────────────
+
+def test_a_new_stream_starts_from_a_clean_slate(app):
+ """The reset must be where the stream starts, not where the last one ended.
+
+ Teardown is skippable — an unmount that races, an effect that re-runs — and
+ a stale flag costs the whole player. Starting from a known state cannot be
+ skipped.
+ """
+ player = _player(app)
+ effect = player[player.index("useEffect(() => {\n let cancelled = false;"):]
+ effect = effect[:effect.index("transport.requestStream")]
+ for ref in ("appendingRef", "endedRef"):
+ assert f"{ref}.current = false" in effect, (
+ f"{ref} is not reset before the stream starts")
+
+
+def test_the_stall_is_reproduced_and_the_reset_clears_it(tmp_path):
+ """The guard and the reset, run for real rather than read.
+
+ Models flushQueue's first line and the append/updateend cycle. Without the
+ reset the second stream appends nothing at all; with it, it drains.
+ """
+ script = tmp_path / "case.mjs"
+ script.write_text("""
+ // flushQueue's guard, and the part that makes it a trap: `updateend`
+ // belongs to the SourceBuffer, which teardown throws away, while
+ // `appending` is a ref on the component, which survives.
+ const makePlayer = (resetOnStart) => {
+ const st = { appending: false, appended: 0, queue: [], owed: 0 };
+ return {
+ st,
+ startStream() { if (resetOnStart) st.appending = false; },
+ push(seg) { st.queue.push(seg); this.flush(); },
+ flush() {
+ if (st.appending || st.queue.length === 0) return;
+ st.appending = true;
+ st.queue.shift();
+ st.appended++;
+ st.owed++; // the browser will fire updateend for this
+ },
+ fireUpdateend() { // only for appends that really happened
+ while (st.owed > 0) {
+ st.owed--;
+ st.appending = false;
+ this.flush();
+ }
+ },
+ teardown() {
+ st.queue = [];
+ st.owed = 0; // the SourceBuffer and its listener are gone
+ },
+ };
+ };
+
+ const out = {};
+ for (const [name, reset] of [["without_reset", false], ["with_reset", true]]) {
+ const p = makePlayer(reset);
+ // First video: an append is in flight and its updateend has not arrived
+ // when the viewer moves on. That is the whole scenario.
+ p.startStream();
+ p.push("A1");
+ p.teardown();
+ const afterFirst = p.st.appended;
+ // Second video.
+ p.startStream();
+ p.push("B1"); p.fireUpdateend();
+ p.push("B2"); p.fireUpdateend();
+ out[name] = p.st.appended - afterFirst;
+ }
+ console.log(JSON.stringify(out));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ got = json.loads(proc.stdout)
+
+ assert got["without_reset"] == 0, (
+ "the scenario no longer reproduces the stall, so this test proves nothing")
+ assert got["with_reset"] == 2, (
+ "the second video still cannot append — the reset does not clear the stall")
+
+
+# ── Segments from the abandoned stream ────────────────────────────────────────
+
+@pytest.mark.parametrize("handler", ["onStreamInit", "onStreamData", "onStreamEnd"])
+def test_stream_messages_are_matched_to_the_file_they_belong_to(app, handler):
+ player = _player(app)
+ body = player[player.index(f"transport.{handler} = "):]
+ body = body[:body.index("\n };")]
+ assert "file_id !== entry.id" in body, (
+ f"{handler} accepts a message from any stream — on an ordered channel "
+ "the film you just left is still arriving")
+
+
+def test_the_node_actually_stamps_those_messages():
+ """The guard above is worth nothing if the field is not sent."""
+ server = (Path(__file__).resolve().parents[2] / "meshbay-node" / "src"
+ / "meshbay_node" / "transport" / "webrtc_server.py")
+ if not server.exists():
+ pytest.skip("the node sources are not available")
+ text = server.read_text()
+ for const in ("STREAM_INIT", "STREAM_DATA", "STREAM_END"):
+ i = text.index(f"MNP.{const}")
+ block = text[i:i + 400]
+ assert re.search(r'"file_id":\s*file_id', block), (
+ f"{const} carries no file_id, so the client cannot tell streams apart")