feat(desktop): sync channel sections across devices via Nostr (#792)

This commit is contained in:
Will Pfleger
2026-06-01 16:02:14 -04:00
committed by GitHub
parent 2b052eb465
commit 753d0fe264
7 changed files with 589 additions and 73 deletions
@@ -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 =
+3
View File
@@ -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;