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 * as React from "react";
|
||||||
|
|
||||||
|
import { relayClient } from "@/shared/api/relayClient";
|
||||||
import {
|
import {
|
||||||
DEFAULT_STORE,
|
DEFAULT_STORE,
|
||||||
readChannelSectionsStore,
|
readChannelSectionsStore,
|
||||||
storageKey,
|
storageKey,
|
||||||
writeChannelSectionsStore,
|
writeChannelSectionsStore,
|
||||||
} from "./channelSectionsStorage";
|
} 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";
|
export type { ChannelSection } from "./channelSectionsStorage";
|
||||||
|
|
||||||
@@ -33,12 +44,22 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
return readChannelSectionsStore(pubkey);
|
return readChannelSectionsStore(pubkey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lastAppliedRemoteTs = React.useRef(0);
|
||||||
|
const lastAppliedEventId = React.useRef("");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
setStore(DEFAULT_STORE);
|
setStore(DEFAULT_STORE);
|
||||||
|
lastAppliedRemoteTs.current = 0;
|
||||||
|
lastAppliedEventId.current = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStore(readChannelSectionsStore(pubkey));
|
setStore(readChannelSectionsStore(pubkey));
|
||||||
|
lastAppliedRemoteTs.current = 0;
|
||||||
|
lastAppliedEventId.current = "";
|
||||||
|
return () => {
|
||||||
|
resetSyncState();
|
||||||
|
};
|
||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -58,6 +79,88 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
};
|
};
|
||||||
}, [pubkey]);
|
}, [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[]>(
|
const sections = React.useMemo<ChannelSection[]>(
|
||||||
() => store.sections.slice().sort((a, b) => a.order - b.order),
|
() => store.sections.slice().sort((a, b) => a.order - b.order),
|
||||||
[store.sections],
|
[store.sections],
|
||||||
@@ -65,31 +168,27 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
|
|
||||||
const createSection = React.useCallback(
|
const createSection = React.useCallback(
|
||||||
(name: string): ChannelSection | null => {
|
(name: string): ChannelSection | null => {
|
||||||
if (!pubkey) {
|
if (!pubkey) return null;
|
||||||
return null;
|
const prev = readChannelSectionsStore(pubkey);
|
||||||
}
|
const maxOrder =
|
||||||
let created: ChannelSection | null = null;
|
prev.sections.length > 0
|
||||||
setStore((prev) => {
|
? Math.max(...prev.sections.map((s) => s.order))
|
||||||
const maxOrder =
|
: -1;
|
||||||
prev.sections.length > 0
|
const section: ChannelSection = {
|
||||||
? Math.max(...prev.sections.map((s) => s.order))
|
id: crypto.randomUUID(),
|
||||||
: -1;
|
name,
|
||||||
const section: ChannelSection = {
|
order: maxOrder + 1,
|
||||||
id: crypto.randomUUID(),
|
};
|
||||||
name,
|
setStore((current) => {
|
||||||
order: maxOrder + 1,
|
|
||||||
};
|
|
||||||
const next: ChannelSectionStore = {
|
const next: ChannelSectionStore = {
|
||||||
...prev,
|
...current,
|
||||||
sections: [...prev.sections, section],
|
sections: [...current.sections, section],
|
||||||
};
|
};
|
||||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
if (!writeChannelSectionsStore(pubkey, next)) return current;
|
||||||
return prev;
|
publishSections(next);
|
||||||
}
|
|
||||||
created = section;
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
return created;
|
return section;
|
||||||
},
|
},
|
||||||
[pubkey],
|
[pubkey],
|
||||||
);
|
);
|
||||||
@@ -109,6 +208,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
publishSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -135,6 +235,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
publishSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -143,33 +244,11 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
|
|
||||||
const moveSectionUp = React.useCallback(
|
const moveSectionUp = React.useCallback(
|
||||||
(sectionId: string) => {
|
(sectionId: string) => {
|
||||||
if (!pubkey) {
|
if (!pubkey) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStore((prev) => {
|
setStore((prev) => {
|
||||||
const target = prev.sections.find((s) => s.id === sectionId);
|
const next = swapSectionOrder(prev, sectionId, "up");
|
||||||
if (!target) {
|
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||||
return prev;
|
publishSections(next);
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -178,33 +257,11 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
|
|
||||||
const moveSectionDown = React.useCallback(
|
const moveSectionDown = React.useCallback(
|
||||||
(sectionId: string) => {
|
(sectionId: string) => {
|
||||||
if (!pubkey) {
|
if (!pubkey) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStore((prev) => {
|
setStore((prev) => {
|
||||||
const target = prev.sections.find((s) => s.id === sectionId);
|
const next = swapSectionOrder(prev, sectionId, "down");
|
||||||
if (!target) {
|
if (!next || !writeChannelSectionsStore(pubkey, next)) return prev;
|
||||||
return prev;
|
publishSections(next);
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -221,6 +278,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
});
|
});
|
||||||
const next: ChannelSectionStore = { ...prev, sections };
|
const next: ChannelSectionStore = { ...prev, sections };
|
||||||
if (!writeChannelSectionsStore(pubkey, next)) return prev;
|
if (!writeChannelSectionsStore(pubkey, next)) return prev;
|
||||||
|
publishSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -240,6 +298,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
publishSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -258,6 +317,7 @@ export function useChannelSections(pubkey: string | undefined): {
|
|||||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
publishSections(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
|
|||||||
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
||||||
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
||||||
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
||||||
|
import { resetSyncState } from "@/features/sidebar/lib/channelSectionsSync";
|
||||||
|
|
||||||
import { initFirstWorkspace } from "./workspaceStorage";
|
import { initFirstWorkspace } from "./workspaceStorage";
|
||||||
import type { Workspace } from "./types";
|
import type { Workspace } from "./types";
|
||||||
@@ -26,6 +27,7 @@ function resetWorkspaceState(): void {
|
|||||||
resetMediaCaches();
|
resetMediaCaches();
|
||||||
clearSearchHitEventCache();
|
clearSearchHitEventCache();
|
||||||
clearAllDrafts();
|
clearAllDrafts();
|
||||||
|
resetSyncState();
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceInitResult =
|
type WorkspaceInitResult =
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export const KIND_FORUM_POST = 45001;
|
|||||||
export const KIND_FORUM_COMMENT = 45003;
|
export const KIND_FORUM_COMMENT = 45003;
|
||||||
export const KIND_APPROVAL_REQUEST = 46010;
|
export const KIND_APPROVAL_REQUEST = 46010;
|
||||||
export const KIND_TYPING_INDICATOR = 20002;
|
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_READ_STATE = 30078;
|
||||||
|
export const KIND_CHANNEL_SECTIONS = 30078;
|
||||||
export const KIND_USER_STATUS = 30315;
|
export const KIND_USER_STATUS = 30315;
|
||||||
export const KIND_AGENT_OBSERVER_FRAME = 24200;
|
export const KIND_AGENT_OBSERVER_FRAME = 24200;
|
||||||
export const KIND_REPO_ANNOUNCEMENT = 30617;
|
export const KIND_REPO_ANNOUNCEMENT = 30617;
|
||||||
|
|||||||
Reference in New Issue
Block a user