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