mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): sync channel sections across devices via Nostr (#792)
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { swapSectionOrder } from "./channelSectionsHelpers.ts";
|
||||
|
||||
function makeStore(sections, assignments = {}) {
|
||||
return { version: 1, sections, assignments };
|
||||
}
|
||||
|
||||
function makeSection(id, name, order) {
|
||||
return { id, name, order };
|
||||
}
|
||||
|
||||
test("move up succeeds: middle section swaps order with the one above", () => {
|
||||
const store = makeStore([
|
||||
makeSection("a", "A", 0),
|
||||
makeSection("b", "B", 1),
|
||||
makeSection("c", "C", 2),
|
||||
]);
|
||||
const result = swapSectionOrder(store, "b", "up");
|
||||
assert.notEqual(result, null);
|
||||
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
|
||||
assert.equal(byId["b"], 0);
|
||||
assert.equal(byId["a"], 1);
|
||||
assert.equal(byId["c"], 2);
|
||||
});
|
||||
|
||||
test("move down succeeds: middle section swaps order with the one below", () => {
|
||||
const store = makeStore([
|
||||
makeSection("a", "A", 0),
|
||||
makeSection("b", "B", 1),
|
||||
makeSection("c", "C", 2),
|
||||
]);
|
||||
const result = swapSectionOrder(store, "b", "down");
|
||||
assert.notEqual(result, null);
|
||||
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
|
||||
assert.equal(byId["b"], 2);
|
||||
assert.equal(byId["c"], 1);
|
||||
assert.equal(byId["a"], 0);
|
||||
});
|
||||
|
||||
test("move up at top boundary returns null", () => {
|
||||
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
|
||||
assert.equal(swapSectionOrder(store, "a", "up"), null);
|
||||
});
|
||||
|
||||
test("move down at bottom boundary returns null", () => {
|
||||
const store = makeStore([makeSection("a", "A", 0), makeSection("b", "B", 1)]);
|
||||
assert.equal(swapSectionOrder(store, "b", "down"), null);
|
||||
});
|
||||
|
||||
test("non-existent section returns null", () => {
|
||||
const store = makeStore([makeSection("a", "A", 0)]);
|
||||
assert.equal(swapSectionOrder(store, "z", "up"), null);
|
||||
});
|
||||
|
||||
test("single section move up returns null", () => {
|
||||
const store = makeStore([makeSection("a", "A", 0)]);
|
||||
assert.equal(swapSectionOrder(store, "a", "up"), null);
|
||||
});
|
||||
|
||||
test("single section move down returns null", () => {
|
||||
const store = makeStore([makeSection("a", "A", 0)]);
|
||||
assert.equal(swapSectionOrder(store, "a", "down"), null);
|
||||
});
|
||||
|
||||
test("non-contiguous orders: swap uses actual order values not indices", () => {
|
||||
const store = makeStore([
|
||||
makeSection("a", "A", 0),
|
||||
makeSection("b", "B", 5),
|
||||
makeSection("c", "C", 10),
|
||||
]);
|
||||
const result = swapSectionOrder(store, "b", "up");
|
||||
assert.notEqual(result, null);
|
||||
const byId = Object.fromEntries(result.sections.map((s) => [s.id, s.order]));
|
||||
assert.equal(byId["b"], 0);
|
||||
assert.equal(byId["a"], 5);
|
||||
assert.equal(byId["c"], 10);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChannelSectionStore } from "./channelSectionsStorage";
|
||||
|
||||
export function swapSectionOrder(
|
||||
prev: ChannelSectionStore,
|
||||
sectionId: string,
|
||||
direction: "up" | "down",
|
||||
): ChannelSectionStore | null {
|
||||
const target = prev.sections.find((s) => s.id === sectionId);
|
||||
if (!target) return null;
|
||||
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((s) => s.id === sectionId);
|
||||
const neighborIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (neighborIdx < 0 || neighborIdx >= sorted.length) return null;
|
||||
const neighbor = sorted[neighborIdx];
|
||||
const sections = prev.sections.map((s) => {
|
||||
if (s.id === target.id) return { ...s, order: neighbor.order };
|
||||
if (s.id === neighbor.id) return { ...s, order: target.order };
|
||||
return s;
|
||||
});
|
||||
return { ...prev, sections };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_STORE,
|
||||
parseChannelSectionPayload,
|
||||
readChannelSectionsStore,
|
||||
storageKey,
|
||||
stripOrphanedAssignments,
|
||||
writeChannelSectionsStore,
|
||||
} from "./channelSectionsStorage.ts";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
sections: overrides.sections ?? [{ id: "s1", name: "Test", order: 0 }],
|
||||
assignments: overrides.assignments ?? {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSection(overrides = {}) {
|
||||
return { id: "s1", name: "Test", order: 0, ...overrides };
|
||||
}
|
||||
|
||||
test("parseChannelSectionPayload: valid complete payload returns correct store", () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
sections: [{ id: "s1", name: "Work", order: 0 }],
|
||||
assignments: { chan1: "s1" },
|
||||
};
|
||||
const result = parseChannelSectionPayload(payload);
|
||||
assert.deepEqual(result, {
|
||||
version: 1,
|
||||
sections: [{ id: "s1", name: "Work", order: 0 }],
|
||||
assignments: { chan1: "s1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: null input returns null", () => {
|
||||
assert.equal(parseChannelSectionPayload(null), null);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: non-object input returns null", () => {
|
||||
assert.equal(parseChannelSectionPayload("string"), null);
|
||||
assert.equal(parseChannelSectionPayload(42), null);
|
||||
assert.equal(parseChannelSectionPayload(true), null);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: missing sections returns empty sections array", () => {
|
||||
const result = parseChannelSectionPayload({ assignments: {} });
|
||||
assert.deepEqual(result?.sections, []);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: malformed section entries are filtered out", () => {
|
||||
const payload = {
|
||||
sections: [
|
||||
{ id: 123, name: "Bad ID", order: 0 },
|
||||
{ id: "s1", name: 456, order: 0 },
|
||||
{ id: "s2", name: "Good", order: "not-a-number" },
|
||||
null,
|
||||
"string-entry",
|
||||
],
|
||||
assignments: {},
|
||||
};
|
||||
const result = parseChannelSectionPayload(payload);
|
||||
assert.deepEqual(result?.sections, []);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: valid sections with some invalid ones filters correctly", () => {
|
||||
const payload = {
|
||||
sections: [
|
||||
{ id: "s1", name: "Valid", order: 0 },
|
||||
{ id: 99, name: "Bad ID", order: 1 },
|
||||
{ id: "s2", name: "Also Valid", order: 2 },
|
||||
],
|
||||
assignments: {},
|
||||
};
|
||||
const result = parseChannelSectionPayload(payload);
|
||||
assert.deepEqual(result?.sections, [
|
||||
{ id: "s1", name: "Valid", order: 0 },
|
||||
{ id: "s2", name: "Also Valid", order: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: missing assignments returns empty assignments object", () => {
|
||||
const result = parseChannelSectionPayload({ sections: [] });
|
||||
assert.deepEqual(result?.assignments, {});
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: assignments with non-string values are filtered out", () => {
|
||||
const payload = {
|
||||
sections: [{ id: "s1", name: "Test", order: 0 }],
|
||||
assignments: { chan1: "s1", chan2: 42, chan3: null, chan4: true },
|
||||
};
|
||||
const result = parseChannelSectionPayload(payload);
|
||||
assert.deepEqual(result?.assignments, { chan1: "s1" });
|
||||
});
|
||||
|
||||
test("parseChannelSectionPayload: orphaned assignments are stripped", () => {
|
||||
const payload = {
|
||||
sections: [{ id: "s1", name: "Exists", order: 0 }],
|
||||
assignments: { chan1: "s1", chan2: "missing-section" },
|
||||
};
|
||||
const result = parseChannelSectionPayload(payload);
|
||||
assert.deepEqual(result?.assignments, { chan1: "s1" });
|
||||
});
|
||||
|
||||
test("stripOrphanedAssignments: store with no orphans returns same reference", () => {
|
||||
const store = makeStore({
|
||||
sections: [makeSection({ id: "s1" })],
|
||||
assignments: { chan1: "s1" },
|
||||
});
|
||||
assert.equal(stripOrphanedAssignments(store), store);
|
||||
});
|
||||
|
||||
test("stripOrphanedAssignments: store with orphaned assignments returns new object without them", () => {
|
||||
const store = makeStore({
|
||||
sections: [makeSection({ id: "s1" })],
|
||||
assignments: { chan1: "s1", chan2: "ghost" },
|
||||
});
|
||||
const result = stripOrphanedAssignments(store);
|
||||
assert.notEqual(result, store);
|
||||
assert.deepEqual(result.assignments, { chan1: "s1" });
|
||||
});
|
||||
|
||||
test("stripOrphanedAssignments: store with all valid assignments returns same reference", () => {
|
||||
const store = makeStore({
|
||||
sections: [
|
||||
makeSection({ id: "s1" }),
|
||||
makeSection({ id: "s2", name: "B", order: 1 }),
|
||||
],
|
||||
assignments: { chan1: "s1", chan2: "s2" },
|
||||
});
|
||||
assert.equal(stripOrphanedAssignments(store), store);
|
||||
});
|
||||
|
||||
test("stripOrphanedAssignments: empty store returns same reference", () => {
|
||||
const store = makeStore({ sections: [], assignments: {} });
|
||||
assert.equal(stripOrphanedAssignments(store), store);
|
||||
});
|
||||
|
||||
test("writeChannelSectionsStore + readChannelSectionsStore: write then read returns same data", () => {
|
||||
const pubkey = "pk-roundtrip";
|
||||
const store = makeStore({
|
||||
sections: [makeSection({ id: "s1", name: "Work", order: 0 })],
|
||||
assignments: { chan1: "s1" },
|
||||
});
|
||||
const written = writeChannelSectionsStore(pubkey, store);
|
||||
assert.equal(written, true);
|
||||
const result = readChannelSectionsStore(pubkey);
|
||||
assert.deepEqual(result, store);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: non-existent key returns DEFAULT_STORE", () => {
|
||||
const result = readChannelSectionsStore("pk-does-not-exist-xyz");
|
||||
assert.deepEqual(result, DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: corrupt JSON returns DEFAULT_STORE", () => {
|
||||
const pubkey = "pk-corrupt";
|
||||
window.localStorage.setItem(storageKey(pubkey), "not-valid-json{{{");
|
||||
const result = readChannelSectionsStore(pubkey);
|
||||
assert.deepEqual(result, DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("readChannelSectionsStore: object with wrong version returns DEFAULT_STORE", () => {
|
||||
const pubkey = "pk-wrong-version";
|
||||
window.localStorage.setItem(
|
||||
storageKey(pubkey),
|
||||
JSON.stringify({ version: 2, sections: [], assignments: {} }),
|
||||
);
|
||||
const result = readChannelSectionsStore(pubkey);
|
||||
assert.deepEqual(result, DEFAULT_STORE);
|
||||
});
|
||||
|
||||
test("writeChannelSectionsStore: returns false when setItem throws", () => {
|
||||
const pubkey = "pk-throws";
|
||||
const original = window.localStorage.setItem;
|
||||
window.localStorage.setItem = () => {
|
||||
throw new Error("storage full");
|
||||
};
|
||||
try {
|
||||
const result = writeChannelSectionsStore(pubkey, makeStore());
|
||||
assert.equal(result, false);
|
||||
} finally {
|
||||
window.localStorage.setItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("storageKey: returns expected format with pubkey", () => {
|
||||
assert.equal(storageKey("abc123"), "sprout-channel-sections.v1:abc123");
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
nip44DecryptFromSelf,
|
||||
nip44EncryptToSelf,
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds";
|
||||
import {
|
||||
parseChannelSectionPayload,
|
||||
type ChannelSectionStore,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
const D_TAG = "channel-sections";
|
||||
const DEBOUNCE_MS = 2_000;
|
||||
|
||||
export type RemoteSections = {
|
||||
store: ChannelSectionStore;
|
||||
createdAt: number;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
let debounceTimer: number | null = null;
|
||||
let lastRemoteCreatedAt = 0;
|
||||
let pendingStore: ChannelSectionStore | null = null;
|
||||
|
||||
async function decryptAndParse(
|
||||
event: RelayEvent,
|
||||
): Promise<RemoteSections | null> {
|
||||
try {
|
||||
const plaintext = await nip44DecryptFromSelf(event.content);
|
||||
const store = parseChannelSectionPayload(JSON.parse(plaintext));
|
||||
if (!store) return null;
|
||||
return { store, createdAt: event.created_at, eventId: event.id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRemoteSections(
|
||||
pubkey: string,
|
||||
): Promise<RemoteSections | null> {
|
||||
try {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_CHANNEL_SECTIONS],
|
||||
authors: [pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 1,
|
||||
});
|
||||
if (events.length === 0) return null;
|
||||
if (events[0].pubkey !== pubkey) return null;
|
||||
const result = await decryptAndParse(events[0]);
|
||||
if (result) {
|
||||
lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelPendingPublish(): void {
|
||||
if (debounceTimer !== null) {
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingStore(): ChannelSectionStore | null {
|
||||
return pendingStore;
|
||||
}
|
||||
|
||||
export function publishSections(store: ChannelSectionStore): void {
|
||||
pendingStore = store;
|
||||
if (debounceTimer !== null) {
|
||||
window.clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
void doPublish(store);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function doPublish(store: ChannelSectionStore): Promise<void> {
|
||||
try {
|
||||
const payload = {
|
||||
version: 1,
|
||||
sections: store.sections,
|
||||
assignments: store.assignments,
|
||||
};
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload));
|
||||
const createdAt = Math.max(
|
||||
Math.floor(Date.now() / 1_000),
|
||||
lastRemoteCreatedAt + 1,
|
||||
);
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_CHANNEL_SECTIONS,
|
||||
content: ciphertext,
|
||||
createdAt,
|
||||
tags: [
|
||||
["d", D_TAG],
|
||||
["t", D_TAG], // relay discoverability; not used in our filters
|
||||
],
|
||||
});
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out publishing channel sections.",
|
||||
"Failed to publish channel sections.",
|
||||
);
|
||||
lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at);
|
||||
pendingStore = null;
|
||||
} catch (error) {
|
||||
console.warn("[channelSectionsSync] publish failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToSections(
|
||||
pubkey: string,
|
||||
onUpdate: (remote: RemoteSections) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
return relayClient.subscribeLive(
|
||||
{
|
||||
kinds: [KIND_CHANNEL_SECTIONS],
|
||||
authors: [pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 0,
|
||||
},
|
||||
(event: RelayEvent) => {
|
||||
if (event.pubkey !== pubkey) return;
|
||||
void decryptAndParse(event).then((result) => {
|
||||
if (result) {
|
||||
lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt);
|
||||
onUpdate(result);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function resetSyncState(): void {
|
||||
if (debounceTimer !== null) {
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
lastRemoteCreatedAt = 0;
|
||||
pendingStore = null;
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
DEFAULT_STORE,
|
||||
readChannelSectionsStore,
|
||||
storageKey,
|
||||
writeChannelSectionsStore,
|
||||
} from "./channelSectionsStorage";
|
||||
import {
|
||||
cancelPendingPublish,
|
||||
fetchRemoteSections,
|
||||
getPendingStore,
|
||||
publishSections,
|
||||
resetSyncState,
|
||||
subscribeToSections,
|
||||
} from "./channelSectionsSync";
|
||||
import type { RemoteSections } from "./channelSectionsSync";
|
||||
import { swapSectionOrder } from "./channelSectionsHelpers";
|
||||
|
||||
export type { ChannelSection } from "./channelSectionsStorage";
|
||||
|
||||
@@ -33,12 +44,22 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
return readChannelSectionsStore(pubkey);
|
||||
});
|
||||
|
||||
const lastAppliedRemoteTs = React.useRef(0);
|
||||
const lastAppliedEventId = React.useRef("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setStore(DEFAULT_STORE);
|
||||
lastAppliedRemoteTs.current = 0;
|
||||
lastAppliedEventId.current = "";
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey));
|
||||
lastAppliedRemoteTs.current = 0;
|
||||
lastAppliedEventId.current = "";
|
||||
return () => {
|
||||
resetSyncState();
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -58,6 +79,88 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
const applyRemote = React.useCallback(
|
||||
(
|
||||
remote: RemoteSections,
|
||||
): ((prev: ChannelSectionStore) => ChannelSectionStore) => {
|
||||
return (prev) => {
|
||||
if (!pubkey) return prev;
|
||||
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
|
||||
if (
|
||||
remote.createdAt === lastAppliedRemoteTs.current &&
|
||||
remote.eventId <= lastAppliedEventId.current
|
||||
)
|
||||
return prev;
|
||||
lastAppliedRemoteTs.current = remote.createdAt;
|
||||
lastAppliedEventId.current = remote.eventId;
|
||||
cancelPendingPublish();
|
||||
if (!writeChannelSectionsStore(pubkey, remote.store)) return prev;
|
||||
return remote.store;
|
||||
};
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let cancelled = false;
|
||||
void fetchRemoteSections(pubkey).then((remote) => {
|
||||
if (cancelled) return;
|
||||
if (remote) {
|
||||
setStore(applyRemote(remote));
|
||||
} else {
|
||||
const local = readChannelSectionsStore(pubkey);
|
||||
if (local.sections.length > 0) {
|
||||
publishSections(local);
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey, applyRemote]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let unsub: (() => Promise<void>) | null = null;
|
||||
let cancelled = false;
|
||||
void subscribeToSections(pubkey, (remote) => {
|
||||
if (cancelled) return;
|
||||
setStore(applyRemote(remote));
|
||||
}).then((dispose) => {
|
||||
if (cancelled) {
|
||||
void dispose();
|
||||
} else {
|
||||
unsub = dispose;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unsub) void unsub();
|
||||
};
|
||||
}, [pubkey, applyRemote]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let cancelled = false;
|
||||
const unsub = relayClient.subscribeToReconnects(() => {
|
||||
void fetchRemoteSections(pubkey).then((remote) => {
|
||||
if (cancelled) return;
|
||||
if (remote) {
|
||||
setStore(applyRemote(remote));
|
||||
}
|
||||
const pending = getPendingStore();
|
||||
if (pending) {
|
||||
publishSections(pending);
|
||||
}
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
}, [pubkey, applyRemote]);
|
||||
|
||||
const sections = React.useMemo<ChannelSection[]>(
|
||||
() => store.sections.slice().sort((a, b) => a.order - b.order),
|
||||
[store.sections],
|
||||
@@ -65,31 +168,27 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
|
||||
const createSection = React.useCallback(
|
||||
(name: string): ChannelSection | null => {
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
let created: ChannelSection | null = null;
|
||||
setStore((prev) => {
|
||||
const maxOrder =
|
||||
prev.sections.length > 0
|
||||
? Math.max(...prev.sections.map((s) => s.order))
|
||||
: -1;
|
||||
const section: ChannelSection = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
order: maxOrder + 1,
|
||||
};
|
||||
if (!pubkey) return null;
|
||||
const prev = readChannelSectionsStore(pubkey);
|
||||
const maxOrder =
|
||||
prev.sections.length > 0
|
||||
? Math.max(...prev.sections.map((s) => s.order))
|
||||
: -1;
|
||||
const section: ChannelSection = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
order: maxOrder + 1,
|
||||
};
|
||||
setStore((current) => {
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
sections: [...prev.sections, section],
|
||||
...current,
|
||||
sections: [...current.sections, section],
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
created = section;
|
||||
if (!writeChannelSectionsStore(pubkey, next)) return current;
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
return created;
|
||||
return section;
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
@@ -109,6 +208,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -135,6 +235,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -143,33 +244,11 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
|
||||
const moveSectionUp = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (!pubkey) return;
|
||||
setStore((prev) => {
|
||||
const target = prev.sections.find((s) => s.id === sectionId);
|
||||
if (!target) {
|
||||
return prev;
|
||||
}
|
||||
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((s) => s.id === sectionId);
|
||||
if (idx <= 0) {
|
||||
return prev;
|
||||
}
|
||||
const neighbor = sorted[idx - 1];
|
||||
const sections = prev.sections.map((s) => {
|
||||
if (s.id === target.id) {
|
||||
return { ...s, order: neighbor.order };
|
||||
}
|
||||
if (s.id === neighbor.id) {
|
||||
return { ...s, order: target.order };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
const next = swapSectionOrder(prev, sectionId, "up");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -178,33 +257,11 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
|
||||
const moveSectionDown = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
if (!pubkey) return;
|
||||
setStore((prev) => {
|
||||
const target = prev.sections.find((s) => s.id === sectionId);
|
||||
if (!target) {
|
||||
return prev;
|
||||
}
|
||||
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((s) => s.id === sectionId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) {
|
||||
return prev;
|
||||
}
|
||||
const neighbor = sorted[idx + 1];
|
||||
const sections = prev.sections.map((s) => {
|
||||
if (s.id === target.id) {
|
||||
return { ...s, order: neighbor.order };
|
||||
}
|
||||
if (s.id === neighbor.id) {
|
||||
return { ...s, order: target.order };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
const next = swapSectionOrder(prev, sectionId, "down");
|
||||
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -221,6 +278,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -240,6 +298,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
@@ -258,6 +317,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
publishSections(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
|
||||
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
||||
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
||||
import { resetSyncState } from "@/features/sidebar/lib/channelSectionsSync";
|
||||
|
||||
import { initFirstWorkspace } from "./workspaceStorage";
|
||||
import type { Workspace } from "./types";
|
||||
@@ -26,6 +27,7 @@ function resetWorkspaceState(): void {
|
||||
resetMediaCaches();
|
||||
clearSearchHitEventCache();
|
||||
clearAllDrafts();
|
||||
resetSyncState();
|
||||
}
|
||||
|
||||
type WorkspaceInitResult =
|
||||
|
||||
@@ -16,7 +16,10 @@ export const KIND_FORUM_POST = 45001;
|
||||
export const KIND_FORUM_COMMENT = 45003;
|
||||
export const KIND_APPROVAL_REQUEST = 46010;
|
||||
export const KIND_TYPING_INDICATOR = 20002;
|
||||
// NIP-78 application-specific data. Both use kind 30078; the relay
|
||||
// differentiates them by d-tag ("read-state:<slotId>" vs "channel-sections").
|
||||
export const KIND_READ_STATE = 30078;
|
||||
export const KIND_CHANNEL_SECTIONS = 30078;
|
||||
export const KIND_USER_STATUS = 30315;
|
||||
export const KIND_AGENT_OBSERVER_FRAME = 24200;
|
||||
export const KIND_REPO_ANNOUNCEMENT = 30617;
|
||||
|
||||
Reference in New Issue
Block a user