diff options
Diffstat (limited to 'packages/meshbay-hub')
17 files changed, 309 insertions, 48 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 0f28adf..988bcb1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -149,6 +149,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // destination rather than a set of folders it reads. const [chatDirectory, setChatDirectory] = useState(''); const [chatLinkPreview, setChatLinkPreview] = useState(true); + // Whether members' cross-group Search lists this group. Not an app setting: + // it is about the group as a whole, and it hides nothing from this page. + const [searchListed, setSearchListed] = useState(true); // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { @@ -401,6 +404,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, .map((k) => [k.slice(0, -'_directories'.length), ack[k] || []]))); setChatDirectory(ack.chat_directory || ''); setChatLinkPreview(ack.chat_link_preview !== false); + setSearchListed(ack.search_listed !== false); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, }); @@ -417,6 +421,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setAppDirectories((prev) => ({ ...prev, [app]: dirs })); transport.onChatDirectory = (path) => setChatDirectory(path); transport.onChatLinkPreview = (on) => setChatLinkPreview(on); + transport.onSearchListed = (listed) => setSearchListed(listed); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); transport.onRootsChanged = (msg) => { @@ -865,6 +870,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} + searchListed=${searchListed} entries=${entries} nodeDirs=${nodeDirs} appSettings=${appSettings} ${/* The saving pane already knows what it asked for; this is so diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 0f8ccdd..4d67348 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -399,6 +399,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, + searchListed, entries, nodeDirs, appSettings, onAppDirectories, onRefreshIndex, @@ -652,6 +653,27 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]); + // Whether members' cross-group Search lists this group. The switch follows + // the node's answer: the ack is broadcast and replayed to the requester + // (transport.js BROADCAST_ACK_TYPES), which is what moves `searchListed`. + const [listedBusy, setListedBusy] = useState(false); + const [listedMsg, setListedMsg] = useState(''); + const toggleSearchListed = useCallback(async (next) => { + const transport = transportRef && transportRef.current; + setListedMsg(''); + setListedBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + await transport.setSearchListed(next, adminSignFn); + } catch (err) { + setListedMsg(err.message); + } finally { + setListedBusy(false); + } + }, [transportRef, adminSignFn]); + // ── Per-app settings ──────────────────────────────────────────────── // // What every app's settings pane is given, and the one operation the page @@ -936,6 +958,23 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${appsMsg && html`<p class="error-msg">${appsMsg}</p>`} + ${/* Whether this group shows up in members' cross-group Search. A + listing preference, not a permission: the hint says so, because a + switch next to "members" and "devices" reads as access control. */ + isNodeAdmin && connected && html` + <${CollapsibleSection} titleKey="settings_node.search_listed_title" + defaultOpen=${false}> + <div class="settings-row"> + <${ToggleSwitch} checked=${searchListed !== false} + disabled=${listedBusy} + onChange=${toggleSearchListed} + label=${t('settings_node.search_listed_label')} /> + </div> + <p class="settings-hint">${t('settings_node.search_listed_hint')}</p> + ${listedMsg && html`<p class="error-msg">${listedMsg}</p>`} + </${CollapsibleSection}> + `} + ${/* How hard the node works watching its own disk — indexer.py DirectoryIndexer. A performance knob, not a permission: it changes nothing about who can see or do what. */ 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 8607011..4ad8b4b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -710,6 +710,7 @@ export default { 'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.', 'search.indexing': 'Indexierung von {done} / {total} Gruppen …', 'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' }, + 'search.unlisted': { one: '{n} Gruppe wird in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen', other: '{n} Gruppen werden in der Suche nicht angezeigt – öffnen Sie sie zum Durchsuchen' }, 'search.n_sources': { one: '{n} Quelle', other: '{n} Quellen' }, 'search.view_files': 'Dateien', 'search.view_videos': 'Videos', @@ -889,6 +890,9 @@ export default { 'settings_node.roots': 'Freigegebene Verzeichnisse', 'settings_node.scan_title': 'Scan', 'settings_node.scan_hint': 'Wie oft der Node seine Verzeichnisse erneut auf verpasste Änderungen prüft und wie lange er nach einer Änderung wartet, bevor eine Datei indiziert wird.', + 'settings_node.search_listed_title': 'Suche', + 'settings_node.search_listed_label': 'Diese Gruppe in der Suche anzeigen', + 'settings_node.search_listed_hint': 'Wenn deaktiviert, erscheinen die Dateien dieser Gruppe nicht in der gruppenübergreifenden Suche der Mitglieder. Mitglieder sehen weiterhin alles, wenn sie die Gruppe öffnen: Das nimmt sie nur aus der globalen Auflistung und verbirgt vor niemandem etwas.', 'settings_node.scan_reconcile_label': 'Prüfintervall (Minuten)', 'settings_node.scan_debounce_label': 'Wartezeit nach einer Änderung (Sekunden)', 'settings_node.scan_save': 'Speichern', 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 fee6cc3..43a0db0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -630,6 +630,9 @@ export default { 'settings_node.detach_failed_continue': 'Could not detach the group from the node. Delete on hub anyway?', 'settings_node.scan_title': 'Scanning', 'settings_node.scan_hint': 'How often the node re-checks its directories for changes it may have missed, and how long it waits after a file changes before indexing it.', + 'settings_node.search_listed_title': 'Search', + 'settings_node.search_listed_label': 'List this group in Search', + 'settings_node.search_listed_hint': 'When off, this group\'s files do not appear in members\' cross-group Search. Members still see everything by opening the group: this keeps it out of the global listing and hides nothing from anyone.', 'settings_node.scan_reconcile_label': 'Re-check interval (minutes)', 'settings_node.scan_debounce_label': 'Wait after a change (seconds)', 'settings_node.scan_save': 'Save', @@ -819,6 +822,7 @@ export default { 'search.hint': 'Type to search file names across all your groups.', 'search.indexing': 'Indexing {done} / {total} groups...', 'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' }, + 'search.unlisted': { one: '{n} group is not listed in Search — open it to browse', other: '{n} groups are not listed in Search — open them to browse' }, 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Files', 'search.view_videos': 'Videos', 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 b958da3..0df5a38 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -705,6 +705,7 @@ export default { 'search.hint': 'Escriba un término para buscar en todos sus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' }, + 'search.unlisted': { one: '{n} grupo no aparece en la búsqueda: ábrelo para explorarlo', other: '{n} grupos no aparecen en la búsqueda: ábrelos para explorarlos' }, 'search.n_sources': { one: '{n} fuente', other: '{n} fuentes' }, 'search.view_files': 'Archivos', 'search.view_videos': 'Vídeos', @@ -884,6 +885,9 @@ export default { 'settings_node.roots': 'Directorios compartidos', 'settings_node.scan_title': 'Escaneo', 'settings_node.scan_hint': 'Con qué frecuencia el node vuelve a comprobar sus directorios en busca de cambios que pudo haber pasado por alto, y cuánto tiempo espera tras un cambio antes de indexar un archivo.', + 'settings_node.search_listed_title': 'Búsqueda', + 'settings_node.search_listed_label': 'Mostrar este grupo en la búsqueda', + 'settings_node.search_listed_hint': 'Si está desactivado, los archivos de este grupo no aparecen en la búsqueda global de los miembros. Los miembros siguen viéndolo todo al abrir el grupo: solo lo saca del listado global y no oculta nada a nadie.', 'settings_node.scan_reconcile_label': 'Intervalo de comprobación (minutos)', 'settings_node.scan_debounce_label': 'Espera tras un cambio (segundos)', 'settings_node.scan_save': 'Guardar', 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 c993d9c..e7dcacd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -708,6 +708,7 @@ export default { 'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.', 'search.indexing': 'Indexation de {done} / {total} groupes…', 'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' }, + 'search.unlisted': { one: '{n} groupe n\'est pas affiché dans la recherche — ouvrez-le pour le parcourir', other: '{n} groupes ne sont pas affichés dans la recherche — ouvrez-les pour les parcourir' }, 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Fichiers', 'search.view_videos': 'Vidéos', @@ -902,6 +903,9 @@ export default { 'settings_node.roots': 'Répertoires partagés', 'settings_node.scan_title': 'Analyse', 'settings_node.scan_hint': 'À quelle fréquence le node revérifie ses répertoires à la recherche de changements manqués, et combien de temps il attend après une modification avant d\'indexer un fichier.', + 'settings_node.search_listed_title': 'Recherche', + 'settings_node.search_listed_label': 'Afficher ce groupe dans la recherche', + 'settings_node.search_listed_hint': 'Désactivé, les fichiers de ce groupe n\'apparaissent pas dans la recherche globale des membres. Les membres voient toujours tout en ouvrant le groupe : cela le retire du listage global, sans rien cacher à personne.', 'settings_node.scan_reconcile_label': 'Intervalle de revérification (minutes)', 'settings_node.scan_debounce_label': 'Attente après un changement (secondes)', 'settings_node.scan_save': 'Enregistrer', 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 3d4b502..26de960 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -707,6 +707,7 @@ export default { 'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.', 'search.indexing': 'Indicizzazione di {done} / {total} gruppi…', 'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' }, + 'search.unlisted': { one: '{n} gruppo non è mostrato nella ricerca: aprilo per sfogliarlo', other: '{n} gruppi non sono mostrati nella ricerca: aprili per sfogliarli' }, 'search.n_sources': { one: '{n} fonte', other: '{n} fonti' }, 'search.view_files': 'File', 'search.view_videos': 'Video', @@ -898,6 +899,9 @@ export default { 'settings_node.roots': 'Directory condivise', 'settings_node.scan_title': 'Scansione', 'settings_node.scan_hint': 'Con quale frequenza il node ricontrolla le sue directory per cambiamenti che potrebbe aver perso, e quanto tempo attende dopo una modifica prima di indicizzare un file.', + 'settings_node.search_listed_title': 'Ricerca', + 'settings_node.search_listed_label': 'Mostra questo gruppo nella ricerca', + 'settings_node.search_listed_hint': 'Se disattivato, i file di questo gruppo non compaiono nella ricerca globale dei membri. I membri vedono comunque tutto aprendo il gruppo: lo esclude solo dall\'elenco globale e non nasconde nulla a nessuno.', 'settings_node.scan_reconcile_label': 'Intervallo di ricontrollo (minuti)', 'settings_node.scan_debounce_label': 'Attesa dopo una modifica (secondi)', 'settings_node.scan_save': 'Salva', 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 3140527..4db42a1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -695,6 +695,7 @@ export default { 'search.hint': '入力してすべてのグループを検索します。', 'search.indexing': '{done} / {total} グループをインデックス中…', 'search.unreachable': { other: '{n} グループに接続できません' }, + 'search.unlisted': { other: '{n} グループは検索に表示されません — 開いて閲覧してください' }, 'search.n_sources': { other: '{n} 個のソース' }, 'search.view_files': 'ファイル', 'search.view_videos': '動画', @@ -882,6 +883,9 @@ export default { 'settings_node.roots': '共有ディレクトリ', 'settings_node.scan_title': 'スキャン', 'settings_node.scan_hint': 'node が見逃した変更がないかディレクトリを再確認する頻度と、ファイルの変更後にインデックスするまで待つ時間。', + 'settings_node.search_listed_title': '検索', + 'settings_node.search_listed_label': 'このグループを検索に表示する', + 'settings_node.search_listed_hint': 'オフにすると、このグループのファイルはメンバーのグループ横断検索に表示されません。メンバーはグループを開けば引き続きすべて見られます。全体の一覧から外すだけで、誰に対しても何も隠しません。', 'settings_node.scan_reconcile_label': '再確認の間隔(分)', 'settings_node.scan_debounce_label': '変更後の待機時間(秒)', 'settings_node.scan_save': '保存', 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 1e55c8c..a345e94 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -709,6 +709,7 @@ export default { 'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.', 'search.indexing': '{done} / {total} groepen indexeren…', 'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' }, + 'search.unlisted': { one: '{n} groep wordt niet getoond in Zoeken — open haar om te bladeren', other: '{n} groepen worden niet getoond in Zoeken — open ze om te bladeren' }, 'search.n_sources': { one: '{n} bron', other: '{n} bronnen' }, 'search.view_files': 'Bestanden', 'search.view_videos': 'Video\'s', @@ -900,6 +901,9 @@ export default { 'settings_node.roots': 'Gedeelde mappen', 'settings_node.scan_title': 'Scannen', 'settings_node.scan_hint': 'Hoe vaak de node zijn mappen opnieuw controleert op wijzigingen die zijn gemist, en hoe lang hij na een wijziging wacht voordat een bestand wordt geïndexeerd.', + 'settings_node.search_listed_title': 'Zoeken', + 'settings_node.search_listed_label': 'Deze groep tonen in Zoeken', + 'settings_node.search_listed_hint': 'Uitgeschakeld verschijnen de bestanden van deze groep niet in de groepsoverstijgende zoekfunctie van leden. Leden zien nog steeds alles door de groep te openen: dit haalt haar alleen uit de globale lijst en verbergt niets voor niemand.', 'settings_node.scan_reconcile_label': 'Controle-interval (minuten)', 'settings_node.scan_debounce_label': 'Wachttijd na een wijziging (seconden)', 'settings_node.scan_save': 'Opslaan', 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 8486718..665fd0b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -727,6 +727,7 @@ export default { 'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.', 'search.indexing': 'Indeksowanie {done} / {total} grup…', 'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' }, + 'search.unlisted': { one: '{n} grupa nie jest pokazywana w wyszukiwaniu — otwórz ją, aby przeglądać', few: '{n} grupy nie są pokazywane w wyszukiwaniu — otwórz je, aby przeglądać', many: '{n} grup nie jest pokazywanych w wyszukiwaniu — otwórz je, aby przeglądać', other: '{n} grupy nie jest pokazywane w wyszukiwaniu — otwórz je, aby przeglądać' }, 'search.n_sources': { one: '{n} źródło', few: '{n} źródła', many: '{n} źródeł', other: '{n} źródła' }, 'search.view_files': 'Pliki', 'search.view_videos': 'Filmy', @@ -926,6 +927,9 @@ export default { 'settings_node.roots': 'Katalogi współdzielone', 'settings_node.scan_title': 'Skanowanie', 'settings_node.scan_hint': 'Jak często node ponownie sprawdza swoje katalogi w poszukiwaniu pominiętych zmian oraz jak długo czeka po zmianie pliku przed jego zindeksowaniem.', + 'settings_node.search_listed_title': 'Wyszukiwanie', + 'settings_node.search_listed_label': 'Pokazuj tę grupę w wyszukiwaniu', + 'settings_node.search_listed_hint': 'Po wyłączeniu pliki tej grupy nie pojawiają się w globalnym wyszukiwaniu członków. Członkowie nadal widzą wszystko po otwarciu grupy: to tylko usuwa ją z globalnej listy i niczego przed nikim nie ukrywa.', 'settings_node.scan_reconcile_label': 'Interwał sprawdzania (minuty)', 'settings_node.scan_debounce_label': 'Oczekiwanie po zmianie (sekundy)', 'settings_node.scan_save': 'Zapisz', 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 e51df81..a85d96e 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 @@ -706,6 +706,7 @@ export default { 'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' }, + 'search.unlisted': { one: '{n} grupo não aparece na busca — abra-o para navegar', other: '{n} grupos não aparecem na busca — abra-os para navegar' }, 'search.n_sources': { one: '{n} fonte', other: '{n} fontes' }, 'search.view_files': 'Arquivos', 'search.view_videos': 'Vídeos', @@ -885,6 +886,9 @@ export default { 'settings_node.roots': 'Diretórios compartilhados', 'settings_node.scan_title': 'Varredura', 'settings_node.scan_hint': 'Com que frequência o node reverifica seus diretórios em busca de mudanças que possa ter perdido, e quanto tempo espera após uma mudança antes de indexar um arquivo.', + 'settings_node.search_listed_title': 'Busca', + 'settings_node.search_listed_label': 'Mostrar este grupo na busca', + 'settings_node.search_listed_hint': 'Desativado, os arquivos deste grupo não aparecem na busca global dos membros. Os membros continuam vendo tudo ao abrir o grupo: isso só o retira da listagem global e não esconde nada de ninguém.', 'settings_node.scan_reconcile_label': 'Intervalo de reverificação (minutos)', 'settings_node.scan_debounce_label': 'Espera após uma mudança (segundos)', 'settings_node.scan_save': 'Salvar', 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 cc22a70..3e7c1c9 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 @@ -682,6 +682,7 @@ export default { 'search.hint': '输入关键词搜索所有群组。', 'search.indexing': '正在索引 {done} / {total} 个群组…', 'search.unreachable': { other: '{n} 个群组无法连接' }, + 'search.unlisted': { other: '{n} 个群组未在搜索中显示——打开群组即可浏览' }, 'search.n_sources': { other: '{n} 个来源' }, 'search.view_files': '文件', 'search.view_videos': '视频', @@ -869,6 +870,9 @@ export default { 'settings_node.roots': '共享目录', 'settings_node.scan_title': '扫描', 'settings_node.scan_hint': 'node 多久重新检查一次目录以发现可能错过的变化,以及文件变化后等待多久才建立索引。', + 'settings_node.search_listed_title': '搜索', + 'settings_node.search_listed_label': '在搜索中显示此群组', + 'settings_node.search_listed_hint': '关闭后,此群组的文件不会出现在成员的跨群组搜索中。成员打开群组后仍可看到全部内容:这只是将其移出全局列表,不对任何人隐藏任何内容。', 'settings_node.scan_reconcile_label': '重新检查间隔(分钟)', 'settings_node.scan_debounce_label': '变化后的等待时间(秒)', 'settings_node.scan_save': '保存', 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 ee99ccc..a77e8c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -32,6 +32,51 @@ const SEARCH_VIDEO_ROOT = '__search__'; const SEARCH_AUDIO_ROOT = '__search__'; const SEARCH_PHOTO_ROOTS = ['__search_photos__']; +// -- Connecting to a group ---------------------------------------------------- + +/** + * A live connection to one of the nodes serving `groupId`, and its ack — or + * null when the hub lists no node at all. + * + * Every node the hub lists, in turn, exactly as group-page.js does since + * 2026-09-11: the list is in hub registration order, and its head is not + * necessarily a node that answers. Search took `nodes[0]` and stopped, so a + * group with a second, working node counted as unreachable here while it + * opened fine from the sidebar. A refusal naming a state of this browser (a + * code, a passphrase, a device) is the same from every node and stops the + * walk; `not_hosted`, a timeout or a failed connection moves on. + */ +async function connectToGroup(hubBase, groupId, token, bundleKey, username, userId) { + const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); + if (!nodesData.nodes || !nodesData.nodes.length) return null; + + const live = (await ensureFreshToken()) || token; + let lastErr = null; + for (const n of nodesData.nodes) { + const transport = new window.MeshBayTransport(hubBase, live); + transport.onNeedToken = async () => (await ensureFreshToken()) || token; + let timer; + try { + const ack = await Promise.race([ + transport.connect( + n.node_id, live, groupId, null, null, bundleKey, + username, userId, null), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); + }), + ]); + clearTimeout(timer); + return { transport, ack }; + } catch (e) { + clearTimeout(timer); + lastErr = e; + try { transport.close(); } catch {} + if (e.reason && e.reason !== 'not_hosted') throw e; + } + } + throw (lastErr || new Error('no node served this group')); +} + // -- Connection pool ---------------------------------------------------------- class ConnectionPool { @@ -66,29 +111,10 @@ class ConnectionPool { } async _doConnect(groupId, token, bundleKey, username, userId) { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (!nodesData.nodes || !nodesData.nodes.length) throw new Error('offline'); - - const live = (await ensureFreshToken()) || token; - const transport = new window.MeshBayTransport(this._hubBase, live); - transport.onNeedToken = async () => (await ensureFreshToken()) || token; - - let timer; - try { - await Promise.race([ - transport.connect( - nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, - username, userId, null), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); - }), - ]); - clearTimeout(timer); - } catch (e) { - clearTimeout(timer); - try { transport.close(); } catch {} - throw e; - } + const found = await connectToGroup( + this._hubBase, groupId, token, bundleKey, username, userId); + if (!found) throw new Error('offline'); + const { transport } = found; let gek = null; if (transport.gekRaw && window.MeshBayCrypto) { @@ -128,24 +154,17 @@ class ConnectionPool { // -- Index fetching ----------------------------------------------------------- async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { - const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); - if (!nodesData.nodes || !nodesData.nodes.length) return null; - - const live = (await ensureFreshToken()) || token; - const transport = new window.MeshBayTransport(HUB, live); - transport.onNeedToken = async () => (await ensureFreshToken()) || token; + const found = await connectToGroup(HUB, groupId, token, bundleKey, username, userId); + if (!found) return null; + const { transport, ack } = found; try { - let timer; - const ack = await Promise.race([ - transport.connect( - nodesData.nodes[0].node_id, live, groupId, null, null, bundleKey, - username, userId, null), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('Connection timeout')), SEARCH_TIMEOUT); - }), - ]); - clearTimeout(timer); + // The operator asked for this group to stay out of the global listing. + // Decided here, before the index is asked for, so nothing of it is held, + // cached or merged by this page. A listing preference and not a boundary: + // the node cannot tell this request from the group page's, and opening the + // group lists everything. + if (ack.search_listed === false) return { unlisted: true }; const indexMsg = await transport.fetchIndex(); // Plural, with the old scalars as the fallback for a node still speaking @@ -174,6 +193,7 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa const total = groups.length; let done = 0; const unreachable = []; + const unlisted = []; const results = new Map(); for (let i = 0; i < groups.length; i += BATCH_SIZE) { @@ -181,7 +201,9 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa await Promise.all(batch.map(async (g) => { try { const result = await fetchGroupIndex(g.id, token, bundleKey, username, userId); - if (result) { + if (result && result.unlisted) { + unlisted.push(g.name || g.id); + } else if (result) { results.set(g.id, { ...result, groupName: g.name, @@ -195,11 +217,11 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa unreachable.push(g.name || g.id); } done++; - onProgress({ done, total, unreachable: [...unreachable] }); + onProgress({ done, total, unreachable: [...unreachable], unlisted: [...unlisted] }); })); onBatch(new Map(results)); } - return { results, unreachable }; + return { results, unreachable, unlisted }; } // -- SearchPage --------------------------------------------------------------- @@ -281,7 +303,8 @@ function photoUnits(entries) { function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) { const [indexedGroups, setIndexedGroups] = useState(new Map()); - const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); + const [progress, setProgress] = useState( + { done: 0, total: 0, unreachable: [], unlisted: [] }); const [fetching, setFetching] = useState(false); // Bumped by the Files breadcrumb refresh button — re-runs the all-groups // index fetch below, the only "cache" this page has. @@ -333,7 +356,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) (async () => { setFetching(true); - setProgress({ done: 0, total: groups.length, unreachable: [] }); + setProgress({ done: 0, total: groups.length, unreachable: [], unlisted: [] }); await fetchAllIndexes( groups, token, username, userId, @@ -718,6 +741,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) </p> `} + ${!fetching && progress.unlisted.length > 0 && html` + <p class="search-unreachable"> + ${t('search.unlisted', { n: progress.unlisted.length })} + </p> + `} + ${viewMode === 'files' && hasResults && html` <${FilesPanel} groupId="search" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 9de5407..1f05b8d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -96,7 +96,7 @@ const BROADCAST_ACK_TYPES = new Set([ 'root_update_ack', 'root_eject_ack', 'root_plug_ack', 'root_add_ack', 'root_remove_ack', 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack', - 'chat_epoch_ack', + 'chat_epoch_ack', 'search_listed_ack', ]); /** Hand a broadcast ack to the callback that would have had it from a peer. */ @@ -107,6 +107,8 @@ function _replayBroadcast(transport, msg) { transport._onChatDirectory(msg.path || ''); } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) { transport._onChatLinkPreview(Boolean(msg.enabled)); + } else if (msg.type === 'search_listed_ack' && transport._onSearchListed) { + transport._onSearchListed(msg.listed !== false); } else if (msg.type === 'chat_epoch_ack') { transport._applyChatEpoch(msg); } else if (transport._onRootsChanged) { @@ -120,7 +122,7 @@ const ADMIN_OP_TYPES = new Set([ 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug', 'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch', - 'member_unpin', 'gek_rotate', 'group_attach', + 'search_listed', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', ]); @@ -533,6 +535,7 @@ class MeshBayTransport { set onAppDirectories(fn) { this._onAppDirectories = fn; } set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } + set onSearchListed(fn) { this._onSearchListed = fn; } set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } @@ -1526,6 +1529,22 @@ class MeshBayTransport { } /** + * Whether this group's files appear in members' cross-group Search. + * Presentation only — opening the group lists everything regardless. + */ + async setSearchListed(listed, signFn) { + const msg = await this._sendAndWait({ + type: 'search_listed', v: '3.0', listed: Boolean(listed), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp( + msg, 'search_listed', listed ? 'on' : 'off', signFn); + } + return msg; + } + + /** * Open a new chat epoch by hand. Operator only, and signed. * * Not a switch — there is nothing to turn on. The removals that matter open @@ -3097,6 +3116,9 @@ class MeshBayTransport { if (msg.type === 'chat_link_preview_ack' && this._onChatLinkPreview) { this._onChatLinkPreview(Boolean(msg.enabled)); } + if (msg.type === 'search_listed_ack' && this._onSearchListed) { + this._onSearchListed(msg.listed !== false); + } if (msg.type === 'chat_epoch_ack') { this._applyChatEpoch(msg); return; diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py index 940d975..027fee6 100644 --- a/packages/meshbay-hub/tests/test_app_settings_plugin.py +++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py @@ -224,8 +224,9 @@ def test_the_page_performs_exactly_one_app_specific_operation(): "GroupSettingsPanel") calls = set(re.findall(r"transport\.(set\w+)\(", panel)) # The page's own settings, which belong to no app: which apps are enabled - # at all, and how hard the node works watching its disk. - page_level = {"setAppsEnabled", "setScanSettings"} + # at all, how hard the node works watching its disk, and whether members' + # cross-group Search lists the group. + page_level = {"setAppsEnabled", "setScanSettings", "setSearchListed"} # One generic operation, keyed by the app's own name: adding an app adds # no message type and no call site here. generic = {"setAppDirectories"} diff --git a/packages/meshbay-hub/tests/test_search_unlisted.py b/packages/meshbay-hub/tests/test_search_unlisted.py new file mode 100644 index 0000000..4ffec32 --- /dev/null +++ b/packages/meshbay-hub/tests/test_search_unlisted.py @@ -0,0 +1,96 @@ +""" +A group the operator left out of Search is not listed there, in any view. + +`search_listed` is a presentation preference, not an access control (design +§9.11): the node serves the same index to Search and to the group page. What +this file holds is the client half — that Search stops at the handshake ack for +such a group, so nothing of its index is fetched, cached, merged or shown — and +that the switch in Settings reaches the page that renders it. + +Source-reading, like the other Search tests: weak evidence, and the only kind +available for the SPA. Each check is a one-line edit away from failing, which is +what a source-reading test catches well. +""" + +import re +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +SEARCH_PAGE = STATIC / "search-page.js" +TRANSPORT = STATIC / "transport.js" +GROUP_PAGE = STATIC / "group-page.js" +GROUP_SETTINGS = STATIC / "group-settings.js" + +pytestmark = pytest.mark.skipif( + not SEARCH_PAGE.exists(), reason="the SPA sources are not available") + + +def _function(src: str, name: str) -> str: + m = re.search(r"^async function " + re.escape(name) + r"\(.*?^\}", src, re.M | re.S) + assert m, f"{name} is no longer where this test reads it" + return m.group(0) + + +def test_search_stops_before_asking_for_the_index(): + body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchGroupIndex") + stop = body.find("ack.search_listed === false") + fetch = body.find("transport.fetchIndex()") + assert stop != -1, "fetchGroupIndex no longer reads search_listed off the ack" + assert fetch != -1 + assert stop < fetch, ( + "the unlisted check comes after the index is fetched — the index of a " + "group the operator left out of Search is then held by the page") + + +def test_an_unlisted_group_is_neither_indexed_nor_cached_nor_unreachable(): + body = _function(SEARCH_PAGE.read_text(encoding="utf-8"), "fetchAllIndexes") + branch = body[body.index("result.unlisted"):] + branch = branch[:branch.index("} else if (result)")] + assert "unlisted.push" in branch + for forbidden in ("results.set", "cacheGroupIndex", "unreachable.push"): + assert forbidden not in branch, ( + f"an unlisted group reaches `{forbidden}` — it would be shown, " + "cached, or reported as down") + + +def test_every_search_view_is_built_from_the_indexed_groups_only(): + """ + The four views read `indexedGroups`, which only `fetchAllIndexes`'s results + fill. If a view ever read the group list directly, an unlisted group would + come back through it. + """ + src = SEARCH_PAGE.read_text(encoding="utf-8") + for memo in ("fileEntries", "videoEntries", "musicEntries", "photoEntries"): + m = re.search(r"^ const " + memo + r" = useMemo\(\(\) => \{.*?^ \}, \[", + src, re.M | re.S) + assert m, f"{memo} is no longer a useMemo" + assert "for (const [groupId, data] of indexedGroups)" in m.group(0) + assert "groups" not in m.group(0).replace("indexedGroups", ""), ( + f"{memo} reads the raw group list") + + +def test_the_operator_hears_back_from_their_own_change(): + """Same failure as Chat's directory: an admin ack swallowed by its request.""" + transport = TRANSPORT.read_text(encoding="utf-8") + block = transport[transport.index("const BROADCAST_ACK_TYPES"):] + assert "'search_listed_ack'" in block[:block.index("]);")] + replay = transport[transport.index("function _replayBroadcast"):] + replay = replay[:replay.index("\n}") + 2] + assert "_onSearchListed" in replay + + page = GROUP_PAGE.read_text(encoding="utf-8") + assert "transport.onSearchListed = " in page + assert "setSearchListed(ack.search_listed !== false)" in page, ( + "absent must read as listed, or every group on an older node vanishes " + "from Search") + + +def test_the_switch_is_offered_to_the_operator_only(): + settings = GROUP_SETTINGS.read_text(encoding="utf-8") + at = settings.index("settings_node.search_listed_title") + guard = settings.rfind("isNodeAdmin && connected", 0, at) + assert guard != -1 and at - guard < 600, ( + "the Search listing switch is rendered outside the operator's section") + assert "transport.setSearchListed(next, adminSignFn)" in settings diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index c506ff1..ab942e4 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -506,3 +506,31 @@ def test_the_refusal_the_loop_keys_on_has_a_message(transport): refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):] refusals = refusals[:refusals.index("};")] assert "not_hosted:" in refusals + + +# Search had the same `nodes[0]` twice — once to read a group's index, once for +# the pooled connection its thumbnails and playback use — and was not part of +# the fix above, so a group with a second, working node counted as unreachable +# there while it opened fine from the sidebar. + +SEARCH_PAGE = STATIC / "search-page.js" + + +def test_search_tries_every_node_the_hub_offers(): + code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8")) + assert "nodes[0]" not in code, "Search takes the head of the node list again" + walk = code[code.index("for (const n of nodesData.nodes)"):] + walk = walk[:walk.index("throw (lastErr")] + assert "not_hosted" in walk and "throw e" in walk, ( + "the walk must stop for a refusal about this browser and move on " + "for one about this node") + + +def test_search_connects_in_one_place(): + """Two call sites with their own connect is how one of them kept `nodes[0]`.""" + code = _code_only(SEARCH_PAGE.read_text(encoding="utf-8")) + assert code.count("transport.connect(") == 1 + pool = code[code.index("async _doConnect("):code.index("_evict() {")] + index = code[code.index("async function fetchGroupIndex("): + code.index("async function fetchAllIndexes(")] + assert "connectToGroup(" in pool and "connectToGroup(" in index |