summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
commit84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch)
treed26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/src
parent188a76f52d2f30609147b1beee7754f2cbd1e778 (diff)
downloadmeshbay-84e778d5cdd1bb5cded8a7c0238797c17d48666c.tar.gz
feat(hub): chat, presence, a Profile page, and downloads that do not freeze
Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-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
15 files changed, 1111 insertions, 288 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');
}
}
}