mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): close section workspace cutover races
Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -24,6 +24,8 @@ const OWNER_BYTES = new Uint8Array(32).fill(1);
|
||||
const OWNER =
|
||||
"1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const RELAY = "wss://Relay.Example/";
|
||||
const RELAY_SELF =
|
||||
"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f";
|
||||
const PROJECTION_EVENT = {
|
||||
id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
pubkey: "9999999999999999999999999999999999999999999999999999999999999999",
|
||||
@@ -149,6 +151,7 @@ test("projection is decrypted, cached by normalized relay, and rendered during o
|
||||
subscribeLive: async () => async () => {},
|
||||
});
|
||||
const restoreWindow = installWindow(storage, async (command, args) => {
|
||||
if (command === "get_relay_self") return RELAY_SELF;
|
||||
if (command === "nip44_decrypt_from_self") return "workspace-key";
|
||||
if (command === "decrypt_workspace_metadata") {
|
||||
if (args.envelope === "ciphertext-alpha") return "Alpha";
|
||||
|
||||
@@ -16,7 +16,15 @@ import {
|
||||
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import type { LegacySectionSource } from "./channelSectionsSync";
|
||||
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
|
||||
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
|
||||
import {
|
||||
hasImportMarker,
|
||||
readStoredImportAction,
|
||||
readStoredImportCommand,
|
||||
clearImportAction,
|
||||
writeImportState,
|
||||
} from "./sectionWorkspaceStorage";
|
||||
import {
|
||||
KIND_SECTION_WORKSPACE_IMPORT,
|
||||
KIND_SECTION_WORKSPACE_PROJECTION,
|
||||
@@ -36,9 +44,6 @@ const MAX_ASSIGNMENTS = 1_000;
|
||||
const MAX_ENCRYPTED_METADATA_BYTES = 65_535;
|
||||
const MAX_KEY_ENVELOPE_BYTES = 4_096;
|
||||
const CACHE_PREFIX = "buzz-section-workspace.v1";
|
||||
const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`;
|
||||
const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`;
|
||||
|
||||
type ProjectionSection = {
|
||||
id: string;
|
||||
rank: number;
|
||||
@@ -471,54 +476,20 @@ export function canonicalJson(input: unknown): string {
|
||||
.map((key) => `${JSON.stringify(key)}:${canonicalJson(input[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
export function canonicalRelayAuthority(relayUrl: string): string {
|
||||
const parsed = new URL(relayUrl.trim());
|
||||
if (
|
||||
!["ws:", "wss:", "http:", "https:"].includes(parsed.protocol) ||
|
||||
!parsed.hostname
|
||||
) {
|
||||
throw new Error("invalid relay URL");
|
||||
}
|
||||
return parsed.host.toLowerCase();
|
||||
}
|
||||
|
||||
function cacheKey(pubkey: string, relayUrl: string): string {
|
||||
return `${CACHE_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
}
|
||||
function importActionKey(pubkey: string, relayUrl: string): string {
|
||||
return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
}
|
||||
function importCommandKey(pubkey: string, relayUrl: string): string {
|
||||
return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
}
|
||||
function readStoredValue(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function readStoredImportCommand(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
): string | null {
|
||||
return readStoredValue(importCommandKey(pubkey, relayUrl));
|
||||
}
|
||||
function hasImportMarker(pubkey: string, relayUrl: string): boolean {
|
||||
return (
|
||||
readStoredValue(importActionKey(pubkey, relayUrl)) !== null ||
|
||||
readStoredImportCommand(pubkey, relayUrl) !== null
|
||||
);
|
||||
}
|
||||
function markImportStarted(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
actionId: string,
|
||||
): void {
|
||||
try {
|
||||
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
|
||||
} catch {}
|
||||
}
|
||||
function writeImportState(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
actionId: string,
|
||||
command: string,
|
||||
): void {
|
||||
try {
|
||||
window.localStorage.setItem(importCommandKey(pubkey, relayUrl), command);
|
||||
window.localStorage.setItem(importActionKey(pubkey, relayUrl), actionId);
|
||||
} catch {}
|
||||
}
|
||||
function readCache(pubkey: string, relayUrl: string): WorkspaceCache | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(cacheKey(pubkey, relayUrl));
|
||||
@@ -600,7 +571,13 @@ async function encryptMetadata(
|
||||
await encryptWorkspaceMetadata({
|
||||
keyHex: key,
|
||||
plaintext: value,
|
||||
aad: aad(community, owner, section.id, 1, purpose),
|
||||
aad: aad(
|
||||
canonicalRelayAuthority(community),
|
||||
owner,
|
||||
section.id,
|
||||
1,
|
||||
purpose,
|
||||
),
|
||||
}),
|
||||
"encrypted_metadata",
|
||||
MAX_ENCRYPTED_METADATA_BYTES,
|
||||
@@ -618,7 +595,13 @@ async function decryptMetadata(
|
||||
return decryptWorkspaceMetadata({
|
||||
keyHex: key,
|
||||
envelope: value,
|
||||
aad: aad(community, owner, sectionId, epoch, purpose),
|
||||
aad: aad(
|
||||
canonicalRelayAuthority(community),
|
||||
owner,
|
||||
sectionId,
|
||||
epoch,
|
||||
purpose,
|
||||
),
|
||||
});
|
||||
}
|
||||
async function projectionStore(
|
||||
@@ -683,10 +666,10 @@ function validateEventRouting(event: RelayEvent, owner: string): void {
|
||||
throw new Error("projection routing mismatch");
|
||||
}
|
||||
|
||||
function projectionFromEvent(
|
||||
async function projectionFromEvent(
|
||||
event: RelayEvent,
|
||||
owner: string,
|
||||
): SectionWorkspaceProjection {
|
||||
): Promise<SectionWorkspaceProjection> {
|
||||
if (
|
||||
event.id.length !== 64 ||
|
||||
!/^[0-9a-f]{64}$/.test(event.id) ||
|
||||
@@ -703,6 +686,14 @@ function projectionFromEvent(
|
||||
})
|
||||
)
|
||||
throw new Error("invalid projection event signature");
|
||||
let relaySelf: string | null;
|
||||
try {
|
||||
relaySelf = await getRelaySelf();
|
||||
} catch {
|
||||
throw new Error("relay signer is untrusted");
|
||||
}
|
||||
if (!relaySelf || relaySelf !== event.pubkey)
|
||||
throw new Error("invalid projection relay signer");
|
||||
const projection = parseSectionWorkspaceProjection(
|
||||
parseStrictJson(event.content),
|
||||
);
|
||||
@@ -776,23 +767,14 @@ async function submitImport(
|
||||
sha256(new TextEncoder().encode(canonicalPlaintext)),
|
||||
);
|
||||
let actionId: string;
|
||||
let command: string | null;
|
||||
try {
|
||||
command = window.localStorage.getItem(
|
||||
importCommandKey(pubkey, normalizedRelay),
|
||||
);
|
||||
} catch {
|
||||
command = null;
|
||||
}
|
||||
let command: string | null = readStoredImportCommand(pubkey, normalizedRelay);
|
||||
if (command) {
|
||||
const parsed = parseStrictJson(command);
|
||||
const imported = parseWorkspaceImport(parsed);
|
||||
actionId = imported.action_id;
|
||||
} else {
|
||||
actionId =
|
||||
window.localStorage.getItem(importActionKey(pubkey, normalizedRelay)) ??
|
||||
crypto.randomUUID();
|
||||
markImportStarted(pubkey, normalizedRelay, actionId);
|
||||
readStoredImportAction(pubkey, normalizedRelay) ?? crypto.randomUUID();
|
||||
const key = await generateWorkspaceKey();
|
||||
const sections: ImportSection[] = [];
|
||||
const sectionIds = new Set<string>();
|
||||
@@ -850,7 +832,9 @@ async function submitImport(
|
||||
};
|
||||
command = canonicalJson(imported);
|
||||
parseWorkspaceImport(parseStrictJson(command));
|
||||
writeImportState(pubkey, normalizedRelay, actionId, command);
|
||||
if (!writeImportState(pubkey, normalizedRelay, actionId, command)) {
|
||||
throw new Error("cannot durably persist workspace import command");
|
||||
}
|
||||
}
|
||||
await publishImportCommand(pubkey, actionId, command);
|
||||
}
|
||||
@@ -928,12 +912,17 @@ export class SectionWorkspaceSyncManager {
|
||||
return this.acceptProjection(projection);
|
||||
}
|
||||
if (this.migrationStarted) {
|
||||
const replayed = await replayStoredImport(this.pubkey, this.relayUrl);
|
||||
if (replayed) return this.getCachedStore();
|
||||
if (readStoredValue(importActionKey(this.pubkey, this.relayUrl))) {
|
||||
const command = readStoredImportCommand(this.pubkey, this.relayUrl);
|
||||
if (command) {
|
||||
const replayed = await replayStoredImport(this.pubkey, this.relayUrl);
|
||||
if (replayed) return this.getCachedStore();
|
||||
}
|
||||
const actionOnly = readStoredImportAction(this.pubkey, this.relayUrl);
|
||||
if (actionOnly && clearImportAction(this.pubkey, this.relayUrl)) {
|
||||
this.migrationStarted = false;
|
||||
} else {
|
||||
return this.getCachedStore();
|
||||
}
|
||||
this.migrationStarted = false;
|
||||
}
|
||||
if (this.pubkey && !this.destroyed) {
|
||||
const legacy = await legacyFetch();
|
||||
@@ -967,7 +956,7 @@ export class SectionWorkspaceSyncManager {
|
||||
if (this.destroyed) return;
|
||||
void (async () => {
|
||||
try {
|
||||
let remote = projectionFromEvent(event, this.pubkey);
|
||||
let remote = await projectionFromEvent(event, this.pubkey);
|
||||
let action = projectionRevisionAction(
|
||||
this.projection?.revision ?? null,
|
||||
remote.revision,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl";
|
||||
|
||||
const CACHE_PREFIX = "buzz-section-workspace.v1";
|
||||
const IMPORT_ACTION_PREFIX = `${CACHE_PREFIX}:import-action`;
|
||||
const IMPORT_COMMAND_PREFIX = `${CACHE_PREFIX}:import-command`;
|
||||
|
||||
function importActionKey(pubkey: string, relayUrl: string): string {
|
||||
return `${IMPORT_ACTION_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
}
|
||||
function importCommandKey(pubkey: string, relayUrl: string): string {
|
||||
return `${IMPORT_COMMAND_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
|
||||
}
|
||||
export function readStoredValue(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function readStoredImportCommand(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
): string | null {
|
||||
return readStoredValue(importCommandKey(pubkey, relayUrl));
|
||||
}
|
||||
export function hasImportMarker(pubkey: string, relayUrl: string): boolean {
|
||||
return (
|
||||
readStoredValue(importActionKey(pubkey, relayUrl)) !== null ||
|
||||
readStoredImportCommand(pubkey, relayUrl) !== null
|
||||
);
|
||||
}
|
||||
export function readStoredImportAction(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
): string | null {
|
||||
return readStoredValue(importActionKey(pubkey, relayUrl));
|
||||
}
|
||||
export function clearImportAction(pubkey: string, relayUrl: string): boolean {
|
||||
try {
|
||||
const key = importActionKey(pubkey, relayUrl);
|
||||
window.localStorage.removeItem(key);
|
||||
return window.localStorage.getItem(key) === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function writeImportState(
|
||||
pubkey: string,
|
||||
relayUrl: string,
|
||||
actionId: string,
|
||||
command: string,
|
||||
): boolean {
|
||||
try {
|
||||
const commandKey = importCommandKey(pubkey, relayUrl);
|
||||
const actionKey = importActionKey(pubkey, relayUrl);
|
||||
window.localStorage.setItem(commandKey, command);
|
||||
window.localStorage.setItem(actionKey, actionId);
|
||||
return (
|
||||
window.localStorage.getItem(commandKey) === command &&
|
||||
window.localStorage.getItem(actionKey) === actionId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -76,3 +76,145 @@ test("assignChannel refreshes an existing assignment before the next eviction",
|
||||
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy subscription resolving after workspace cutover is disposed and cannot update canonical state", async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { finalizeEvent } = await import("nostr-tools/pure");
|
||||
const { relayClient } = await import("@/shared/api/relayClient");
|
||||
const { useChannelSections } = await import("./useChannelSections.ts");
|
||||
|
||||
const originalFetchEvents = relayClient.fetchEvents;
|
||||
const originalSubscribeLive = relayClient.subscribeLive;
|
||||
const originalSubscribeToReconnects = relayClient.subscribeToReconnects;
|
||||
const owner =
|
||||
"1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const relaySelf =
|
||||
"1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f";
|
||||
const ownerBytes = new Uint8Array(32).fill(1);
|
||||
const projection = {
|
||||
version: 1,
|
||||
owner_pubkey: owner,
|
||||
revision: 1,
|
||||
layout_revision: 1,
|
||||
key_epoch: 1,
|
||||
migration: {
|
||||
source_event_id:
|
||||
"2222222222222222222222222222222222222222222222222222222222222222",
|
||||
source_hash:
|
||||
"3333333333333333333333333333333333333333333333333333333333333333",
|
||||
},
|
||||
reader_key_envelope: "workspace-key-envelope",
|
||||
sections: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
rank: 0,
|
||||
encrypted_label: "canonical-label",
|
||||
encrypted_icon: null,
|
||||
},
|
||||
],
|
||||
assignments: [],
|
||||
};
|
||||
const projectionTemplate = {
|
||||
pubkey: owner,
|
||||
created_at: 1,
|
||||
kind: 30623,
|
||||
tags: [
|
||||
["d", owner],
|
||||
["p", owner],
|
||||
],
|
||||
content: JSON.stringify(projection),
|
||||
};
|
||||
const projectionEvent = finalizeEvent(projectionTemplate, ownerBytes);
|
||||
const legacyStore = {
|
||||
version: 1,
|
||||
sections: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
name: "legacy-name",
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
assignments: {},
|
||||
};
|
||||
const legacyEvent = {
|
||||
id: "4444444444444444444444444444444444444444444444444444444444444444",
|
||||
pubkey: owner,
|
||||
created_at: 2,
|
||||
kind: 30078,
|
||||
tags: [["d", "channel-sections"]],
|
||||
content: "legacy-ciphertext",
|
||||
sig: "legacy-signature",
|
||||
};
|
||||
let resolveLegacySubscription;
|
||||
let legacyLiveCallback;
|
||||
let disposerCalls = 0;
|
||||
relayClient.fetchEvents = async (filter) =>
|
||||
filter.kinds.includes(30623) ? [projectionEvent] : [];
|
||||
relayClient.subscribeLive = (filter, onEvent) => {
|
||||
if (filter.kinds.includes(30078)) {
|
||||
legacyLiveCallback = onEvent;
|
||||
return new Promise((resolve) => {
|
||||
resolveLegacySubscription = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(async () => {});
|
||||
};
|
||||
relayClient.subscribeToReconnects = () => () => {};
|
||||
const previousInternals = window.__TAURI_INTERNALS__;
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (command, args) => {
|
||||
if (command === "get_relay_self") return relaySelf;
|
||||
if (command === "nip44_decrypt_from_self") {
|
||||
return args.ciphertext === "legacy-ciphertext"
|
||||
? JSON.stringify(legacyStore)
|
||||
: "workspace-key";
|
||||
}
|
||||
if (command === "decrypt_workspace_metadata") return "canonical-name";
|
||||
throw new Error(`unexpected command ${command}`);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useChannelSections(owner, "wss://relay.example"),
|
||||
);
|
||||
const flush = async () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
await act(flush);
|
||||
assert.deepEqual(result.current.sections, [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
name: "canonical-name",
|
||||
order: 0,
|
||||
},
|
||||
]);
|
||||
assert.equal(typeof resolveLegacySubscription, "function");
|
||||
assert.equal(typeof legacyLiveCallback, "function");
|
||||
|
||||
resolveLegacySubscription(async () => {
|
||||
disposerCalls += 1;
|
||||
});
|
||||
await act(flush);
|
||||
assert.equal(disposerCalls, 1);
|
||||
|
||||
legacyLiveCallback(legacyEvent);
|
||||
await act(flush);
|
||||
assert.deepEqual(result.current.sections, [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
name: "canonical-name",
|
||||
order: 0,
|
||||
},
|
||||
]);
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
window.__TAURI_INTERNALS__ = previousInternals;
|
||||
relayClient.fetchEvents = originalFetchEvents;
|
||||
relayClient.subscribeLive = originalSubscribeLive;
|
||||
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -49,6 +49,9 @@ export function useChannelSections(
|
||||
);
|
||||
const lastAppliedRemoteTs = React.useRef(0);
|
||||
const lastAppliedEventId = React.useRef("");
|
||||
const legacySubscriptionRef = React.useRef<(() => Promise<void>) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey || !relayUrl) {
|
||||
@@ -68,6 +71,10 @@ export function useChannelSections(
|
||||
return () => {
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
if (legacySubscriptionRef.current) {
|
||||
void legacySubscriptionRef.current();
|
||||
legacySubscriptionRef.current = null;
|
||||
}
|
||||
workspaceManagerRef.current?.destroy();
|
||||
workspaceManagerRef.current = null;
|
||||
};
|
||||
@@ -79,7 +86,7 @@ export function useChannelSections(
|
||||
}
|
||||
const key = storageKey(pubkey, relayUrl);
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== key) {
|
||||
if (e.key !== key || workspaceManagerRef.current?.isCanonical()) {
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey, relayUrl));
|
||||
@@ -124,6 +131,11 @@ export function useChannelSections(
|
||||
const legacy = await legacyManager.fetchLegacySource();
|
||||
return legacy;
|
||||
});
|
||||
if (workspaceManager.isCanonical() && legacySubscriptionRef.current) {
|
||||
void legacySubscriptionRef.current();
|
||||
legacySubscriptionRef.current = null;
|
||||
legacyManager.cancelPendingPublish();
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (workspaceStore) setStore(workspaceStore);
|
||||
if (workspaceManager.isCanonical()) {
|
||||
@@ -134,6 +146,7 @@ export function useChannelSections(
|
||||
readChannelSectionsStore(pubkey, relayUrl),
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (workspaceManager.isCanonical()) return;
|
||||
if (result.action === "apply-remote") {
|
||||
setStore(applyRemote(result.data));
|
||||
}
|
||||
@@ -169,23 +182,25 @@ export function useChannelSections(
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let unsub: (() => Promise<void>) | null = null;
|
||||
let cancelled = false;
|
||||
void managerRef.current
|
||||
?.subscribeToSections((remote) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore(applyRemote(remote));
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (cancelled) {
|
||||
if (cancelled || workspaceManagerRef.current?.isCanonical()) {
|
||||
void dispose();
|
||||
} else {
|
||||
unsub = dispose;
|
||||
legacySubscriptionRef.current = dispose;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unsub) void unsub();
|
||||
if (legacySubscriptionRef.current) {
|
||||
void legacySubscriptionRef.current();
|
||||
legacySubscriptionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pubkey, applyRemote]);
|
||||
|
||||
@@ -193,11 +208,16 @@ export function useChannelSections(
|
||||
if (!pubkey) return;
|
||||
let cancelled = false;
|
||||
const unsub = relayClient.subscribeToReconnects(() => {
|
||||
if (workspaceManagerRef.current?.isCanonical()) {
|
||||
managerRef.current?.cancelPendingPublish();
|
||||
return;
|
||||
}
|
||||
void managerRef.current?.fetchRemoteSections().then((result) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || workspaceManagerRef.current?.isCanonical()) return;
|
||||
if (result.status === "found") {
|
||||
setStore(applyRemote(result.data));
|
||||
}
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
const pending = managerRef.current?.getPendingStore();
|
||||
if (pending) {
|
||||
managerRef.current?.publishSections(pending);
|
||||
@@ -218,6 +238,7 @@ export function useChannelSections(
|
||||
const createSection = React.useCallback(
|
||||
(name: string, icon?: string): ChannelSection | null => {
|
||||
if (!pubkey) return null;
|
||||
if (workspaceManagerRef.current?.isCanonical()) return null;
|
||||
const prev = readChannelSectionsStore(pubkey, relayUrl);
|
||||
const maxOrder =
|
||||
prev.sections.length > 0
|
||||
@@ -252,6 +273,7 @@ export function useChannelSections(
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
@@ -285,6 +307,7 @@ export function useChannelSections(
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
for (const channelId of Object.keys(assignments)) {
|
||||
@@ -314,6 +337,7 @@ export function useChannelSections(
|
||||
const moveSectionUp = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) return;
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const next = swapSectionOrder(prev, sectionId, "up");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
|
||||
@@ -332,6 +356,7 @@ export function useChannelSections(
|
||||
const moveSectionDown = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) return;
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const next = swapSectionOrder(prev, sectionId, "down");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next, relayUrl))
|
||||
@@ -350,6 +375,7 @@ export function useChannelSections(
|
||||
const reorderSections = React.useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
if (!pubkey) return;
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const sections = prev.sections.map((s) => {
|
||||
const newOrder = orderedIds.indexOf(s.id);
|
||||
@@ -373,6 +399,7 @@ export function useChannelSections(
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
@@ -400,6 +427,7 @@ export function useChannelSections(
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (workspaceManagerRef.current?.isCanonical()) return;
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
|
||||
Reference in New Issue
Block a user