diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
| commit | 2916aa376009283305a7acec4aafa3c96544499e (patch) | |
| tree | bc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | 25f62e169e049f0757ef611d5b409e7523958dee (diff) | |
| download | meshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz | |
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 61 |
1 files changed, 60 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 38314e7..92dcb28 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -20,7 +20,9 @@ import { GroupPage } from './group-page.js'; import { SearchPage, ConnectionPool } from './search-page.js'; import { MusicPlayerBar } from './music-player.js'; import { NameModal } from './playlist-menu.js'; -import { saveQueueAsPlaylist } from './playlists.js'; +import { + saveQueueAsPlaylist, setPlaylistTransport, pullOnce, flushPush, +} from './playlists.js'; import { IndexingDock } from './index-dock.js'; import { SettingsPage } from './settings-page.js'; import { ProfilePage } from './profile-page.js'; @@ -752,6 +754,63 @@ function App() { const handleStopMusic = useCallback(() => setMusicQueue(null), []); + // ── Playlists reach a node through here ──────────────────────────────── + // + // `playlists.js` pushes on every write and needs a transport to push + // through; the shell is what owns connections. Preferring the group already + // open costs nothing, and dialing when there is none is a change of position + // that is worth stating: §7 refused dialing *at sign-in for a feature nobody + // had asked to use*. Somebody who has just made a playlist has asked, and one + // dial to save their work is cheaper than losing it. + useEffect(() => { + if (!user) { setPlaylistTransport(null); return undefined; } + setPlaylistTransport(async () => { + const gt = groupTransportRef.current; + const live = gt && gt.transportRef && gt.transportRef.current; + if (live && live.connected) return live; + // Bounded: an account with twenty groups, all offline, must not spend + // twenty ten-second timeouts on a background push. + for (const g of groups.slice(0, 3)) { + try { + const conn = await getMusicConnection(g.id); + if (conn && conn.transport && conn.transport.connected) return conn.transport; + } catch { /* try the next one */ } + } + return null; + }); + // A pending push has a second and a half to wait, and closing a laptop + // inside that window is not rare. `pagehide` covers a tab closing and a + // navigation; `visibilitychange` covers a phone being backgrounded, which + // on iOS is the only one of the two that reliably fires at all. + const flush = () => { flushPush().catch(() => {}); }; + const onHidden = () => { if (document.visibilityState === 'hidden') flush(); }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', onHidden); + + return () => { + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', onHidden); + setPlaylistTransport(null); + }; + }, [user, groups, getMusicConnection]); + + // Reconcile once per sign-in, whatever this browser already holds. + // + // Nothing went looking until a group's Music tab happened to be opened, which + // is not where anybody looks for a playlist — and bounding this to an empty + // device, as the first version did, left out the ordinary case: a phone that + // has nine playlists and is missing the tenth. + // + // `groups.length` moves as the sidebar fills, so the ref is what makes "once" + // mean once rather than once per group that arrives. + const pulledForRef = useRef(null); + useEffect(() => { + if (!user || !groups.length) return; + if (pulledForRef.current === user.userId) return; + pulledForRef.current = user.userId; + pullOnce(user.userId).catch(() => {}); + }, [user, groups.length]); + // Saving the queue is a shell-level action because the queue is: the player // bar outlives every page, and the account it belongs to is here. const [saveQueue, setSaveQueue] = useState(null); |