summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js100
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
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