From eba2e6b14484c124f3c87ff95cd7ee640833e5d3 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 15:35:20 +0200 Subject: feat(hub): cross-group search with reuse of existing views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 175 +++++++-------------- 1 file changed, 59 insertions(+), 116 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 703bd47..50a972b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -8,14 +8,16 @@ import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; -import { FILE_ICONS, formatSize } from './file-utils.js'; +import { formatSize } from './file-utils.js'; import { - HUB, navigate, session, getCachedGroupIndex, getAllCachedIndexes, + HUB, navigate, session, getCachedGroupIndex, _storeBundleKey, _loadBundleKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, ensureFreshToken, hubFetch, refreshAccessToken, } from './hub-client.js'; import { GroupPage } from './group-page.js'; +import { SearchPage, ConnectionPool } from './search-page.js'; +import { MusicPlayerBar } from './music-player.js'; import { GroupName } from './group-name.js'; import { APPS } from './apps.js'; @@ -1293,118 +1295,6 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru `; } - -// ── 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([]); - const [searched, setSearched] = useState(false); - - const doSearch = useCallback(async (q) => { - const term = q.trim().toLowerCase(); - if (!term) { setResults([]); setSearched(false); return; } - const indexes = await getAllCachedIndexes(); - const hits = []; - for (const idx of indexes) { - 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, - groupOwner: idx.groupOwner, syncedAt: idx.cachedAt }); - } - } - } - setResults(hits); - setSearched(true); - }, []); - - const onInput = useCallback((e) => { - const q = e.target.value; - setQuery(q); - doSearch(q); - }, [doSearch]); - - return html` -
-

${t('search.title')}

-
- -
- ${searched && results.length === 0 && html` -

${t('search.no_results')}

- `} - ${results.length > 0 && html` - - - - - - - - - - - - ${results.map(r => html` - - - - - - - - `)} - -
${t('group.col_name')}${t('group.col_size')}${t('search.col_group')}${t('group.col_type')}
${FILE_ICONS[r.type] || FILE_ICONS.other} - ${r.name} - ${formatSize(r.size)} - ${r.groupName - ? html`<${GroupName} name=${r.groupName} owner=${r.groupOwner} inline=${true} />` - : r.groupId.slice(0, 8)} - ${r.syncedAt && html` -
- ${t('search.synced', { ago: formatAgo(r.syncedAt) })} -
- `} -
${r.type}
-

- ${t('search.result_count', { n: results.length })} -

- `} - ${!searched && html` -

${t('search.hint')}

- `} -

${t('search.cache_note')}

-
- `; -} - // ── Settings Page ─────────────────────────────────────────────────────────── const THEME_OPTIONS = ['light', 'dark', 'system']; @@ -3010,6 +2900,48 @@ function App() { const [hubInfo, setHubInfo] = useState(null); const allowPublicGroups = !hubInfo || hubInfo.allow_public_groups !== false; + // -- Persistent music player (lifted from group-page.js) -- + const [musicQueue, setMusicQueue] = useState(null); + const musicPoolRef = useRef(null); + const userRef = useRef(user); + userRef.current = user; + const groupTransportRef = useRef(null); + + useEffect(() => { + musicPoolRef.current = new ConnectionPool(HUB); + return () => { if (musicPoolRef.current) musicPoolRef.current.closeAll(); }; + }, []); + + const getMusicConnection = useCallback(async (groupId) => { + const gt = groupTransportRef.current; + if (gt && gt.groupId === groupId) { + const tr = gt.transportRef.current; + if (tr && tr.connected) return { transport: tr, gek: gt.gekRef.current }; + } + const u = userRef.current; + if (!u || !musicPoolRef.current) throw new Error('no connection'); + const bundleKey = session.bundleKey || await _loadBundleKey(); + if (bundleKey) session.bundleKey = bundleKey; + const conn = await musicPoolRef.current.connect( + groupId, u.token, bundleKey, u.username, u.userId); + return { transport: conn.transport, gek: conn.gek }; + }, []); + + const handlePlayQueue = useCallback((tracks, startIndex, source) => { + if (source && source.transportRef) { + groupTransportRef.current = { + groupId: source.groupId, + transportRef: source.transportRef, + gekRef: source.gekRef, + }; + } else { + groupTransportRef.current = null; + } + setMusicQueue({ tracks, startIndex, nonce: Date.now() }); + }, []); + + const handleStopMusic = useCallback(() => setMusicQueue(null), []); + const resolved = resolveTheme(theme); // Keep the session alive without anyone having to think about it. @@ -3286,7 +3218,10 @@ function App() { } else if (!user) { page = html`<${LoginPage} />`; } else if (route === '/search') { - page = html`<${SearchPage} />`; + page = html`<${SearchPage} + token=${user.token} username=${user.username} userId=${user.userId} + groups=${groups} userPrefs=${userPrefs} + onPlayQueue=${handlePlayQueue} />`; } else if (route === '/explore') { page = html`<${ExplorePage} token=${user.token} myGroupIds=${groups.map(g => g.id)} @@ -3311,7 +3246,8 @@ function App() { userPrefs=${userPrefs} onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} onGroupUpdated=${updateGroup} onPresence=${notePresence} - onLeft=${handleLeftGroup} />`; + onLeft=${handleLeftGroup} + onPlayQueue=${handlePlayQueue} onStopMusic=${handleStopMusic} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} role=${user.role} />` @@ -3360,6 +3296,13 @@ function App() { ${page} + ${musicQueue && html` + <${MusicPlayerBar} + getConnection=${getMusicConnection} + queue=${musicQueue} + userPrefs=${userPrefs} + onClose=${handleStopMusic} /> + `} `; } -- cgit v1.2.3