mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(sidebar): scope channel sections storage to relay URL (#1477)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
stripOrphanedAssignments,
|
||||
writeChannelSectionsStore,
|
||||
} from "./channelSectionsStorage.ts";
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
@@ -202,3 +203,125 @@ test("writeChannelSectionsStore: returns false when setItem throws", () => {
|
||||
test("storageKey: returns expected format with pubkey", () => {
|
||||
assert.equal(storageKey("abc123"), "buzz-channel-sections.v1:abc123");
|
||||
});
|
||||
|
||||
// ─── Relay-scoped key tests ───────────────────────────────────────────────────
|
||||
|
||||
test("storageKey: with relayUrl includes normalized+encoded relay in key", () => {
|
||||
const relay = "wss://relay.example.com";
|
||||
const key = storageKey("pk1", relay);
|
||||
assert.equal(
|
||||
key,
|
||||
`buzz-channel-sections.v1:pk1:${encodeURIComponent(normalizeRelayUrl(relay))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("storageKey: without relayUrl returns legacy pubkey-only key", () => {
|
||||
assert.equal(storageKey("pk1"), "buzz-channel-sections.v1:pk1");
|
||||
assert.equal(storageKey("pk1", undefined), "buzz-channel-sections.v1:pk1");
|
||||
});
|
||||
|
||||
test("storageKey: two different relays produce different keys for same pubkey", () => {
|
||||
const k1 = storageKey("pk1", "wss://relay-a.example.com");
|
||||
const k2 = storageKey("pk1", "wss://relay-b.example.com");
|
||||
assert.notEqual(k1, k2);
|
||||
});
|
||||
|
||||
test("storageKey: equivalent relay URLs (case + trailing slash) map to the same key", () => {
|
||||
const k1 = storageKey("pk1", "WSS://Relay.Example/");
|
||||
const k2 = storageKey("pk1", "wss://relay.example");
|
||||
assert.equal(k1, k2);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore + writeChannelSectionsStore: scoped write/read roundtrip", () => {
|
||||
const pubkey = "pk-relay-roundtrip";
|
||||
const relay = "wss://relay.example.com";
|
||||
const store = makeStore({
|
||||
sections: [makeSection({ id: "s1", name: "Work", order: 0 })],
|
||||
assignments: { chan1: "s1" },
|
||||
});
|
||||
assert.equal(writeChannelSectionsStore(pubkey, store, relay), true);
|
||||
const result = readChannelSectionsStore(pubkey, relay);
|
||||
assert.deepEqual(result, store);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: scoped key is isolated from other relay's data", () => {
|
||||
const pubkey = "pk-isolation";
|
||||
const relayA = "wss://relay-a.example.com";
|
||||
const relayB = "wss://relay-b.example.com";
|
||||
const storeA = makeStore({
|
||||
sections: [makeSection({ id: "sa", name: "Relay A section", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
writeChannelSectionsStore(pubkey, storeA, relayA);
|
||||
// Relay B should see empty store, not relay A's data.
|
||||
const resultB = readChannelSectionsStore(pubkey, relayB);
|
||||
assert.deepEqual(resultB, DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: migrates legacy unscoped data on first scoped read", () => {
|
||||
const pubkey = "pk-migrate";
|
||||
const relay = "wss://relay-migrate.example.com";
|
||||
const legacyStore = makeStore({
|
||||
sections: [makeSection({ id: "sl", name: "Legacy Section", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
// Write under legacy (pubkey-only) key.
|
||||
writeChannelSectionsStore(pubkey, legacyStore);
|
||||
// First scoped read should migrate and return the legacy data.
|
||||
const result = readChannelSectionsStore(pubkey, relay);
|
||||
assert.deepEqual(result, legacyStore);
|
||||
// Legacy key must be deleted after migration (globally one-time guarantee).
|
||||
const legacyKey = storageKey(pubkey);
|
||||
assert.equal(window.localStorage.getItem(legacyKey), null);
|
||||
// Subsequent scoped reads should hit the scoped key directly.
|
||||
const result2 = readChannelSectionsStore(pubkey, relay);
|
||||
assert.deepEqual(result2, legacyStore);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: migration is globally one-time — relay B sees DEFAULT_STORE after relay A migrates", () => {
|
||||
const pubkey = "pk-migrate-once";
|
||||
const relayA = "wss://relay-migrate-once-a.example.com";
|
||||
const relayB = "wss://relay-migrate-once-b.example.com";
|
||||
const legacyStore = makeStore({
|
||||
sections: [makeSection({ id: "sm", name: "Migrated Section", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
// Write under legacy key then migrate via relay A.
|
||||
writeChannelSectionsStore(pubkey, legacyStore);
|
||||
readChannelSectionsStore(pubkey, relayA); // triggers migration, deletes legacy key
|
||||
// Relay B must see DEFAULT_STORE — legacy data must not bleed in.
|
||||
const resultB = readChannelSectionsStore(pubkey, relayB);
|
||||
assert.deepEqual(resultB, DEFAULT_STORE);
|
||||
// Relay B scoped key must not have been created.
|
||||
const scopedBKey = storageKey(pubkey, relayB);
|
||||
assert.equal(window.localStorage.getItem(scopedBKey), null);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: migration only copies non-empty legacy stores", () => {
|
||||
const pubkey = "pk-migrate-empty";
|
||||
const relay = "wss://relay-migrate-empty.example.com";
|
||||
// Write an explicitly empty store under the legacy key.
|
||||
writeChannelSectionsStore(pubkey, DEFAULT_STORE);
|
||||
// Empty legacy store should NOT be migrated — we get DEFAULT_STORE.
|
||||
const result = readChannelSectionsStore(pubkey, relay);
|
||||
assert.deepEqual(result, DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: scoped key takes precedence over legacy key after migration", () => {
|
||||
const pubkey = "pk-precedence";
|
||||
const relay = "wss://relay-precedence.example.com";
|
||||
const legacyStore = makeStore({
|
||||
sections: [makeSection({ id: "sold", name: "Old Section", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
const newStore = makeStore({
|
||||
sections: [makeSection({ id: "snew", name: "New Section", order: 0 })],
|
||||
assignments: {},
|
||||
});
|
||||
// Write legacy data then scoped data (simulates post-migration state).
|
||||
writeChannelSectionsStore(pubkey, legacyStore);
|
||||
writeChannelSectionsStore(pubkey, newStore, relay);
|
||||
// Scoped read must return the scoped (newer) store, not the legacy one.
|
||||
const result = readChannelSectionsStore(pubkey, relay);
|
||||
assert.deepEqual(result, newStore);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
|
||||
|
||||
export type ChannelSection = {
|
||||
@@ -18,8 +20,20 @@ export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
|
||||
assignments: {},
|
||||
});
|
||||
|
||||
export function storageKey(pubkey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
|
||||
/**
|
||||
* Returns the localStorage key for channel sections.
|
||||
*
|
||||
* When `relayUrl` is provided the key is scoped to that relay (normalized via
|
||||
* the same `normalizeRelayUrl` used by all relay-scoped local stores) so
|
||||
* sections from different workspaces/relays don't bleed across each other.
|
||||
* When omitted the legacy pubkey-only key is returned (used only during
|
||||
* one-time migration in `readChannelSectionsStore`).
|
||||
*/
|
||||
export function storageKey(pubkey: string, relayUrl?: string): string {
|
||||
if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`;
|
||||
const normalized = normalizeRelayUrl(relayUrl);
|
||||
// Encode the normalized relay so it can't contain the `:` delimiter.
|
||||
return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalized)}`;
|
||||
}
|
||||
|
||||
export function stripOrphanedAssignments(
|
||||
@@ -62,17 +76,62 @@ export function parseChannelSectionPayload(
|
||||
return stripOrphanedAssignments({ version: 1, sections, assignments });
|
||||
}
|
||||
|
||||
export function readChannelSectionsStore(pubkey: string): ChannelSectionStore {
|
||||
function parseRaw(raw: string | null): ChannelSectionStore | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(pubkey));
|
||||
if (!raw) {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
|
||||
return DEFAULT_STORE;
|
||||
return null;
|
||||
}
|
||||
return parseChannelSectionPayload(parsed) ?? DEFAULT_STORE;
|
||||
return parseChannelSectionPayload(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the section store for `pubkey` scoped to `relayUrl`.
|
||||
*
|
||||
* On first access for a scoped key, migrates any existing data from the
|
||||
* legacy pubkey-only key so users don't lose their sections on upgrade.
|
||||
* After a successful migration write the legacy key is deleted, making
|
||||
* this a globally one-time operation — subsequent empty scoped-key reads
|
||||
* (i.e. a different relay) won't see legacy data and won't trigger
|
||||
* cross-relay contamination via the seed-publish path.
|
||||
*/
|
||||
export function readChannelSectionsStore(
|
||||
pubkey: string,
|
||||
relayUrl?: string,
|
||||
): ChannelSectionStore {
|
||||
try {
|
||||
const key = storageKey(pubkey, relayUrl);
|
||||
const raw = window.localStorage.getItem(key);
|
||||
|
||||
// Scoped key already has data — use it directly.
|
||||
if (raw !== null) {
|
||||
return parseRaw(raw) ?? DEFAULT_STORE;
|
||||
}
|
||||
|
||||
// No scoped data yet. If we were given a relay scope, attempt a
|
||||
// one-time migration from the legacy pubkey-only key.
|
||||
if (relayUrl) {
|
||||
const legacyKey = storageKey(pubkey);
|
||||
const legacyRaw = window.localStorage.getItem(legacyKey);
|
||||
const migrated = parseRaw(legacyRaw);
|
||||
if (migrated && migrated.sections.length > 0) {
|
||||
// Persist under the scoped key and remove the legacy key so this
|
||||
// migration cannot fire again for any other relay scope.
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(migrated));
|
||||
window.localStorage.removeItem(legacyKey);
|
||||
} catch {
|
||||
// Ignore write failures — we still return the migrated value.
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_STORE;
|
||||
} catch {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
@@ -81,9 +140,13 @@ export function readChannelSectionsStore(pubkey: string): ChannelSectionStore {
|
||||
export function writeChannelSectionsStore(
|
||||
pubkey: string,
|
||||
store: ChannelSectionStore,
|
||||
relayUrl?: string,
|
||||
): boolean {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
|
||||
window.localStorage.setItem(
|
||||
storageKey(pubkey, relayUrl),
|
||||
JSON.stringify(store),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
210
desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs
Normal file
210
desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { ChannelSectionSyncManager } from "./channelSectionsSync.ts";
|
||||
|
||||
function makeStore(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
sections: overrides.sections ?? [],
|
||||
assignments: overrides.assignments ?? {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
|
||||
|
||||
// Regression guard for the workspace-switch cross-relay publish vector:
|
||||
// edit sections in relay A → destroy() is called (relayUrl dep change) →
|
||||
// no publish should fire. The scoped localStorage write is durable; when the
|
||||
// user returns to relay A the seed-publish path handles it.
|
||||
test("destroy: cancels pending publish without flushing to the relay", () => {
|
||||
const publishCalls = [];
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
// Simulate the timer scheduler with a manual clock so we can advance it.
|
||||
let timerCallback = null;
|
||||
const originalSetTimeout = globalThis.window?.setTimeout;
|
||||
const originalClearTimeout = globalThis.window?.clearTimeout;
|
||||
|
||||
// Inject a fake window.setTimeout/clearTimeout if needed.
|
||||
const fakeTimers = [];
|
||||
let nextId = 1;
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
globalThis.window.setTimeout = (fn, _ms) => {
|
||||
const id = nextId++;
|
||||
fakeTimers.push({ id, fn });
|
||||
timerCallback = fn;
|
||||
return id;
|
||||
};
|
||||
globalThis.window.clearTimeout = (id) => {
|
||||
const idx = fakeTimers.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
fakeTimers.splice(idx, 1);
|
||||
timerCallback = null;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-test");
|
||||
const store = makeStore({
|
||||
sections: [{ id: "s1", name: "Work", order: 0 }],
|
||||
});
|
||||
|
||||
// Queue a publish — this sets the debounce timer.
|
||||
manager.publishSections(store);
|
||||
assert.ok(timerCallback !== null, "debounce timer should be set");
|
||||
|
||||
// Destroy before the debounce fires — simulates workspace switch.
|
||||
manager.destroy();
|
||||
|
||||
// Timer must be cleared and no publish should fire now.
|
||||
assert.ok(
|
||||
timerCallback === null,
|
||||
"debounce timer should be cleared on destroy",
|
||||
);
|
||||
|
||||
// Advance time by invoking the callback that was cleared — it shouldn't exist.
|
||||
// If clearTimeout didn't work, try firing whatever was captured before destroy.
|
||||
// (There's nothing to fire after a correct destroy.)
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"no publish event should have been sent after destroy",
|
||||
);
|
||||
} finally {
|
||||
// Restore timer functions.
|
||||
if (originalSetTimeout !== undefined) {
|
||||
globalThis.window.setTimeout = originalSetTimeout;
|
||||
}
|
||||
if (originalClearTimeout !== undefined) {
|
||||
globalThis.window.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression guard for the timer-fired race: debounce fires → doPublish starts
|
||||
// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep
|
||||
// change) → publishEvent must never be called even though the timer already
|
||||
// fired and cleared itself before destroy() ran.
|
||||
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
|
||||
// fetchEvents is held until we release it — simulates the latency window.
|
||||
let releaseFetch = null;
|
||||
const publishCalls = [];
|
||||
|
||||
mock.method(relayClient, "fetchEvents", () => {
|
||||
return new Promise((resolve) => {
|
||||
// resolve with empty so fetchOwnBlobBeforePublish returns the local store
|
||||
releaseFetch = () => resolve([]);
|
||||
});
|
||||
});
|
||||
mock.method(relayClient, "publishEvent", (...args) => {
|
||||
publishCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
let capturedCallback = null;
|
||||
let nextId = 1;
|
||||
const origSetTimeout = globalThis.window.setTimeout;
|
||||
const origClearTimeout = globalThis.window.clearTimeout;
|
||||
globalThis.window.setTimeout = (fn, _ms) => {
|
||||
capturedCallback = fn;
|
||||
return nextId++;
|
||||
};
|
||||
globalThis.window.clearTimeout = (_id) => {
|
||||
capturedCallback = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-race");
|
||||
const store = makeStore({
|
||||
sections: [{ id: "s1", name: "Work", order: 0 }],
|
||||
});
|
||||
|
||||
// Queue the publish — captures the debounce callback.
|
||||
manager.publishSections(store);
|
||||
assert.ok(capturedCallback !== null, "debounce timer should be set");
|
||||
|
||||
// Fire the debounce manually — this starts doPublish() and nulls
|
||||
// debounceTimer inside publishSections' callback, leaving the async
|
||||
// doPublish running and awaiting fetchOwnBlobBeforePublish.
|
||||
const timerFn = capturedCallback;
|
||||
capturedCallback = null; // timer cleared itself inside the callback
|
||||
timerFn();
|
||||
|
||||
// Now destroy() — debounceTimer is already null (timer fired), so only
|
||||
// the destroyed flag can stop doPublish.
|
||||
manager.destroy();
|
||||
|
||||
// Release the held fetchEvents — fetchOwnBlobBeforePublish resolves with
|
||||
// the local store, then doPublish should check destroyed and abort.
|
||||
releaseFetch();
|
||||
|
||||
// Drain microtasks so doPublish fully runs through to its abort point.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
publishCalls.length,
|
||||
0,
|
||||
"publishEvent must not be called after destroy() even when timer already fired",
|
||||
);
|
||||
} finally {
|
||||
globalThis.window.setTimeout = origSetTimeout;
|
||||
globalThis.window.clearTimeout = origClearTimeout;
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy: is safe to call with no pending publish", () => {
|
||||
const manager = new ChannelSectionSyncManager("pk-no-pending");
|
||||
// Should not throw even with nothing queued.
|
||||
assert.doesNotThrow(() => manager.destroy());
|
||||
});
|
||||
|
||||
test("destroy: cancelPendingPublish clears pendingStore", () => {
|
||||
let timerCallback = null;
|
||||
let nextId = 1;
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
}
|
||||
const orig = globalThis.window.setTimeout;
|
||||
const origClear = globalThis.window.clearTimeout;
|
||||
globalThis.window.setTimeout = (fn, _ms) => {
|
||||
timerCallback = fn;
|
||||
return nextId++;
|
||||
};
|
||||
globalThis.window.clearTimeout = (_id) => {
|
||||
timerCallback = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = new ChannelSectionSyncManager("pk-pending-null");
|
||||
const store = makeStore({
|
||||
sections: [{ id: "s1", name: "Test", order: 0 }],
|
||||
});
|
||||
manager.publishSections(store);
|
||||
assert.deepEqual(manager.getPendingStore(), store);
|
||||
|
||||
manager.destroy();
|
||||
assert.equal(
|
||||
manager.getPendingStore(),
|
||||
null,
|
||||
"pendingStore must be null after destroy",
|
||||
);
|
||||
assert.ok(timerCallback === null, "timer must be cleared after destroy");
|
||||
} finally {
|
||||
globalThis.window.setTimeout = orig;
|
||||
globalThis.window.clearTimeout = origClear;
|
||||
}
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export class ChannelSectionSyncManager {
|
||||
private lastRemoteCreatedAt = 0;
|
||||
private pendingStore: ChannelSectionStore | null = null;
|
||||
private lastPublishedStore: ChannelSectionStore | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(pubkey: string) {
|
||||
this.pubkey = pubkey;
|
||||
@@ -143,6 +144,10 @@ export class ChannelSectionSyncManager {
|
||||
private async doPublish(store: ChannelSectionStore): Promise<void> {
|
||||
try {
|
||||
const merged = await this.fetchOwnBlobBeforePublish(store);
|
||||
// Guard: manager may have been destroyed while fetchOwnBlobBeforePublish
|
||||
// was awaited (workspace switch during in-flight fetch). If so, abort
|
||||
// before touching the relay.
|
||||
if (this.destroyed) return;
|
||||
if (this.isIdenticalToLastPublished(merged)) {
|
||||
this.pendingStore = null;
|
||||
return;
|
||||
@@ -166,6 +171,10 @@ export class ChannelSectionSyncManager {
|
||||
["t", D_TAG], // relay discoverability; not used in our filters
|
||||
],
|
||||
});
|
||||
// Final guard immediately before the network call — sign/encrypt are
|
||||
// synchronous-ish but cheap; the relay socket may have moved to a
|
||||
// different workspace by the time we reach this point.
|
||||
if (this.destroyed) return;
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out publishing channel sections.",
|
||||
@@ -208,13 +217,15 @@ export class ChannelSectionSyncManager {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.debounceTimer !== null && this.pendingStore !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
void this.doPublish(this.pendingStore);
|
||||
} else if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
// Cancel any pending publish and mark this manager as destroyed so any
|
||||
// in-flight doPublish() calls abort before reaching relayClient. The
|
||||
// scoped localStorage write is already durable; when the user returns to
|
||||
// this relay the existing seed-publish guard will re-publish from local
|
||||
// state. Flushing here would race against workspace switching and could
|
||||
// publish relay A's sections to relay B via the shared relayClient
|
||||
// singleton.
|
||||
this.destroyed = true;
|
||||
this.cancelPendingPublish();
|
||||
this.pendingStore = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import type {
|
||||
ChannelSectionStore,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
export function useChannelSections(pubkey: string | undefined): {
|
||||
export function useChannelSections(
|
||||
pubkey: string | undefined,
|
||||
relayUrl?: string,
|
||||
): {
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
createSection: (name: string) => ChannelSection | null;
|
||||
@@ -34,7 +37,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!pubkey) {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
return readChannelSectionsStore(pubkey);
|
||||
return readChannelSectionsStore(pubkey, relayUrl);
|
||||
});
|
||||
|
||||
const managerRef = React.useRef<ChannelSectionSyncManager | null>(null);
|
||||
@@ -48,7 +51,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
lastAppliedEventId.current = "";
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey));
|
||||
setStore(readChannelSectionsStore(pubkey, relayUrl));
|
||||
lastAppliedRemoteTs.current = 0;
|
||||
lastAppliedEventId.current = "";
|
||||
managerRef.current = new ChannelSectionSyncManager(pubkey);
|
||||
@@ -56,24 +59,24 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
};
|
||||
}, [pubkey]);
|
||||
}, [pubkey, relayUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
const key = storageKey(pubkey);
|
||||
const key = storageKey(pubkey, relayUrl);
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== key) {
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey));
|
||||
setStore(readChannelSectionsStore(pubkey, relayUrl));
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
};
|
||||
}, [pubkey]);
|
||||
}, [pubkey, relayUrl]);
|
||||
|
||||
const applyRemote = React.useCallback(
|
||||
(
|
||||
@@ -90,11 +93,12 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
lastAppliedRemoteTs.current = remote.createdAt;
|
||||
lastAppliedEventId.current = remote.eventId;
|
||||
managerRef.current?.cancelPendingPublish();
|
||||
if (!writeChannelSectionsStore(pubkey, remote.store)) return prev;
|
||||
if (!writeChannelSectionsStore(pubkey, remote.store, relayUrl))
|
||||
return prev;
|
||||
return remote.store;
|
||||
};
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -105,7 +109,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (remote) {
|
||||
setStore(applyRemote(remote));
|
||||
} else {
|
||||
const local = readChannelSectionsStore(pubkey);
|
||||
const local = readChannelSectionsStore(pubkey, relayUrl);
|
||||
if (local.sections.length > 0) {
|
||||
managerRef.current?.publishSections(local);
|
||||
}
|
||||
@@ -114,7 +118,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey, applyRemote]);
|
||||
}, [pubkey, relayUrl, applyRemote]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
@@ -167,7 +171,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
const createSection = React.useCallback(
|
||||
(name: string): ChannelSection | null => {
|
||||
if (!pubkey) return null;
|
||||
const prev = readChannelSectionsStore(pubkey);
|
||||
const prev = readChannelSectionsStore(pubkey, relayUrl);
|
||||
const maxOrder =
|
||||
prev.sections.length > 0
|
||||
? Math.max(...prev.sections.map((s) => s.order))
|
||||
@@ -182,13 +186,13 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
...current,
|
||||
sections: [...current.sections, section],
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) return current;
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return current;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
return section;
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const renameSection = React.useCallback(
|
||||
@@ -203,14 +207,14 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
s.id === sectionId ? { ...s, name: newName } : s,
|
||||
),
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const deleteSection = React.useCallback(
|
||||
@@ -230,14 +234,14 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
sections: prev.sections.filter((s) => s.id !== sectionId),
|
||||
assignments,
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const moveSectionUp = React.useCallback(
|
||||
@@ -245,12 +249,13 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!pubkey) return;
|
||||
setStore((prev) => {
|
||||
const next = swapSectionOrder(prev, sectionId, "up");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
|
||||
return prev;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const moveSectionDown = React.useCallback(
|
||||
@@ -258,12 +263,13 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!pubkey) return;
|
||||
setStore((prev) => {
|
||||
const next = swapSectionOrder(prev, sectionId, "down");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
|
||||
return prev;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const reorderSections = React.useCallback(
|
||||
@@ -275,12 +281,12 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
return newOrder === -1 ? s : { ...s, order: newOrder };
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev;
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const assignChannel = React.useCallback(
|
||||
@@ -293,14 +299,14 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
...prev,
|
||||
assignments: { ...prev.assignments, [channelId]: sectionId },
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
const unassignChannel = React.useCallback(
|
||||
@@ -312,14 +318,14 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
const next: ChannelSectionStore = { ...prev, assignments };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
|
||||
return prev;
|
||||
}
|
||||
managerRef.current?.publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -344,7 +344,7 @@ export function AppSidebar({
|
||||
reorderSections,
|
||||
assignChannel,
|
||||
unassignChannel,
|
||||
} = useChannelSections(currentPubkey);
|
||||
} = useChannelSections(currentPubkey, activeWorkspace?.relayUrl);
|
||||
|
||||
const [createSectionState, setCreateSectionState] = React.useState<{
|
||||
open: boolean;
|
||||
|
||||
Reference in New Issue
Block a user