diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs new file mode 100644 index 000000000..97362adf2 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs @@ -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); +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts new file mode 100644 index 000000000..fe41cfee6 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsHelpers.ts @@ -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 }; +} diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs new file mode 100644 index 000000000..390e255d4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts new file mode 100644 index 000000000..52008f77d --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -0,0 +1,146 @@ +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 { + 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 { + 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 { + try { + const payload = { + 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> { + 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; +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2052299b0..090b7e285 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -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,87 @@ export function useChannelSections(pubkey: string | undefined): { }; }, [pubkey]); + const applyRemote = React.useCallback( + ( + remote: RemoteSections, + ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { + 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) | 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( () => store.sections.slice().sort((a, b) => a.order - b.order), [store.sections], @@ -65,31 +167,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 +207,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -135,6 +234,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -143,33 +243,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 +256,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; }); }, @@ -240,6 +296,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -258,6 +315,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index 8da3e949d..335ed8147 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -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 = diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73028596f..bed88adad 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -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:" 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;