diff options
| -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 |