diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
| commit | 2c0903c648e24b4e2adf20492398e8b67d033b49 (patch) | |
| tree | 0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/src/meshbay_hub/static/group-page.js | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| parent | eeda274d751c537f4ecef3087994a16a9517478f (diff) | |
| download | meshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz | |
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3.
The root model replaces the old `upload` flag and group-wide `member_upload`
with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that
both front doors — the loopback API and signed MNP — reach through the same
`ops` functions. MNP goes to 1.1, additively: the roots table now rides on
`index_delta`, so a root added, removed, ejected or plugged reaches every
connected client instead of only whoever reloaded.
The group UI becomes a plugin architecture: an application is a registry
entry in `apps.js` plus its own files, with directories stored generically
by `ops.set_app_directories` under whatever the app is called. A reference
application, hidden behind `?dev=1`, is what makes that claim testable —
adding it is what found the two places still naming apps by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 199 |
1 files changed, 154 insertions, 45 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 466af53..6f41426 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -1,5 +1,5 @@ import { - html, useState, useEffect, useCallback, useRef, + html, useState, useEffect, useCallback, useRef, useMemo, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; @@ -9,7 +9,7 @@ import { HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; -import { visibleApps } from './apps.js'; +import { APPS, visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; import { FilePreview } from './files-app.js'; import { VideoPlayer } from './video-player.js'; @@ -88,9 +88,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [nodeRoots, setNodeRoots] = useState([]); const [isNodeAdmin, setIsNodeAdmin] = useState(false); - // Whether ordinary members may upload here. The node decides and enforces it; - // this only says whether to offer the controls. Defaults to true so a node - // that predates the setting behaves as it always did. + // Legacy: the group-wide upload switch a node speaking MNP 1.0 sends on its + // handshake ack. Per-root `writable` replaced it, and this is read only when + // the roots carry no flags at all — see `attachRoot` below. Defaults to true + // so such a node behaves as it always did. const [memberUpload, setMemberUpload] = useState(true); // Which applications this group has enabled, from the node. Falls back to // every registered app when a node predates the setting (or hasn't answered @@ -127,15 +128,22 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // TMDB on/off + whether a custom token is set, node-wide (not per-group) — // docs/mediacenter.md §5.5. Null until the handshake ack arrives. const [tmdbConfig, setTmdbConfig] = useState(null); - // Which folder is the Videos app's entry point for this group — '' - // (the default) means the whole group index. Set from Files, per-group. - const [videoRoot, setVideoRoot] = useState(''); - // Same shape — the Music app's own entry point. - const [audioRoot, setAudioRoot] = useState(''); - // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot - // above (docs/photos.md §2.1: a photo library is routinely scattered - // across several folders). Empty means nothing configured yet. - const [photoRoots, setPhotoRoots] = useState([]); + // Which folders each app works over. One shape for all of them — a list, + // always, even where an app only wants one (docs/refactor-groups.md §1.6): + // Videos and Music were single values, which meant a library spread over two + // drives could not be described at all. Empty means nothing configured yet, + // which every app reads as "show nothing", never "the whole group index". + const [appDirectories, setAppDirectories] = useState({}); + const appDirs = useCallback( + (key) => appDirectories[key] || [], [appDirectories]); + // Where chat attachments are written — one directory, because Chat has one + // destination rather than a set of folders it reads. + const [chatDirectory, setChatDirectory] = useState(''); + const [chatLinkPreview, setChatLinkPreview] = useState(true); + // Whether this node speaks the operations MNP 1.1 added. False for one that + // predates them, and the Settings page then offers what that node can + // actually do rather than controls whose messages it drops unanswered. + const [nodeSupportsAppOps, setNodeSupportsAppOps] = useState(false); // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { @@ -225,8 +233,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (indexMsg.roots) setNodeRoots(indexMsg.roots); cacheGroupIndex(groupId, group ? group.name : groupId, group ? group.owner_username : null, fresh, - { videoRoot, audioRoot, photoRoots }); - }, [groupId, group, videoRoot, audioRoot, photoRoots]); + { video: appDirs('video'), music: appDirs('music'), + photo: appDirs('photo') }); + }, [groupId, group, appDirs]); // additions/deletions/updates (daemon.py _broadcast_index_change, once // there is a previous snapshot to diff against) — applied on top of @@ -235,6 +244,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // enrichment (duration/thumb_hash/display_title/...) arriving for a file // already in the table — same id, new fields (see group_index.py diff()). const applyIndexDelta = useCallback((deltaMsg) => { + // The roots table rides on the delta as of MNP 1.1. Before that it + // travelled only on a full index_sync, which is sent on request — so a + // root added, removed, ejected or plugged by anyone left every other + // client's directory table stale until they reloaded the page. + if (Array.isArray(deltaMsg.roots) && deltaMsg.roots.length) { + setNodeRoots(deltaMsg.roots); + } setEntries((prev) => { const deletions = new Set(deltaMsg.deletions || []); const kept = prev.filter((e) => !deletions.has(e.id)); @@ -248,10 +264,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const fresh = updated.concat(additions); cacheGroupIndex(groupId, group ? group.name : groupId, group ? group.owner_username : null, fresh, - { videoRoot, audioRoot, photoRoots }); + { video: appDirs('video'), music: appDirs('music'), + photo: appDirs('photo') }); return fresh; }); - }, [groupId, group, videoRoot, audioRoot, photoRoots]); + }, [groupId, group, appDirs]); useEffect(() => { let cancelled = false; @@ -325,9 +342,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, tokenCustomized: !!ack.tmdb_token_customized, language: ack.tmdb_language || '', }); - setVideoRoot(ack.video_root || ''); - setAudioRoot(ack.audio_root || ''); - setPhotoRoots(ack.photo_roots || []); + // The plural form when the node speaks it, the old scalars when it + // does not — an MNP 1.0 node sends only the latter, and reading its + // missing `video_directories` as "nothing configured" would empty a + // working Videos tab. + setAppDirectories({ + video: ack.video_directories + || (ack.video_root ? [ack.video_root] : []), + music: ack.music_directories + || (ack.audio_root ? [ack.audio_root] : []), + photo: ack.photo_directories || ack.photo_roots || [], + }); + setNodeSupportsAppOps(transport.supportsAppOps); + setChatDirectory(ack.chat_directory || ''); + setChatLinkPreview(ack.chat_link_preview !== false); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, }); @@ -343,11 +371,29 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // recent value. transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled })); - transport.onVideoRoot = (path) => setVideoRoot(path); - transport.onAudioRoot = (path) => setAudioRoot(path); - transport.onPhotoRoots = (roots) => setPhotoRoots(roots); + // One handler for every app's directories, plus the three older + // per-app messages a node that predates the generic op still sends. + transport.onAppDirectories = (app, dirs) => + setAppDirectories((prev) => ({ ...prev, [app]: dirs })); + transport.onVideoRoot = (path) => setAppDirectories( + (prev) => ({ ...prev, video: path ? [path] : [] })); + transport.onAudioRoot = (path) => setAppDirectories( + (prev) => ({ ...prev, music: path ? [path] : [] })); + transport.onPhotoRoots = (roots) => setAppDirectories( + (prev) => ({ ...prev, photo: roots || [] })); + transport.onChatDirectory = (path) => setChatDirectory(path); + transport.onChatLinkPreview = (on) => setChatLinkPreview(on); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); + transport.onRootsChanged = (msg) => { + // `msg.roots &&` would accept `[]`, and an empty array is truthy — + // so a node that could not describe its roots would blank the + // operator's table on an op that actually succeeded. A group always + // has at least one root, so nothing legitimate is dropped here. + if (Array.isArray(msg.roots) && msg.roots.length) { + setNodeRoots(msg.roots); + } + }; // The node's own scan (a root added while we were already connected, // or reconcile catching one back up) — never the entries, just // enough to animate the sidebar dot. Guaranteed a final push at the @@ -535,9 +581,35 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } }, [groupId, token, descDraft, onGroupUpdated]); - // Asked in two places — the Files toolbar and the chat composer — so it is - // answered once. The operator is never locked out of their own node. - const mayUpload = memberUpload || isNodeAdmin; + // Where an attachment goes, answered once for the whole page. + // + // Files does not use this — it uploads into the root being browsed, which is + // the only unambiguous answer once a group can have several writable roots. + // Chat has no folder to browse, so it needs one picked for it, and this is + // the same rule the node applies when a client names no root at all. It + // becomes an operator-chosen directory in phase 2 (refactor-groups.md §1.7). + // + // `memberUpload` is the fallback for a node still speaking MNP 1.0, whose + // roots carry no `writable` at all: there, the single upload root is the one + // the node marked, and the ack's computed flag is all we get. + const writableRoots = useMemo( + () => nodeRoots.filter((r) => r.writable && r.available !== false), + [nodeRoots]); + const legacyNode = nodeRoots.length > 0 + && nodeRoots.every((r) => r.writable === undefined); + // The operator's chosen attachment folder wins where there is one — that is + // what the Chat settings pane is for. Its root has to be writable and + // present, or the choice is stale (they made it read-only, or ejected the + // drive) and the fallback is better than a refusal at send time. + const chatDirRoot = chatDirectory ? chatDirectory.split('/')[0] : ''; + const chatDirUsable = Boolean( + chatDirRoot && writableRoots.some((r) => r.name === chatDirRoot)); + const attachDir = chatDirUsable ? chatDirectory : ''; + const attachRoot = chatDirUsable ? chatDirRoot + : writableRoots.length ? writableRoots[0].name + : (legacyNode && memberUpload + ? (nodeRoots.find((r) => r.upload) || nodeRoots[0]).name + : ''); // A single dispatcher so any app can open the right modal without owning // video/preview state itself — Files' table and Chat's attachments both @@ -566,14 +638,55 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setPreviewEntry(entry); }, [entries, onPlayQueue, onStopMusic]); + const unavailRoots = useMemo(() => { + const s = new Set(); + for (const r of nodeRoots) if (!r.available) s.add(r.name); + return s; + }, [nodeRoots]); + const availableEntries = useMemo( + () => entries.filter((e) => { + const root = (e.path || '').split('/')[0]; + return !root || !unavailRoots.has(root); + }), [entries, unavailRoots]); + + // Everything the operator settings panes read, in one object. Built here + // because this is where the state already lives, and passed through + // `group-settings.js` untouched — that page renders the panes without + // knowing what any of them is for, which is what makes adding an app a + // registry entry rather than an edit to the page. + // `<key>Directories` for every registered app, derived from the registry + // rather than written out. Naming them here would mean adding an app + // required editing this file, which is the one thing the plugin + // architecture is supposed to have removed — and the reference app + // (docs/refactor-groups.md §4.1) is what made the difference visible. + const perAppDirectories = useMemo(() => { + const out = {}; + for (const app of APPS) out[`${app.key}Directories`] = appDirs(app.key); + return out; + }, [appDirs]); + + const appSettings = useMemo(() => ({ + ...perAppDirectories, + chatDirectory, + chatLinkPreview, + tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true, + tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '', + tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized), + musicbrainzEnabled: musicbrainzConfig + ? musicbrainzConfig.enabled !== false : true, + }), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig, + musicbrainzConfig]); + const commonProps = { groupId, transportRef, gekRef, status, username, - entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, - isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, + entries, availableEntries, nodeDirs, nodeRoots, + setEntries, setNodeDirs, setNodeRoots, applyIndex, + isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, - videoRoot, onVideoRoot: (path) => setVideoRoot(path), - audioRoot, onAudioRoot: (path) => setAudioRoot(path), - photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), + // Plural everywhere: Videos and Music read a list now, and Photos always + // did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire, + // for a node that speaks MNP 1.0 — nothing in the client carries them. + ...perAppDirectories, tmdbConfig, musicbrainzConfig, onPlayQueue, }; @@ -699,24 +812,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin} userId=${userId} operatorPaired=${operatorPaired} connected=${status === 'connected'} - memberUpload=${memberUpload} - onMemberUpload=${(allowed) => setMemberUpload(allowed)} + mnpRoots=${nodeRoots} enabledApps=${enabledApps} onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} - tmdbConfig=${tmdbConfig} - onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))} - onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))} - musicbrainzConfig=${musicbrainzConfig} - onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))} entries=${entries} nodeDirs=${nodeDirs} - videoRoot=${videoRoot} - onVideoRoot=${(path) => setVideoRoot(path)} - audioRoot=${audioRoot} - onAudioRoot=${(path) => setAudioRoot(path)} - photoRoots=${photoRoots} - onPhotoRoots=${(roots) => setPhotoRoots(roots)} + appSettings=${appSettings} + nodeSupportsAppOps=${nodeSupportsAppOps} + ${/* The saving pane already knows what it asked for; this is so + the page's own copy moves at the same time, rather than + waiting for the ack it will not be handed (transport.js + resolves an admin ack against the pending request). */''} + onAppDirectories=${(app, dirs) => + setAppDirectories((prev) => ({ ...prev, [app]: dirs }))} onRefreshIndex=${refreshIndex} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> |