openvibers/openvibe.network Commits
The site switcher's pinned and recent sites follow the account (#430)
Changes
nnew file mode 100644@@@ -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";@@@ -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);@@@ -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.@@@ -115,6 +115,19 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERassert.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']]);@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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";
@@ -176,6 +176,45 @@ router.put('/me/display', requireAuth, async (req, res) => {
@@ -176,6 +176,45 @@ router.put('/me/display', requireAuth, async (req, res) => {
res.json({ ok: true, display });
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 ────────────────
// ── Your submissions, with their review status ────────────────
router.get('/me/submissions', requireAuth, async (req, res) => {
router.get('/me/submissions', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -115,6 +115,19 @@ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHER
@@ -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(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');
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 ──
// ── 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');
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']]);
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']]);
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');