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:
Will Pfleger
2026-07-02 17:38:55 -04:00
committed by GitHub
parent 453b8b1e47
commit f9d06ae21a
6 changed files with 460 additions and 47 deletions

View File

@@ -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);
});

View File

@@ -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;

View 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;
}
});

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -344,7 +344,7 @@ export function AppSidebar({
reorderSections,
assignChannel,
unassignChannel,
} = useChannelSections(currentPubkey);
} = useChannelSections(currentPubkey, activeWorkspace?.relayUrl);
const [createSectionState, setCreateSectionState] = React.useState<{
open: boolean;