fix: stop cross-tab active tab fighting + add per-user tab persistence
activeLayerId was being written to the shared Automerge CRDT on every tab switch, causing all open windows/devices to follow. Now active tab is local-only. Adds REST API + server-side storage so authenticated users' tab lists persist across sessions and devices. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2eb9ca2d8f
commit
e7f0181f70
|
|
@ -1042,13 +1042,8 @@ export class CommunitySync extends EventTarget {
|
||||||
this.#syncToServer();
|
this.#syncToServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set active layer */
|
/** Set active layer — local-only, never broadcast to other tabs/devices */
|
||||||
setActiveLayer(layerId: string): void {
|
setActiveLayer(layerId: string): void {
|
||||||
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => {
|
|
||||||
doc.activeLayerId = layerId;
|
|
||||||
});
|
|
||||||
this.#scheduleSave();
|
|
||||||
this.#syncToServer();
|
|
||||||
this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
|
this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -538,11 +538,64 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
// Track current module as recently used
|
// Track current module as recently used
|
||||||
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
|
if (tabBar.trackRecent) tabBar.trackRecent(currentModuleId);
|
||||||
|
|
||||||
// Helper: save current tab list to localStorage
|
// Helper: save current tab list to localStorage + server
|
||||||
|
let _tabSaveTimer = null;
|
||||||
function saveTabs() {
|
function saveTabs() {
|
||||||
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
||||||
|
// Debounced server save for authenticated users
|
||||||
|
clearTimeout(_tabSaveTimer);
|
||||||
|
_tabSaveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('encryptid_session');
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
|
||||||
|
body: JSON.stringify({ tabs: layers }),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch(e) {}
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch tabs from server for authenticated users (merge with localStorage)
|
||||||
|
(function syncTabsFromServer() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('encryptid_session');
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
fetch('/api/user/tabs/' + encodeURIComponent(spaceSlug), {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + session.accessToken },
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
||||||
|
// Server has nothing — push localStorage tabs up
|
||||||
|
saveTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Merge: union of moduleIds, server order wins for shared tabs
|
||||||
|
const serverMap = new Map(data.tabs.map(t => [t.moduleId, t]));
|
||||||
|
const localMap = new Map(layers.map(t => [t.moduleId, t]));
|
||||||
|
const merged = [...data.tabs];
|
||||||
|
for (const [mid, lt] of localMap) {
|
||||||
|
if (!serverMap.has(mid)) merged.push(lt);
|
||||||
|
}
|
||||||
|
merged.forEach((l, i) => { l.order = i; });
|
||||||
|
layers = merged;
|
||||||
|
// Ensure current module is present
|
||||||
|
if (!layers.find(l => l.moduleId === currentModuleId)) {
|
||||||
|
layers.push(makeLayer(currentModuleId, layers.length));
|
||||||
|
}
|
||||||
|
tabBar.setLayers(layers);
|
||||||
|
saveTabs();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
|
||||||
// ── Tab cache: instant switching via show/hide DOM panes ──
|
// ── Tab cache: instant switching via show/hide DOM panes ──
|
||||||
let tabCache = null;
|
let tabCache = null;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -483,3 +483,17 @@ UPDATE trust_events SET authority = 'dev-ops' WHERE authority IN ('curation', 'm
|
||||||
|
|
||||||
-- Add new CHECK constraint with updated values
|
-- Add new CHECK constraint with updated values
|
||||||
ALTER TABLE delegations ADD CONSTRAINT delegations_authority_check CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom'));
|
ALTER TABLE delegations ADD CONSTRAINT delegations_authority_check CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom'));
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- USER TAB STATE (per-user tab persistence across devices)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_tab_state (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
space_slug TEXT NOT NULL,
|
||||||
|
tabs JSONB NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, space_slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_tab_state_user_id ON user_tab_state(user_id);
|
||||||
|
|
|
||||||
|
|
@ -1079,6 +1079,53 @@ app.delete('/api/user/addresses/:id', async (c) => {
|
||||||
return c.json({ success: true });
|
return c.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// USER TAB STATE (per-user tab persistence across devices)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** GET /api/user/tabs/:spaceSlug — return saved tabs for this user+space */
|
||||||
|
app.get('/api/user/tabs/:spaceSlug', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const userId = claims.sub as string;
|
||||||
|
const spaceSlug = c.req.param('spaceSlug');
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT tabs, updated_at FROM user_tab_state
|
||||||
|
WHERE user_id = ${userId} AND space_slug = ${spaceSlug}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return c.json({ tabs: null, updatedAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ tabs: rows[0].tabs, updatedAt: rows[0].updated_at });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/user/tabs/:spaceSlug — upsert tab list for this user+space */
|
||||||
|
app.put('/api/user/tabs/:spaceSlug', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const userId = claims.sub as string;
|
||||||
|
const spaceSlug = c.req.param('spaceSlug');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(body.tabs)) {
|
||||||
|
return c.json({ error: 'tabs must be an array' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
INSERT INTO user_tab_state (user_id, space_slug, tabs, updated_at)
|
||||||
|
VALUES (${userId}, ${spaceSlug}, ${JSON.stringify(body.tabs)}::jsonb, NOW())
|
||||||
|
ON CONFLICT (user_id, space_slug)
|
||||||
|
DO UPDATE SET tabs = ${JSON.stringify(body.tabs)}::jsonb, updated_at = NOW()
|
||||||
|
`;
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCOUNT SETTINGS ENDPOINTS
|
// ACCOUNT SETTINGS ENDPOINTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -2883,11 +2883,60 @@
|
||||||
tabBar.setLayers(layers);
|
tabBar.setLayers(layers);
|
||||||
tabBar.setAttribute('active', 'layer-' + currentModuleId);
|
tabBar.setAttribute('active', 'layer-' + currentModuleId);
|
||||||
|
|
||||||
// Helper: save current tab list to localStorage
|
// Helper: save current tab list to localStorage + server
|
||||||
|
let _tabSaveTimer = null;
|
||||||
function saveTabs() {
|
function saveTabs() {
|
||||||
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
|
||||||
|
clearTimeout(_tabSaveTimer);
|
||||||
|
_tabSaveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('encryptid_session');
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
fetch('/api/user/tabs/' + encodeURIComponent(communitySlug), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.accessToken },
|
||||||
|
body: JSON.stringify({ tabs: layers }),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch(e) {}
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch tabs from server for authenticated users
|
||||||
|
(function syncTabsFromServer() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('encryptid_session');
|
||||||
|
if (!raw) return;
|
||||||
|
const session = JSON.parse(raw);
|
||||||
|
if (!session?.accessToken) return;
|
||||||
|
fetch('/api/user/tabs/' + encodeURIComponent(communitySlug), {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + session.accessToken },
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data?.tabs || !Array.isArray(data.tabs) || data.tabs.length === 0) {
|
||||||
|
saveTabs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverMap = new Map(data.tabs.map(t => [t.moduleId, t]));
|
||||||
|
const localMap = new Map(layers.map(t => [t.moduleId, t]));
|
||||||
|
const merged = [...data.tabs];
|
||||||
|
for (const [mid, lt] of localMap) {
|
||||||
|
if (!serverMap.has(mid)) merged.push(lt);
|
||||||
|
}
|
||||||
|
merged.forEach((l, i) => { l.order = i; });
|
||||||
|
layers = merged;
|
||||||
|
if (!layers.find(l => l.moduleId === currentModuleId)) {
|
||||||
|
layers.push(makeLayer(currentModuleId, layers.length));
|
||||||
|
}
|
||||||
|
tabBar.setLayers(layers);
|
||||||
|
saveTabs();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
|
||||||
// ── Tab events ──
|
// ── Tab events ──
|
||||||
tabBar.addEventListener('layer-switch', (e) => {
|
tabBar.addEventListener('layer-switch', (e) => {
|
||||||
const { moduleId } = e.detail;
|
const { moduleId } = e.detail;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue