aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
commit2916aa376009283305a7acec4aafa3c96544499e (patch)
treebc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent25f62e169e049f0757ef611d5b409e7523958dee (diff)
downloadmeshbay-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.js61
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);