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..6ff37b19b --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -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 { + 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 = { + 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> { + 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..f4bd12ec1 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,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) | 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 +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; }); }, 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; diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart new file mode 100644 index 000000000..e59b978ab --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../shared/crypto/nip44.dart'; +import '../../../shared/relay/relay.dart'; +import '../read_state/read_state_time.dart'; +import 'channel_sections_storage.dart'; + +const _uuid = Uuid(); + +class ChannelSectionsCrypto { + final Uint8List _conversationKey; + + ChannelSectionsCrypto(String nsec, String pubkey) + : _conversationKey = _deriveKey(nsec, pubkey); + + static Uint8List _deriveKey(String nsec, String pubkey) { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + return getConversationKey(privkeyHex, pubkey); + } + + String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext); + + String decrypt(String ciphertext) => + nip44Decrypt(_conversationKey, ciphertext); +} + +class ChannelSectionsManager { + final String pubkey; + final ChannelSectionsStorage _storage; + final ChannelSectionsCrypto _crypto; + final RelaySessionNotifier? _relaySession; + final SignedEventRelay? _signedEventRelay; + final bool _remoteEnabled; + final VoidCallback _onChanged; + + ChannelSectionStore _store; + Timer? _publishDebounce; + int _lastRemoteCreatedAt = 0; + void Function()? _unsubscribe; + bool _disposed = false; + + ChannelSectionsManager({ + required this.pubkey, + required SharedPreferences prefs, + required ChannelSectionsCrypto crypto, + required RelaySessionNotifier? relaySession, + required SignedEventRelay? signedEventRelay, + required bool remoteEnabled, + required VoidCallback onChanged, + }) : _storage = ChannelSectionsStorage(prefs), + _crypto = crypto, + _relaySession = relaySession, + _signedEventRelay = signedEventRelay, + _remoteEnabled = remoteEnabled, + _onChanged = onChanged, + _store = ChannelSectionsStorage(prefs).read(pubkey); + + ChannelSectionStore get store => _store; + + Future initialize() async { + if (_disposed) return; + + if (!_remoteEnabled || _relaySession == null) { + _onChanged(); + return; + } + + await _fetchAndMerge(); + await _startLiveSubscription(); + _onChanged(); + } + + void dispose({bool flushPending = true}) { + if (_disposed) return; + _disposed = true; + + final hadPending = _publishDebounce != null; + _publishDebounce?.cancel(); + _publishDebounce = null; + + if (flushPending && hadPending && _remoteEnabled) { + unawaited(_publish(allowDisposed: true)); + } + + _unsubscribe?.call(); + _unsubscribe = null; + } + + // ------------------------------------------------------------------------- + // CRUD + // ------------------------------------------------------------------------- + + void createSection(String name) { + if (_disposed) return; + final maxOrder = _store.sections.fold( + -1, + (max, s) => s.order > max ? s.order : max, + ); + final section = ChannelSection( + id: _uuid.v4(), + name: name.trim(), + order: maxOrder + 1, + ); + _store = ChannelSectionStore( + sections: [..._store.sections, section], + assignments: _store.assignments, + ); + _persist(); + markDirty(); + } + + void renameSection(String sectionId, String newName) { + if (_disposed) return; + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id == sectionId) + ChannelSection(id: s.id, name: newName.trim(), order: s.order) + else + s, + ], + assignments: _store.assignments, + ); + _persist(); + markDirty(); + } + + void deleteSection(String sectionId) { + if (_disposed) return; + final updatedAssignments = Map.from(_store.assignments) + ..removeWhere((_, sid) => sid == sectionId); + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id != sectionId) s, + ], + assignments: updatedAssignments, + ); + _persist(); + markDirty(); + } + + void moveSectionUp(String sectionId) { + if (_disposed) return; + final sorted = _sortedSections(); + final idx = sorted.indexWhere((s) => s.id == sectionId); + if (idx <= 0) return; + _swapOrders(sorted, idx, idx - 1); + markDirty(); + } + + void moveSectionDown(String sectionId) { + if (_disposed) return; + final sorted = _sortedSections(); + final idx = sorted.indexWhere((s) => s.id == sectionId); + if (idx < 0 || idx >= sorted.length - 1) return; + _swapOrders(sorted, idx, idx + 1); + markDirty(); + } + + void assignChannel(String channelId, String sectionId) { + if (_disposed) return; + final updated = Map.from(_store.assignments) + ..[channelId] = sectionId; + _store = ChannelSectionStore( + sections: _store.sections, + assignments: updated, + ); + _persist(); + markDirty(); + } + + void unassignChannel(String channelId) { + if (_disposed) return; + final updated = Map.from(_store.assignments) + ..remove(channelId); + _store = ChannelSectionStore( + sections: _store.sections, + assignments: updated, + ); + _persist(); + markDirty(); + } + + void markDirty() { + if (!_remoteEnabled || _disposed) return; + _publishDebounce?.cancel(); + _publishDebounce = Timer(const Duration(seconds: 5), () { + _publishDebounce = null; + unawaited(_publish()); + }); + } + + // ------------------------------------------------------------------------- + // Remote sync + // ------------------------------------------------------------------------- + + Future _fetchAndMerge() async { + if (_relaySession == null) return; + try { + final events = await _relaySession.fetchHistory( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': ['channel-sections'], + }, + limit: 1, + ), + ); + _mergeEvents(events); + _persist(); + if (!_disposed) _onChanged(); + } catch (_) { + // Local state remains usable when relay is unavailable. + } + } + + Future _startLiveSubscription() async { + if (_relaySession == null) return; + try { + _unsubscribe = await _relaySession.subscribe( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': ['channel-sections'], + }, + limit: 1, + ), + _handleIncomingEvent, + ); + } catch (_) { + // Non-fatal — local state and history still work. + } + } + + void _mergeEvents(List events) { + for (final event in events) { + if (event.pubkey != pubkey) continue; + _mergeEvent(event); + } + } + + void _mergeEvent(NostrEvent event) { + // Only process channel-sections d-tag events. + final dTag = event.getTagValue('d'); + if (dTag != 'channel-sections') return; + + try { + final plaintext = _crypto.decrypt(event.content); + final parsed = jsonDecode(plaintext); + if (parsed is! Map) return; + + final incoming = ChannelSectionStore.fromJson(parsed); + + // Last-write-wins: newer createdAt wins; tie-break by event ID. + final isNewer = + event.createdAt > _lastRemoteCreatedAt || + (event.createdAt == _lastRemoteCreatedAt && + event.id.compareTo(_lastRemoteEventId ?? '') > 0); + + if (isNewer) { + _lastRemoteCreatedAt = event.createdAt; + _lastRemoteEventId = event.id; + _store = incoming; + _persist(); + } + } catch (_) { + // Decryption failure or parse error — keep existing state. + } + } + + String? _lastRemoteEventId; + + void _handleIncomingEvent(NostrEvent event) { + if (_disposed) return; + _mergeEvent(event); + if (!_disposed) _onChanged(); + } + + Future _publish({bool allowDisposed = false}) async { + if ((!allowDisposed && _disposed) || + !_remoteEnabled || + _signedEventRelay == null) { + return; + } + + try { + final payload = jsonEncode(_store.toJson()); + final ciphertext = _crypto.encrypt(payload); + final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1); + + await _signedEventRelay.submit( + kind: EventKind.readState, + content: ciphertext, + tags: [ + ['d', 'channel-sections'], + ['t', 'channel-sections'], + ], + createdAt: createdAt, + ); + + _lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt); + } catch (error) { + debugPrint('[ChannelSectionsManager] publish failed: $error'); + } + } + + void _persist() { + _storage.write(pubkey, _store); + } + + List _sortedSections() { + final sorted = _store.sections.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + return sorted; + } + + void _swapOrders(List sorted, int indexA, int indexB) { + final orderA = sorted[indexA].order; + final orderB = sorted[indexB].order; + final idA = sorted[indexA].id; + final idB = sorted[indexB].id; + + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id == idA) + ChannelSection(id: s.id, name: s.name, order: orderB) + else if (s.id == idB) + ChannelSection(id: s.id, name: s.name, order: orderA) + else + s, + ], + assignments: _store.assignments, + ); + _persist(); + } +} diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart new file mode 100644 index 000000000..233997175 --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -0,0 +1,141 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../../shared/relay/relay.dart'; +import '../../../shared/theme/theme_provider.dart'; +import '../../../shared/workspace/workspace_provider.dart'; +import 'channel_sections_manager.dart'; +import 'channel_sections_storage.dart'; + +class ChannelSectionsState { + final bool isReady; + final ChannelSectionStore store; + + /// Bumped on every change to force downstream rebuilds. + final int version; + + const ChannelSectionsState({ + this.isReady = false, + this.store = const ChannelSectionStore(), + this.version = 0, + }); +} + +class ChannelSectionsNotifier extends Notifier { + ChannelSectionsManager? _manager; + + @override + ChannelSectionsState build() { + _manager?.dispose(flushPending: false); + _manager = null; + + final relayConfig = ref.watch(relayConfigProvider); + final sessionState = ref.watch(relaySessionProvider); + // Rebuild when the active workspace changes (pubkey may differ). + ref.watch(activeWorkspaceProvider); + + final nsec = relayConfig.nsec?.trim(); + if (nsec == null || nsec.isEmpty) { + return const ChannelSectionsState(); + } + + final pubkey = _safePubkeyFromNsec(nsec); + if (pubkey == null || pubkey.isEmpty) { + return const ChannelSectionsState(); + } + + final ChannelSectionsCrypto crypto; + try { + crypto = ChannelSectionsCrypto(nsec, pubkey); + } catch (_) { + return const ChannelSectionsState(); + } + + final prefs = ref.read(savedPrefsProvider); + final signedRelay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: nsec, + ); + + late final ChannelSectionsManager manager; + manager = ChannelSectionsManager( + pubkey: pubkey, + prefs: prefs, + crypto: crypto, + relaySession: ref.read(relaySessionProvider.notifier), + signedEventRelay: signedRelay, + remoteEnabled: sessionState.status == SessionStatus.connected, + onChanged: () => _emitManagerState(manager), + ); + _manager = manager; + + ref.onDispose(() { + manager.dispose(); + if (_manager == manager) { + _manager = null; + } + }); + + Future.microtask(() async { + await manager.initialize(); + if (_manager != manager) return; + _emitManagerState(manager); + }); + + return ChannelSectionsState( + isReady: false, + store: manager.store, + version: 1, + ); + } + + // ------------------------------------------------------------------------- + // CRUD delegates + // ------------------------------------------------------------------------- + + void createSection(String name) => _manager?.createSection(name); + + void renameSection(String sectionId, String newName) => + _manager?.renameSection(sectionId, newName); + + void deleteSection(String sectionId) => _manager?.deleteSection(sectionId); + + void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId); + + void moveSectionDown(String sectionId) => + _manager?.moveSectionDown(sectionId); + + void assignChannel(String channelId, String sectionId) => + _manager?.assignChannel(channelId, sectionId); + + void unassignChannel(String channelId) => + _manager?.unassignChannel(channelId); + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + + void _emitManagerState(ChannelSectionsManager manager) { + if (_manager != manager) return; + state = ChannelSectionsState( + isReady: true, + store: manager.store, + version: state.version + 1, + ); + } +} + +final channelSectionsProvider = + NotifierProvider( + ChannelSectionsNotifier.new, + ); + +String? _safePubkeyFromNsec(String nsec) { + try { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + if (privkeyHex.isEmpty) return null; + return nostr.Keys(privkeyHex).public; + } catch (_) { + return null; + } +} diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart new file mode 100644 index 000000000..47011941c --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +String channelSectionsKey(String pubkey) => + 'sprout.channel-sections.v1:$pubkey'; + +class ChannelSection { + final String id; + final String name; + final int order; + + const ChannelSection({ + required this.id, + required this.name, + required this.order, + }); + + Map toJson() => {'id': id, 'name': name, 'order': order}; + + factory ChannelSection.fromJson(Map json) => ChannelSection( + id: json['id'] as String, + name: json['name'] as String, + order: json['order'] as int, + ); +} + +class ChannelSectionStore { + final int version; + final List sections; + final Map assignments; + + const ChannelSectionStore({ + this.version = 1, + this.sections = const [], + this.assignments = const {}, + }); + + Map toJson() => { + 'version': version, + 'sections': sections.map((s) => s.toJson()).toList(), + 'assignments': assignments, + }; + + factory ChannelSectionStore.fromJson(Map json) { + final rawSections = json['sections']; + final sections = []; + if (rawSections is List) { + for (final entry in rawSections) { + if (entry is Map && + entry['id'] is String && + entry['name'] is String && + entry['order'] is int) { + sections.add(ChannelSection.fromJson(entry)); + } + } + } + + final rawAssignments = json['assignments']; + final assignments = {}; + if (rawAssignments is Map) { + for (final entry in rawAssignments.entries) { + if (entry.key is String && entry.value is String) { + assignments[entry.key as String] = entry.value as String; + } + } + } + + // Strip assignments referencing sections that don't exist. + final sectionIds = {for (final s in sections) s.id}; + assignments.removeWhere((_, sectionId) => !sectionIds.contains(sectionId)); + + return ChannelSectionStore( + version: 1, + sections: sections, + assignments: assignments, + ); + } +} + +class ChannelSectionsStorage { + final SharedPreferences _prefs; + + ChannelSectionsStorage(this._prefs); + + ChannelSectionStore read(String pubkey) { + final raw = _prefs.getString(channelSectionsKey(pubkey)); + if (raw == null || raw.isEmpty) { + return const ChannelSectionStore(); + } + + try { + final parsed = jsonDecode(raw); + if (parsed is! Map) { + return const ChannelSectionStore(); + } + if (parsed['version'] != 1) { + return const ChannelSectionStore(); + } + return ChannelSectionStore.fromJson(parsed); + } catch (_) { + return const ChannelSectionStore(); + } + } + + void write(String pubkey, ChannelSectionStore store) { + _prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson())); + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 36eb7ed6b..ad96e59cf 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -21,6 +21,8 @@ import '../pairing/pairing_provider.dart'; import 'channel.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; +import 'channel_sections/channel_sections_storage.dart'; import 'channels_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; @@ -272,6 +274,7 @@ class _SliverChannelsList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final readState = ref.watch(readStateProvider); + final sectionsState = ref.watch(channelSectionsProvider); final visibleChannels = channels .where((channel) => channel.isMember && !channel.isArchived) .toList(); @@ -333,6 +336,33 @@ class _SliverChannelsList extends HookConsumerWidget { } : const {}; + // Build sorted user-defined sections and compute which stream channels + // belong to each section. Channels not assigned to any valid section fall + // through to the built-in "Channels" list. + final userSections = sectionsState.store.sections.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + final sectionAssignments = sectionsState.store.assignments; + final validSectionIds = {for (final s in userSections) s.id}; + final assignedChannelIds = { + for (final entry in sectionAssignments.entries) + if (validSectionIds.contains(entry.value)) entry.key, + }; + final ungroupedStreamChannels = streamChannels + .where((c) => !assignedChannelIds.contains(c.id)) + .toList(); + + final sectionExpandedStates = useState>({}); + + bool sectionExpanded(String sectionId) => + sectionExpandedStates.value[sectionId] ?? true; + + void toggleSection(String sectionId) { + sectionExpandedStates.value = { + ...sectionExpandedStates.value, + sectionId: !sectionExpanded(sectionId), + }; + } + return SliverPadding( padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), sliver: SliverList.list( @@ -340,12 +370,87 @@ class _SliverChannelsList extends HookConsumerWidget { if (visibleChannels.isEmpty) const _EmptyState() else ...[ + // User-defined sections for stream channels, in user-defined order. + for (final section in userSections) + _CustomChannelSection( + section: section, + channels: streamChannels + .where((c) => sectionAssignments[c.id] == section.id) + .toList(), + unreadChannelIds: unreadChannelIds, + currentPubkey: currentPubkey, + expanded: sectionExpanded(section.id), + isFirst: userSections.first.id == section.id, + isLast: userSections.last.id == section.id, + onToggle: () => toggleSection(section.id), + onRename: () async { + final name = await showDialog( + context: context, + builder: (_) => _SectionNameDialog( + title: 'Rename Section', + confirmLabel: 'Rename', + initialValue: section.name, + ), + ); + if (name != null && name.isNotEmpty) { + ref + .read(channelSectionsProvider.notifier) + .renameSection(section.id, name); + } + }, + onDelete: () async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Delete "${section.name}"?'), + content: const Text( + 'Channels in this section will move back to the main list.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Delete', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ); + if (confirmed == true) { + ref + .read(channelSectionsProvider.notifier) + .deleteSection(section.id); + } + }, + onMoveUp: () => ref + .read(channelSectionsProvider.notifier) + .moveSectionUp(section.id), + onMoveDown: () => ref + .read(channelSectionsProvider.notifier) + .moveSectionDown(section.id), + onSelectChannel: onSelectChannel, + onMarkChannelRead: (channel) { + final ts = dateTimeToUnixSeconds(channel.lastMessageAt); + if (ts != null) { + ref + .read(readStateProvider.notifier) + .markContextRead(channel.id, ts); + } + }, + ), _ChannelSection( title: 'Channels', icon: LucideIcons.hash, expanded: channelsExpanded.value, onToggle: () => channelsExpanded.value = !channelsExpanded.value, - channels: streamChannels, + channels: ungroupedStreamChannels, unreadChannelIds: unreadChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No stream channels yet', @@ -380,6 +485,230 @@ class _SliverChannelsList extends HookConsumerWidget { } } +// --------------------------------------------------------------------------- +// User-defined channel sections +// --------------------------------------------------------------------------- + +class _CustomChannelSection extends StatelessWidget { + final ChannelSection section; + final List channels; + final Set unreadChannelIds; + final String? currentPubkey; + final bool expanded; + final bool isFirst; + final bool isLast; + final VoidCallback onToggle; + final VoidCallback onRename; + final VoidCallback onDelete; + final VoidCallback onMoveUp; + final VoidCallback onMoveDown; + final Future Function(Channel channel) onSelectChannel; + final void Function(Channel channel) onMarkChannelRead; + + const _CustomChannelSection({ + required this.section, + required this.channels, + required this.unreadChannelIds, + required this.currentPubkey, + required this.expanded, + required this.isFirst, + required this.isLast, + required this.onToggle, + required this.onRename, + required this.onDelete, + required this.onMoveUp, + required this.onMoveDown, + required this.onSelectChannel, + required this.onMarkChannelRead, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CustomSectionHeader( + section: section, + expanded: expanded, + isFirst: isFirst, + isLast: isLast, + onToggle: onToggle, + onRename: onRename, + onDelete: onDelete, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + ), + if (expanded) + for (final channel in channels) + _ChannelTile( + channel: channel, + isUnread: unreadChannelIds.contains(channel.id), + currentPubkey: currentPubkey, + onTap: () => onSelectChannel(channel), + onMarkRead: () => onMarkChannelRead(channel), + sectionId: section.id, + ), + ], + ); + } +} + +class _CustomSectionHeader extends StatelessWidget { + final ChannelSection section; + final bool expanded; + final bool isFirst; + final bool isLast; + final VoidCallback onToggle; + final VoidCallback onRename; + final VoidCallback onDelete; + final VoidCallback onMoveUp; + final VoidCallback onMoveDown; + + const _CustomSectionHeader({ + required this.section, + required this.expanded, + required this.isFirst, + required this.isLast, + required this.onToggle, + required this.onRename, + required this.onDelete, + required this.onMoveUp, + required this.onMoveDown, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.xs, + Grid.twelve, + Grid.xs, + Grid.half, + ), + child: Row( + children: [ + Icon( + LucideIcons.folder, + size: 14, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.half), + Text( + section.name.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const Spacer(), + GestureDetector( + onTapUp: (details) async { + final overlay = + Overlay.of(context).context.findRenderObject()! + as RenderBox; + final position = RelativeRect.fromRect( + details.globalPosition & Size.zero, + Offset.zero & overlay.size, + ); + final value = await showMenu( + context: context, + position: position, + items: [ + const PopupMenuItem(value: 'rename', child: Text('Rename')), + PopupMenuItem( + value: 'move_up', + enabled: !isFirst, + child: const Text('Move Up'), + ), + PopupMenuItem( + value: 'move_down', + enabled: !isLast, + child: const Text('Move Down'), + ), + const PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ); + switch (value) { + case 'rename': + onRename(); + case 'move_up': + onMoveUp(); + case 'move_down': + onMoveDown(); + case 'delete': + onDelete(); + } + }, + child: Icon( + LucideIcons.ellipsisVertical, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(width: Grid.quarter), + Icon( + expanded ? LucideIcons.chevronDown : LucideIcons.chevronRight, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Section name dialog (create / rename) +// --------------------------------------------------------------------------- + +class _SectionNameDialog extends HookWidget { + final String title; + final String confirmLabel; + final String initialValue; + + const _SectionNameDialog({ + required this.title, + required this.confirmLabel, + this.initialValue = '', + }); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + + void confirm() { + final name = controller.text.trim(); + if (name.isNotEmpty) Navigator.of(context).pop(name); + } + + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (_) => confirm(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton(onPressed: confirm, child: Text(confirmLabel)), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Built-in channel sections (Channels / Forums / DMs) +// --------------------------------------------------------------------------- + class _ChannelSection extends StatelessWidget { final String title; final IconData icon; @@ -437,6 +766,8 @@ class _ChannelSection extends StatelessWidget { isUnread: unreadChannelIds.contains(channel.id), currentPubkey: currentPubkey, onTap: () => onSelectChannel(channel), + onMarkRead: null, + sectionId: null, ), ], ], @@ -530,11 +861,20 @@ class _ChannelTile extends ConsumerWidget { final String? currentPubkey; final VoidCallback onTap; + /// Called when the user requests to mark this channel read (from long-press + /// actions menu). Null for channels in built-in sections. + final VoidCallback? onMarkRead; + + /// The user-defined section this channel currently belongs to, or null. + final String? sectionId; + const _ChannelTile({ required this.channel, required this.isUnread, required this.currentPubkey, required this.onTap, + this.onMarkRead, + this.sectionId, }); @override @@ -544,6 +884,7 @@ class _ChannelTile extends ConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: onTap, + onLongPress: () => _showChannelActions(context, ref), child: Padding( padding: const EdgeInsets.only( left: Grid.xs + Grid.xxs, @@ -626,6 +967,153 @@ class _ChannelTile extends ConsumerWidget { ), ); } + + void _showChannelActions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final sections = ref.read(channelSectionsProvider).store.sections + ..sort((a, b) => a.order.compareTo(b.order)); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.folderInput), + title: const Text('Move to section'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _showMoveSectionSheet(context, ref, sections); + }, + ), + ListTile( + leading: Icon( + isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, + ), + title: Text(isUnread ? 'Mark as read' : 'Mark as unread'), + onTap: () { + Navigator.of(sheetContext).pop(); + final ts = dateTimeToUnixSeconds(channel.lastMessageAt); + if (ts != null) { + if (isUnread) { + onMarkRead?.call(); + ref + .read(readStateProvider.notifier) + .markContextRead(channel.id, ts); + } else { + ref + .read(readStateProvider.notifier) + .markContextUnread(channel.id, ts); + } + } + }, + ), + ], + ), + ), + ); + }, + ); + } + + Future _showMoveSectionSheet( + BuildContext context, + WidgetRef ref, + List sections, + ) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final section in sections) + ListTile( + leading: Icon( + LucideIcons.folder, + color: sectionId == section.id + ? Theme.of(sheetContext).colorScheme.primary + : null, + ), + title: Text(section.name), + trailing: sectionId == section.id + ? Icon( + LucideIcons.check, + color: Theme.of(sheetContext).colorScheme.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .assignChannel(channel.id, section.id); + }, + ), + ListTile( + leading: const Icon(LucideIcons.folderPlus), + title: const Text('New section…'), + onTap: () async { + Navigator.of(sheetContext).pop(); + if (!context.mounted) return; + final name = await showDialog( + context: context, + builder: (_) => const _SectionNameDialog( + title: 'New Section', + confirmLabel: 'Create', + ), + ); + if (name != null && name.isNotEmpty) { + ref + .read(channelSectionsProvider.notifier) + .createSection(name); + // Assign after create — sections list has been mutated, + // re-read to find the new section by name. + final newSection = ref + .read(channelSectionsProvider) + .store + .sections + .lastWhere( + (s) => s.name == name.trim(), + orElse: () => const ChannelSection( + id: '', + name: '', + order: -1, + ), + ); + if (newSection.id.isNotEmpty) { + ref + .read(channelSectionsProvider.notifier) + .assignChannel(channel.id, newSection.id); + } + } + }, + ), + if (sectionId != null) + ListTile( + leading: const Icon(LucideIcons.folderMinus), + title: const Text('Remove from section'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .unassignChannel(channel.id); + }, + ), + ], + ), + ), + ); + }, + ); + } } class _DmAvatar extends ConsumerWidget { diff --git a/mobile/lib/features/channels/read_state/read_state_manager.dart b/mobile/lib/features/channels/read_state/read_state_manager.dart index eb7548b29..631806fc6 100644 --- a/mobile/lib/features/channels/read_state/read_state_manager.dart +++ b/mobile/lib/features/channels/read_state/read_state_manager.dart @@ -107,6 +107,16 @@ class ReadStateManager { _advanceContext(contextId, unixTimestamp, publishable: true); } + void markContextUnread(String contextId, int lastMessageTimestamp) { + if (_disposed || lastMessageTimestamp <= 0) return; + final rollbackTo = lastMessageTimestamp - 1; + _effectiveState[contextId] = rollbackTo; + _publishableContextIds.add(contextId); + _persistLocalState(); + _onChanged(); + _schedulePublish(); + } + void seedContextRead(String contextId, int unixTimestamp) { _advanceContext(contextId, unixTimestamp, publishable: false); } diff --git a/mobile/lib/features/channels/read_state/read_state_provider.dart b/mobile/lib/features/channels/read_state/read_state_provider.dart index b310a4522..721e47cfa 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/features/channels/read_state/read_state_provider.dart @@ -121,6 +121,10 @@ class ReadStateNotifier extends Notifier { _manager?.markContextRead(contextId, unixTimestamp); } + void markContextUnread(String contextId, int lastMessageTimestamp) { + _manager?.markContextUnread(contextId, lastMessageTimestamp); + } + void seedContextRead(String contextId, int unixTimestamp) { _manager?.seedContextRead(contextId, unixTimestamp); }