diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 01:31:49 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 01:31:49 +0200 |
| commit | 5338894f7fec9e1a60affb0e2ff3b9797bcbc968 (patch) | |
| tree | 731247a1ec2a42f78fd01a45b15d794f6b50a620 | |
| parent | f4d04741379e77d2ef86cccc93856e590bfe1082 (diff) | |
| download | meshbay-5338894f7fec9e1a60affb0e2ff3b9797bcbc968.tar.gz | |
The Files panel stops guessing, and search says how old its answer is
Four small things, two of which were the same bug wearing different hats.
The index cache seeded the Files panel and then raced the live index:
IndexedDB is async, so a fast node could hand you the real list and have
it overwritten a moment later by the cached one. That is the "choses
bizarres". The panel now shows what the node says, or says it cannot
reach the node — no third state that looks like data but is memory.
The cache stays, written on every sync, and the search page is the only
thing that reads it. Search across groups has no other source: it cannot
connect to every node to answer a keystroke. So it now says how stale
each hit is — "synced 2 hours ago", per group — and a line under the
results explains that opening a group refreshes what search knows about
it. Deleting a file also rewrites the cache now; it used to refresh the
table and leave the cache holding a file that no longer existed, which is
why search kept offering it.
The invite form is shown only once this browser holds an operator key.
Invites are signed with it and the node checks the signature against its
roster, so an unpaired browser could fill the form in and fail on submit.
An owner who is not the node's operator is told to ask the one who is.
Group descriptions now show on the home cards, as they already did in
Explore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 100 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 8 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 2 |
3 files changed, 78 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 0f101d2..f9f6b55 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -605,6 +605,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge }) { ${groups.map(g => html` <a key=${g.id} class="group-card" href="#/group/${g.id}"> <h3>${g.name}</h3> + ${g.description && html`<p class="group-card-desc">${g.description}</p>`} <span class="badge">${g.visibility}</span> ${' '} <span class="badge">${g.join_policy}</span> @@ -873,7 +874,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, onJoined }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); - const [cached, setCached] = useState(false); + const [error, setError] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); @@ -922,15 +923,25 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, return () => document.removeEventListener('click', close); }, [menuOpen]); + // 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 + // group was reconnected. + const applyIndex = useCallback((indexMsg) => { + const fresh = indexMsg.entries || []; + setEntries(fresh); + if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); + cacheGroupIndex(groupId, group ? group.name : groupId, fresh); + }, [groupId, group]); + useEffect(() => { let cancelled = false; - getCachedGroupIndex(groupId).then(hit => { - if (hit && !cancelled) { - setEntries(hit.entries || []); - setCached(true); - } - }); + // The cache is written here and read only by the search page. It used to + // seed this list too, which put a stale index on screen and then raced the + // live one: IndexedDB is async, so a fast node could be overwritten by the + // cache landing afterwards. Files shows what the node says, or says it + // cannot reach the node. const connect = async () => { setStatus('discovering'); @@ -984,11 +995,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, transport.onIndexSync = (msg) => { if (cancelled) return; - const synced = msg.entries || []; - setEntries(synced); - if (msg.dirs) setNodeDirs(msg.dirs); - setCached(false); - cacheGroupIndex(groupId, group ? group.name : groupId, synced); + applyIndex(msg); }; // We are in: an invitation to this group has served its purpose. @@ -996,13 +1003,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const indexMsg = await transport.fetchIndex(); if (cancelled) return; - const freshEntries = indexMsg.entries || []; - setEntries(freshEntries); - setNodeDirs(indexMsg.dirs || []); - setCached(false); + applyIndex(indexMsg); setStatus('connected'); - - cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries); } catch (err) { if (cancelled) return; @@ -1040,6 +1042,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, transportRef.current = null; } }; + // applyIndex is deliberately not a dependency: its identity changes with the + // `group` object, which the hub poll re-creates, and re-running this effect + // means tearing down the WebRTC connection. groupId is here, so a real group + // change still re-captures it. }, [groupId, token, retryKey]); const downloadFile = useCallback(async (entry) => { @@ -1150,21 +1156,19 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.deleteFile(entry.id, signFn); - const indexMsg = await transport.fetchIndex(); - setEntries(indexMsg.entries || []); + applyIndex(await transport.fetchIndex()); } catch (err) { setError(err.message); } - }, []); + }, [applyIndex]); const refreshIndex = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; try { - const indexMsg = await transport.fetchIndex(); - setEntries(indexMsg.entries || []); + applyIndex(await transport.fetchIndex()); } catch {} - }, []); + }, [applyIndex]); const toggleSort = useCallback((key) => { setSortAsc(prev => sortKey === key ? !prev : true); @@ -1215,9 +1219,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, offline: t('status.offline'), error: t('status.error'), }[status] || status; - const statusLabel = (cached && status !== 'connected') - ? `${baseLabel} (${t('status.cached', { n: entries.length })})` - : baseLabel; + const statusLabel = baseLabel; const statusClass = status === 'connected' ? 'status-ok' : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy'; @@ -1288,7 +1290,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span> </div> `} - ${(status === 'connected' || (cached && entries.length > 0)) && html` + ${status === 'connected' && html` <div class="group-tabs"> <button class="group-tab ${tab === 'files' ? 'active' : ''}" onClick=${() => setTab('files')}>${t('group.tab_files')}</button> @@ -1438,7 +1440,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${' '}${t('group.offline_hint')} </p> `} - ${!cached && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` <p class="page-message">${statusLabel}</p> `} ${previewEntry && html` @@ -1687,7 +1689,13 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, return html` <div class="members-panel"> - ${isAdmin && html` + ${isAdmin && !operatorPaired && html` + <p class="settings-hint"> + ${isNodeAdmin ? t('members.invite_needs_pairing') + : t('members.invite_ask_operator')} + </p> + `} + ${isAdmin && operatorPaired && html` <form class="invite-form" onSubmit=${doInvite}> <h4>${t('members.invite_title')}</h4> ${error && html`<p class="error-msg">${error}</p>`} @@ -2171,6 +2179,29 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { // ── Search Page (cross-group file search) ─────────────────────────────────── +/** + * "3 hours ago", in the reader's language. + * + * The search page needs it because its results come from a cache: a file that + * was deleted an hour ago is still listed until the group is opened again, and + * the honest thing is to say how old the answer is rather than to imply it is + * live. + */ +function formatAgo(ts) { + if (!ts) return ''; + const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' }); + let delta = (ts - Date.now()) / 1000; + const steps = [['second', 60], ['minute', 60], ['hour', 24], + ['day', 7], ['week', 4.35], ['month', 12], ['year', Infinity]]; + for (const [unit, span] of steps) { + if (Math.abs(delta) < span || span === Infinity) { + return rtf.format(Math.round(delta), unit); + } + delta /= span; + } + return ''; +} + function SearchPage() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); @@ -2185,7 +2216,8 @@ function SearchPage() { for (const e of (idx.entries || [])) { if (e.name.toLowerCase().includes(term) || (e.path && e.path.toLowerCase().includes(term))) { - hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName }); + hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName, + syncedAt: idx.cachedAt }); } } } @@ -2231,6 +2263,11 @@ function SearchPage() { <td class="file-size">${formatSize(r.size)}</td> <td> <a href="#/group/${r.groupId}" class="badge">${r.groupName || r.groupId.slice(0, 8)}</a> + ${r.syncedAt && html` + <div class="search-synced"> + ${t('search.synced', { ago: formatAgo(r.syncedAt) })} + </div> + `} </td> <td class="file-type">${r.type}</td> </tr> @@ -2244,6 +2281,7 @@ function SearchPage() { ${!searched && html` <p class="page-message">${t('search.hint')}</p> `} + <p class="settings-hint" style="margin-top:12px">${t('search.cache_note')}</p> </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 1fe8085..cca595d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -263,6 +263,10 @@ const en = { 'members.invite_title': 'Invite member', 'members.username_placeholder': 'Username', 'members.invite_btn': 'Invite', + 'members.invite_needs_pairing': 'Invitations are signed with the key this node ' + + 'pinned for your browser, so pair it below before inviting anyone.', + 'members.invite_ask_operator': 'Only the operator of the node hosting this group can ' + + 'invite, from a browser paired with it.', 'members.pair_title': 'Pair this browser with your node', 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' + 'deletion — from a browser it has been paired with. Run ' @@ -288,12 +292,14 @@ const en = { 'sidebar.search': 'Search files', // Status - 'status.cached': '{n} cached files', // Search 'search.title': 'Search Files', 'search.placeholder': 'Search across all groups...', 'search.no_results': 'No files match your search.', + 'search.synced': 'synced {ago}', + 'search.cache_note': 'Search reads what each group looked like the last time you ' + + 'opened it. Open a group to bring its files up to date here.', 'search.col_group': 'Group', 'search.result_count': '{n} files found', 'search.hint': 'Search file names across all cached group indexes.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2d1eb65..fab4f55 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -57,6 +57,8 @@ body { a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } +.search-synced { font-size: 0.72em; color: var(--text-dim); margin-top: 3px; } + /* ── Icons ────────────────────────────────────────────────────────────────── */ /* Sized in em and stroked in currentColor, so an icon inherits the size and |