From 4ef6ca817e36e207f57dd810727f7225e7d26eff Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 28 Aug 2026 16:11:46 +0200 Subject: feat(hub): search downloads, photos view, pre-connect optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads from search: FilesPanel accepts a getTransport callback for cross-group file downloads. Select/Download are available in search; delete and zip are hidden (readOnly). Each download connects to the entry's group via the pool on demand. Photos view: PhotosApp reused in search with the same pattern as Videos/Music — synthetic root, per-entry transport refs, group badges on album cards. Lightbox uses per-entry transport for full-resolution image fetches. Zip download hidden in search context. Pre-connecting: connections to all groups start as soon as indexing finishes, regardless of the active view. Thumbnails cache across pool evictions, so they're ready when the user switches views. Co-Authored-By: Claude Opus 4.6 --- .../src/meshbay_hub/static/files-app.js | 27 +++++---- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../src/meshbay_hub/static/photos-app.js | 34 ++++++----- .../src/meshbay_hub/static/search-page.js | 70 ++++++++++++++++++---- .../meshbay-hub/src/meshbay_hub/static/style.css | 3 +- 14 files changed, 107 insertions(+), 37 deletions(-) diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index daf13af..75005e2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -27,7 +27,7 @@ function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, - showGroup, readOnly, + showGroup, readOnly, getTransport, }) { const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState(() => new Set()); @@ -42,13 +42,18 @@ function FilesPanel({ useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); const downloadFile = useCallback(async (entry) => { - const transport = transportRef.current; + let transport, gek; + if (getTransport) { + const conn = await getTransport(entry); + transport = conn.transport; + gek = conn.gek; + } else { + transport = transportRef.current; + gek = gekRef.current; + } if (!transport || !transport.connected) return; - // Both of these have to happen inside the click: a browser grants a file - // picker, and re-grants a folder, only from a user gesture — so this stays - // a click handler all the way down into downloadEntry's own picker call. - await downloadEntry(transfers, transport, gekRef.current, entry); - }, []); + await downloadEntry(transfers, transport, gek, entry); + }, [getTransport]); const uploadFile = useCallback((e) => { const files = [...(e.target.files || [])]; @@ -271,14 +276,14 @@ function FilesPanel({ // first file ever downloaded. for (const e of selectedFiles) await downloadFile(e); }), { disabled: selectedFiles.length === 0 })} - ${action('archive', + ${!readOnly && 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); }), { disabled: selectedDirs.length === 0 })} - ${mayEverDelete && action('trash', + ${mayEverDelete && !readOnly && action('trash', deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), () => { const names = [...deletableFiles.map(e => e.name), @@ -337,14 +342,14 @@ function FilesPanel({ setFilter(e.target.value)} /> `} - ${!readOnly && html``} + ${selecting && html`
${actionItems}
`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index e64d910..e3d9ee2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -533,6 +533,7 @@ export default { 'search.view_files': 'Dateien', 'search.view_videos': 'Videos', 'search.view_music': 'Musik', + 'search.view_photos': 'Fotos', 'search.connecting': 'Verbindung zu Gruppen wird hergestellt …', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 06d54a7..e8bfca3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -611,6 +611,7 @@ export default { 'search.view_files': 'Files', 'search.view_videos': 'Videos', 'search.view_music': 'Music', + 'search.view_photos': 'Photos', 'search.connecting': 'Connecting to groups...', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index af211c6..0847d79 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -529,6 +529,7 @@ export default { 'search.view_files': 'Archivos', 'search.view_videos': 'Vídeos', 'search.view_music': 'Música', + 'search.view_photos': 'Fotos', 'search.connecting': 'Conectando a los grupos…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 92ab2b9..bcc82d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -533,6 +533,7 @@ export default { 'search.view_files': 'Fichiers', 'search.view_videos': 'Vidéos', 'search.view_music': 'Musique', + 'search.view_photos': 'Photos', 'search.connecting': 'Connexion aux groupes…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 201fdfc..bfdd964 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -531,6 +531,7 @@ export default { 'search.view_files': 'File', 'search.view_videos': 'Video', 'search.view_music': 'Musica', + 'search.view_photos': 'Foto', 'search.connecting': 'Connessione ai gruppi…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index a421e25..5d86e32 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -519,6 +519,7 @@ export default { 'search.view_files': 'ファイル', 'search.view_videos': '動画', 'search.view_music': '音楽', + 'search.view_photos': '写真', 'search.connecting': 'グループに接続中…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index b2e3b24..77ba822 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -533,6 +533,7 @@ export default { 'search.view_files': 'Bestanden', 'search.view_videos': 'Video\'s', 'search.view_music': 'Muziek', + 'search.view_photos': 'Foto\'s', 'search.connecting': 'Verbinding maken met groepen…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2c2bacd..23fc4d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -552,6 +552,7 @@ export default { 'search.view_files': 'Pliki', 'search.view_videos': 'Filmy', 'search.view_music': 'Muzyka', + 'search.view_photos': 'Zdjęcia', 'search.connecting': 'Łączenie z grupami…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 96dbc75..5c217f0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -530,6 +530,7 @@ export default { 'search.view_files': 'Arquivos', 'search.view_videos': 'Vídeos', 'search.view_music': 'Música', + 'search.view_photos': 'Fotos', 'search.connecting': 'Conectando aos grupos…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 45d89f7..b00779d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -505,6 +505,7 @@ export default { 'search.view_files': '文件', 'search.view_videos': '视频', 'search.view_music': '音乐', + 'search.view_photos': '照片', 'search.connecting': '正在连接群组…', // Presence, chat paging, leaving a group, the profile page diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 26785e7..d63b384 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -79,22 +79,23 @@ function albumYearLabel(photos) { // ── landing grid: one card per album (directory containing images) ───────── function AlbumCard({ album, transportRef, gekRef, onOpen }) { - // A photo whose own thumbnail is already ready, over blindly photos[0] — - // that specific file's enrichment may not have finished yet even though - // a sibling's has (same fallback video-app.js's PosterGrid already uses - // picking a show's representative episode). const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0]; + const tRef = cover._tRef || transportRef; + const gRef = cover._gRef || gekRef; const year = albumYearLabel(album.photos); return html`
<${MediaThumb} thumbHash=${cover.thumb_hash} alt=${albumTitle(album.dir)} - cls="photo-album-cover" transportRef=${transportRef} gekRef=${gekRef} + cls="photo-album-cover" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" />
${albumTitle(album.dir)}
${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
+ ${cover.groupName && html` +
${cover.groupName}
+ `}
`; @@ -116,10 +117,12 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { // ── open album: grid of its own photos ────────────────────────────────────── function PhotoTile({ entry, transportRef, gekRef, onOpen }) { + const tRef = entry._tRef || transportRef; + const gRef = entry._gRef || gekRef; return html`
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} - cls="photo-tile-thumb" transportRef=${transportRef} gekRef=${gekRef} + cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" />
`; @@ -142,6 +145,8 @@ const ZOOM_MAX = 400; function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { const entry = photos[index]; + const tRef = entry._tRef || transportRef; + const gRef = entry._gRef || gekRef; const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null); const [loading, setLoading] = useState(!_fullBlobCache.has(entry.id)); // null = "fit to window" (the default, object-fit: contain); a number is @@ -160,12 +165,12 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { setLoading(true); let cancelled = false; (async () => { - const transport = transportRef.current; + const transport = tRef.current; if (!transport || !transport.connected) { setLoading(false); return; } try { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); const chunks = await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks); + transport, gRef.current, entry.id, totalChunks); if (cancelled) return; const url = URL.createObjectURL(new Blob(chunks)); _fullBlobCache.set(entry.id, url); @@ -268,7 +273,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { `; } -function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) { +function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) { const [lightboxIndex, setLightboxIndex] = useState(null); const zip = useCallback(async () => { @@ -296,8 +301,8 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) { ${year && html`${year}`} - + ${!readOnly && html``}
${album.photos.map((e, i) => html` @@ -319,6 +324,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) { function PhotosApp({ groupId, transportRef, gekRef, status, entries, photoRoots, setError, + hideFilter, readOnly, }) { const [openDir, setOpenDir] = useState(null); const [filter, setFilter] = useState(''); @@ -346,11 +352,11 @@ function PhotosApp({ `} ${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html`
- `}
${filteredAlbums.length === 0 && html`

${needle ? t('group.empty_filter') : t('photo.empty')}

@@ -362,7 +368,7 @@ function PhotosApp({ ${status === 'connected' && openAlbum && html` <${AlbumView} album=${openAlbum} entries=${entries} transportRef=${transportRef} gekRef=${gekRef} setError=${setError} - onBack=${() => setOpenDir(null)} /> + onBack=${() => setOpenDir(null)} readOnly=${readOnly} /> `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 032d26a..5ac3ab6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -10,6 +10,7 @@ import { import { FilesPanel, FilePreview } from './files-app.js'; import { VideoApp, bumpMediaMetaGeneration, bumpThumbGeneration } from './video-app.js'; import { MusicApp, bumpMusicMetaGeneration } from './music-app.js'; +import { PhotosApp } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; @@ -18,6 +19,7 @@ const MAX_POOL_SIZE = 3; const DEBOUNCE_MS = 200; const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; +const SEARCH_PHOTO_ROOTS = ['__search_photos__']; // -- Connection pool ---------------------------------------------------------- @@ -235,18 +237,15 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) return entry; }, [token, username, userId]); - // Eagerly connect to groups when entering Videos or Music view + // Pre-connect to all groups as soon as indexing finishes so thumbnails + // start loading before the user switches views. The pool evicts old + // connections but _thumbBlobCache keeps fetched thumbnails across evictions. useEffect(() => { - if (viewMode !== 'videos' && viewMode !== 'music') return; + if (fetching || indexedGroups.size === 0) return; let cancelled = false; (async () => { - const groupIds = [...indexedGroups.keys()].filter((gid) => { - const data = indexedGroups.get(gid); - if (viewMode === 'videos') return !!data.roots.videoRoot; - return !!data.roots.audioRoot; - }); - + const groupIds = [...indexedGroups.keys()]; for (let i = 0; i < groupIds.length; i += BATCH_SIZE) { if (cancelled) break; const batch = groupIds.slice(i, i + BATCH_SIZE); @@ -257,7 +256,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) })(); return () => { cancelled = true; }; - }, [viewMode, indexedGroups, connectGroup]); + }, [fetching, indexedGroups, connectGroup]); // -- Entry preparation -- @@ -364,6 +363,32 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) return result; }, [indexedGroups, q, matchesQuery, connectionGen]); + // Photos view: pre-filtered by photoRoots, path-prefixed + const photoEntries = useMemo(() => { + const result = []; + for (const [groupId, data] of indexedGroups) { + const roots = data.roots.photoRoots; + if (!roots || !roots.length) continue; + const conn = groupConns.current.get(groupId); + for (const e of data.entries) { + if (e.type !== 'image') continue; + const p = e.path || ''; + if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue; + if (q && !matchesQuery(e)) continue; + result.push({ + ...e, + path: '__search_photos__/' + e.path, + groupId, + groupName: data.groupName, + groupOwner: data.groupOwner, + _tRef: conn ? conn.tRef : null, + _gRef: conn ? conn.gRef : null, + }); + } + } + return result; + }, [indexedGroups, q, matchesQuery, connectionGen]); + // -- Callbacks -- const onPreview = useCallback(async (entry) => { @@ -417,7 +442,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) await downloadEntry(transfers, transport, modalGekRef.current, entry); }, []); - // No-op setters for FilesPanel (read-only mode) + const getTransport = useCallback(async (entry) => { + const conn = await connectGroup(entry.groupId); + return { transport: conn.transport, gek: conn.gek }; + }, [connectGroup]); + + // No-op setters for FilesPanel const noop = useCallback(() => {}, []); // -- Render -- @@ -456,6 +486,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) onClick=${() => setViewMode('music')} title=${t('search.view_music')}> <${Icon} name="music" /> +
`} @@ -497,7 +531,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) setError=${noop} onPreview=${onPreview} showGroup=${true} - readOnly=${true} /> + readOnly=${true} + getTransport=${getTransport} /> `} ${viewMode === 'videos' && hasResults && html` @@ -527,6 +562,19 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) hideFilter=${true} /> `} + ${viewMode === 'photos' && hasResults && html` + <${PhotosApp} + groupId="search" + transportRef=${defaultTRef} + gekRef=${defaultGRef} + status="connected" + entries=${photoEntries} + photoRoots=${SEARCH_PHOTO_ROOTS} + setError=${noop} + hideFilter=${true} + readOnly=${true} /> + `} + ${!hasResults && !fetching && html`

${t('search.hint')}

`} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index fbd1e70..b315512 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2862,7 +2862,8 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } white-space: nowrap; } .video-card-group, -.music-card-group { +.music-card-group, +.photo-card-group { font-size: 0.72em; color: var(--accent); margin-top: 2px; -- cgit v1.2.3