openvibers/openvibe.network Commits

The site switcher's pinned and recent sites follow the account (#430)

OP OpenVibers committed 14 days ago · 4eddb97d2d · 5 files, +60 −2

GET/PUT /api/themes/me/sites and POST /api/themes/me/sites/visit (expand
migration 0029: user_preferences.site_prefs). The switcher behind the
OpenVibe mark (openvibe-shared 4.48.0) shows the same Recent and Pinned on
every site: ids only, at most 12 pinned and 8 recent, newest first. The
navbar records a visit for a signed-in person at most every 30 minutes and
never under Global Privacy Control or Do Not Track.

Changes

ddiff --git a/migrations/0029_site_prefs.sql b/migrations/0029_site_prefs.sql
nnew file mode 100644
iindex 00000000..92a821c0
---- /dev/null
++++ b/migrations/0029_site_prefs.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- The site switcher (openvibe-shared switcher.js, 2026-10-30; the owner: "clicking the icon … its own drop-down … all
++-- the sites, so you can hop between them fast"): the sites a person pinned and the ones they visited last follow the
++-- account to every OpenVibe site, the way display_prefs does. One JSON value per person, { pinned: [id], recent: [id] },
++-- in the row they already have; an older release never reads it.
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS site_prefs text COLLATE "C";
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 08aadd1a..5feed125 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -176,6 +176,45 @@ router.put('/me/display', requireAuth, async (req, res) => {
res.json({ ok: true, display });
});
++// ── Your sites in the site switcher (openvibe-shared switcher.js, 2026-10-30) ──
++// The sites a person pinned and the ones they visited last, so the switcher behind the OpenVibe icon shows the same
++// "Recent" and "Pinned" on every OpenVibe site. Ids only (the frame's site ids), at most 12 pinned and 8 recent. A visit
++// is recorded by the navbar on page load for a signed-in person, and never under Global Privacy Control or Do Not Track
++// (the navbar's own check, like its page-view beacon).
++const SITE_ID = /^[a-z][a-z0-9-]{1,31}$/;
++const siteIds = (a, n) => [...new Set((Array.isArray(a) ? a : []).map(String).filter((x) => SITE_ID.test(x)))].slice(0, n);
++const cleanSites = (d) => ({ pinned: siteIds(d && d.pinned, 12), recent: siteIds(d && d.recent, 8) });
++async function readSites(db, userId) {
++ try { const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = ?').get(userId); return cleanSites(row && row.site_prefs ? JSON.parse(row.site_prefs) : null); }
++ catch { return cleanSites(null); } // the column arrives with migration 0029
++}
++async function writeSites(db, userId, sites) {
++ await db.prepare(`INSERT INTO user_preferences (user_id, site_prefs, updated_at) VALUES (?, ?, ov_now())
++ ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(sites));
++ return sites;
++}
++router.get('/me/sites', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ res.set('Cache-Control', 'private, no-store').json(await readSites(db, req.user.sub || req.user.id));
++});
++// Replace the pinned list (the switcher's hold-to-pin); recent is kept.
++router.put('/me/sites', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const cur = await readSites(db, userId);
++ res.set('Cache-Control', 'private, no-store').json(await writeSites(db, userId, { pinned: siteIds(req.body && req.body.pinned, 12), recent: cur.recent }));
++});
++// One visit: the site moves to the front of recent.
++router.post('/me/sites/visit', requireAuth, async (req, res) => {
++ const site = String((req.body && req.body.site) || '');
++ if (!SITE_ID.test(site)) return res.status(400).json({ error: 'site must be a site id' });
++ const db = await getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const cur = await readSites(db, userId);
++ if (cur.recent[0] === site) return res.set('Cache-Control', 'private, no-store').json(cur);
++ res.set('Cache-Control', 'private, no-store').json(await writeSites(db, userId, { pinned: cur.pinned, recent: siteIds([site, ...cur.recent], 8) }));
++});
++
// ── Your submissions, with their review status ────────────────
router.get('/me/submissions', requireAuth, async (req, res) => {
const db = await getDb(req);
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 0110a126..d17da76c 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/themes-review.test.js b/test/themes-review.test.js
iindex 3799ee76..f44c7ee3 100644
---- a/test/themes-review.test.js
++++ b/test/themes-review.test.js
@@@ -115,6 +115,19 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHER
assert.strictEqual(r.body.display.scheme, 'auto', 'Auto (light or dark by the device) follows the account');
assert.strictEqual((await call('PUT', '/api/themes/me/display', { as: 2, body: { scheme: 'sepia' } })).body.display.scheme, undefined, 'only manual or auto');
++ // ── The site switcher's pinned and recent sites follow the account (migration 0029) ──
++ r = await call('GET', '/api/themes/me/sites', { as: 2 });
++ assert.deepStrictEqual(r.body, { pinned: [], recent: [] }, 'nothing yet');
++ for (const site of ['live', 'chat', 'live', 'tools']) r = await call('POST', '/api/themes/me/sites/visit', { as: 2, body: { site } });
++ assert.deepStrictEqual(r.body.recent, ['tools', 'live', 'chat'], 'newest first, each site once');
++ r = await call('PUT', '/api/themes/me/sites', { as: 2, body: { pinned: ['design', 'design', 'Bad Id', 'games'] } });
++ assert.deepStrictEqual(r.body, { pinned: ['design', 'games'], recent: ['tools', 'live', 'chat'] }, 'pins are clean ids; recent is kept');
++ assert.deepStrictEqual((await call('GET', '/api/themes/me/sites', { as: 2 })).body.pinned, ['design', 'games'], 'pins follow the account');
++ assert.strictEqual((await call('POST', '/api/themes/me/sites/visit', { as: 2, body: { site: '../x' } })).status, 400, 'only a site id');
++ assert.strictEqual((await call('GET', '/api/themes/me/sites')).status, 401, 'yours only, signed in');
++ for (let i = 0; i < 12; i++) r = await call('POST', '/api/themes/me/sites/visit', { as: 2, body: { site: `site${i}` } });
++ assert.strictEqual(r.body.recent.length, 8, 'at most eight recent');
++
// ── Seasons: what stands in for the default theme when, for apps and the SDK ──
r = await call('GET', '/api/themes/seasons?date=2026-10-25');
assert.deepStrictEqual(r.body.seasons.map((x) => [x.id, x.theme, x.from, x.to]), [['halloween', 'halloween', '10-01', '10-31'], ['winter', 'frost', '12-01', '12-31']]);
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex a048cf08..7030bc27 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');