From 67718d7c9199991fce4961186a627a4174c58ab1 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 14:22:36 -0400 Subject: [PATCH 1/8] feat(desktop): sync channel sections across devices via Nostr Channel sections were localStorage-only, meaning they didn't sync across devices. Adds a NIP-78 (kind 30078) event-based sync layer so sections created, renamed, deleted, or reordered on any device propagate to all others. localStorage is retained as a local cache for instant rendering on startup. --- .../sidebar/lib/channelSectionsSync.ts | 151 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 85 ++++++++++ desktop/src/shared/constants/kinds.ts | 1 + 3 files changed, 237 insertions(+) create mode 100644 desktop/src/features/sidebar/lib/channelSectionsSync.ts diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts new file mode 100644 index 000000000..f4769fcc2 --- /dev/null +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -0,0 +1,151 @@ +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 type { ChannelSectionStore } from "./channelSectionsStorage"; + +const D_TAG = "channel-sections"; +const DEBOUNCE_MS = 2_000; + +export type RemoteSections = { + store: ChannelSectionStore; + createdAt: number; +}; + +let debounceTimer: number | null = null; +let lastRemoteCreatedAt = 0; + +function parsePayload(json: unknown): ChannelSectionStore | null { + if (typeof json !== "object" || json === null) return null; + const obj = json as Record; + const sections = Array.isArray(obj.sections) + ? obj.sections.filter( + (e: unknown): e is { id: string; name: string; order: number } => + typeof e === "object" && + e !== null && + typeof (e as Record).id === "string" && + typeof (e as Record).name === "string" && + typeof (e as Record).order === "number", + ) + : []; + const assignments = + typeof obj.assignments === "object" && + obj.assignments !== null && + !Array.isArray(obj.assignments) + ? Object.fromEntries( + Object.entries(obj.assignments as Record).filter( + (e): e is [string, string] => typeof e[1] === "string", + ), + ) + : {}; + return { version: 1, sections, assignments }; +} + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const store = parsePayload(JSON.parse(plaintext)); + if (!store) return null; + return { store, createdAt: event.created_at }; + } 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; + const result = await decryptAndParse(events[0]); + if (result) { + lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); + } + return result; + } catch { + return null; + } +} + +export function publishSections(store: ChannelSectionStore): void { + if (debounceTimer !== null) { + window.clearTimeout(debounceTimer); + } + debounceTimer = window.setTimeout(() => { + debounceTimer = null; + void doPublish(store); + }, DEBOUNCE_MS); +} + +async function doPublish(store: ChannelSectionStore): Promise { + try { + const payload = { + sections: store.sections, + assignments: store.assignments, + }; + const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + lastRemoteCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_CHANNEL_SECTIONS, + content: ciphertext, + createdAt, + tags: [ + ["d", D_TAG], + ["t", D_TAG], + ], + }); + await relayClient.publishEvent( + event, + "Timed out publishing channel sections.", + "Failed to publish channel sections.", + ); + lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at); + } catch { + // Non-fatal: next mutation or reconnect will retry + } +} + +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) => { + 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; +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 2052299b0..a75796728 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -1,11 +1,18 @@ import * as React from "react"; +import { relayClient } from "@/shared/api/relayClient"; import { DEFAULT_STORE, readChannelSectionsStore, storageKey, writeChannelSectionsStore, } from "./channelSectionsStorage"; +import { + fetchRemoteSections, + publishSections, + resetSyncState, + subscribeToSections, +} from "./channelSectionsSync"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -33,12 +40,19 @@ export function useChannelSections(pubkey: string | undefined): { return readChannelSectionsStore(pubkey); }); + const lastAppliedRemoteTs = React.useRef(0); + React.useEffect(() => { if (!pubkey) { setStore(DEFAULT_STORE); + lastAppliedRemoteTs.current = 0; return; } setStore(readChannelSectionsStore(pubkey)); + lastAppliedRemoteTs.current = 0; + return () => { + resetSyncState(); + }; }, [pubkey]); React.useEffect(() => { @@ -58,6 +72,70 @@ export function useChannelSections(pubkey: string | undefined): { }; }, [pubkey]); + React.useEffect(() => { + if (!pubkey) return; + let cancelled = false; + void fetchRemoteSections(pubkey).then((remote) => { + if (cancelled) return; + if (remote) { + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + } else { + const local = readChannelSectionsStore(pubkey); + if (local.sections.length > 0) { + publishSections(local); + } + } + }); + return () => { + cancelled = true; + }; + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) return; + let unsub: (() => Promise) | null = null; + let cancelled = false; + void subscribeToSections(pubkey, (remote) => { + if (cancelled) return; + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + }).then((dispose) => { + if (cancelled) { + void dispose(); + } else { + unsub = dispose; + } + }); + return () => { + cancelled = true; + if (unsub) void unsub(); + }; + }, [pubkey]); + + React.useEffect(() => { + if (!pubkey) return; + return relayClient.subscribeToReconnects(() => { + void fetchRemoteSections(pubkey).then((remote) => { + if (!remote) return; + setStore((prev) => { + if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; + lastAppliedRemoteTs.current = remote.createdAt; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; + return remote.store; + }); + }); + }); + }, [pubkey]); + const sections = React.useMemo( () => store.sections.slice().sort((a, b) => a.order - b.order), [store.sections], @@ -87,6 +165,7 @@ export function useChannelSections(pubkey: string | undefined): { return prev; } created = section; + publishSections(next); return next; }); return created; @@ -109,6 +188,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -135,6 +215,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -170,6 +251,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -205,6 +287,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -240,6 +323,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, @@ -258,6 +342,7 @@ export function useChannelSections(pubkey: string | undefined): { if (!writeChannelSectionsStore(pubkey, next)) { return prev; } + publishSections(next); return next; }); }, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 73028596f..2f0b264dc 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -17,6 +17,7 @@ export const KIND_FORUM_COMMENT = 45003; export const KIND_APPROVAL_REQUEST = 46010; export const KIND_TYPING_INDICATOR = 20002; 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; From 951612df83b1f6367be1e57358412e6df2a4b6eb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 16:01:02 -0400 Subject: [PATCH 2/8] fix(desktop): harden channel sections sync from review findings Cancel stale debounce when applying remote updates to prevent publishing overwritten state. Track pending publishes for retry on reconnect. Add pubkey validation before decrypting relay events. Extract applyRemote reconcile helper and swapSectionOrder to eliminate duplication. Fix createSection to construct section before setStore so return value is deterministic. Add NIP-01 event ID tie-breaking. Wire resetSyncState into resetWorkspaceState. --- .../sidebar/lib/channelSectionsSync.ts | 59 +++--- .../sidebar/lib/useChannelSections.ts | 184 +++++++++--------- .../features/workspaces/useWorkspaceInit.ts | 2 + desktop/src/shared/constants/kinds.ts | 2 + 4 files changed, 119 insertions(+), 128 deletions(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index f4769fcc2..52008f77d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -6,7 +6,10 @@ import { } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; import { KIND_CHANNEL_SECTIONS } from "@/shared/constants/kinds"; -import type { ChannelSectionStore } from "./channelSectionsStorage"; +import { + parseChannelSectionPayload, + type ChannelSectionStore, +} from "./channelSectionsStorage"; const D_TAG = "channel-sections"; const DEBOUNCE_MS = 2_000; @@ -14,45 +17,21 @@ const DEBOUNCE_MS = 2_000; export type RemoteSections = { store: ChannelSectionStore; createdAt: number; + eventId: string; }; let debounceTimer: number | null = null; let lastRemoteCreatedAt = 0; - -function parsePayload(json: unknown): ChannelSectionStore | null { - if (typeof json !== "object" || json === null) return null; - const obj = json as Record; - const sections = Array.isArray(obj.sections) - ? obj.sections.filter( - (e: unknown): e is { id: string; name: string; order: number } => - typeof e === "object" && - e !== null && - typeof (e as Record).id === "string" && - typeof (e as Record).name === "string" && - typeof (e as Record).order === "number", - ) - : []; - const assignments = - typeof obj.assignments === "object" && - obj.assignments !== null && - !Array.isArray(obj.assignments) - ? Object.fromEntries( - Object.entries(obj.assignments as Record).filter( - (e): e is [string, string] => typeof e[1] === "string", - ), - ) - : {}; - return { version: 1, sections, assignments }; -} +let pendingStore: ChannelSectionStore | null = null; async function decryptAndParse( event: RelayEvent, ): Promise { try { const plaintext = await nip44DecryptFromSelf(event.content); - const store = parsePayload(JSON.parse(plaintext)); + const store = parseChannelSectionPayload(JSON.parse(plaintext)); if (!store) return null; - return { store, createdAt: event.created_at }; + return { store, createdAt: event.created_at, eventId: event.id }; } catch { return null; } @@ -69,6 +48,7 @@ export async function fetchRemoteSections( 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); @@ -79,7 +59,19 @@ export async function fetchRemoteSections( } } +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); } @@ -106,7 +98,7 @@ async function doPublish(store: ChannelSectionStore): Promise { createdAt, tags: [ ["d", D_TAG], - ["t", D_TAG], + ["t", D_TAG], // relay discoverability; not used in our filters ], }); await relayClient.publishEvent( @@ -115,8 +107,9 @@ async function doPublish(store: ChannelSectionStore): Promise { "Failed to publish channel sections.", ); lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, event.created_at); - } catch { - // Non-fatal: next mutation or reconnect will retry + pendingStore = null; + } catch (error) { + console.warn("[channelSectionsSync] publish failed:", error); } } @@ -132,6 +125,7 @@ export async function subscribeToSections( limit: 0, }, (event: RelayEvent) => { + if (event.pubkey !== pubkey) return; void decryptAndParse(event).then((result) => { if (result) { lastRemoteCreatedAt = Math.max(lastRemoteCreatedAt, result.createdAt); @@ -148,4 +142,5 @@ export function resetSyncState(): void { 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 a75796728..ecad246c8 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -8,11 +8,14 @@ import { writeChannelSectionsStore, } from "./channelSectionsStorage"; import { + cancelPendingPublish, fetchRemoteSections, + getPendingStore, publishSections, resetSyncState, subscribeToSections, } from "./channelSectionsSync"; +import type { RemoteSections } from "./channelSectionsSync"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -21,6 +24,26 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; +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 }; +} + export function useChannelSections(pubkey: string | undefined): { sections: ChannelSection[]; assignments: Record; @@ -41,15 +64,18 @@ export function useChannelSections(pubkey: string | undefined): { }); 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(); }; @@ -72,18 +98,34 @@ export function useChannelSections(pubkey: string | undefined): { }; }, [pubkey]); + const applyRemote = React.useCallback( + ( + remote: RemoteSections, + ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { + return (prev) => { + if (remote.createdAt < lastAppliedRemoteTs.current) return prev; + if ( + remote.createdAt === lastAppliedRemoteTs.current && + remote.eventId >= lastAppliedEventId.current + ) + return prev; + lastAppliedRemoteTs.current = remote.createdAt; + lastAppliedEventId.current = remote.eventId; + cancelPendingPublish(); + if (!writeChannelSectionsStore(pubkey!, remote.store)) return prev; + return remote.store; + }; + }, + [pubkey], + ); + React.useEffect(() => { if (!pubkey) return; let cancelled = false; void fetchRemoteSections(pubkey).then((remote) => { if (cancelled) return; if (remote) { - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + setStore(applyRemote(remote)); } else { const local = readChannelSectionsStore(pubkey); if (local.sections.length > 0) { @@ -94,7 +136,7 @@ export function useChannelSections(pubkey: string | undefined): { return () => { cancelled = true; }; - }, [pubkey]); + }, [pubkey, applyRemote]); React.useEffect(() => { if (!pubkey) return; @@ -102,12 +144,7 @@ export function useChannelSections(pubkey: string | undefined): { let cancelled = false; void subscribeToSections(pubkey, (remote) => { if (cancelled) return; - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + setStore(applyRemote(remote)); }).then((dispose) => { if (cancelled) { void dispose(); @@ -119,22 +156,28 @@ export function useChannelSections(pubkey: string | undefined): { cancelled = true; if (unsub) void unsub(); }; - }, [pubkey]); + }, [pubkey, applyRemote]); React.useEffect(() => { if (!pubkey) return; - return relayClient.subscribeToReconnects(() => { + let cancelled = false; + const unsub = relayClient.subscribeToReconnects(() => { void fetchRemoteSections(pubkey).then((remote) => { - if (!remote) return; - setStore((prev) => { - if (remote.createdAt <= lastAppliedRemoteTs.current) return prev; - lastAppliedRemoteTs.current = remote.createdAt; - if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; - return remote.store; - }); + if (cancelled) return; + if (remote) { + setStore(applyRemote(remote)); + } + const pending = getPendingStore(); + if (pending) { + publishSections(pending); + } }); }); - }, [pubkey]); + return () => { + cancelled = true; + unsub(); + }; + }, [pubkey, applyRemote]); const sections = React.useMemo( () => store.sections.slice().sort((a, b) => a.order - b.order), @@ -143,32 +186,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], ); @@ -224,33 +262,10 @@ 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; }); @@ -260,33 +275,10 @@ 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; }); 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 2f0b264dc..bed88adad 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -16,6 +16,8 @@ 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; From 25667b6c1a9422a6b225e6e32c2ad07a53bda06e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 16:23:08 -0400 Subject: [PATCH 3/8] test(desktop): add unit tests for channel sections storage and helpers Extract swapSectionOrder to channelSectionsHelpers.ts for testability. 27 tests covering parseChannelSectionPayload validation, stripOrphanedAssignments identity semantics, localStorage round-trip, and swapSectionOrder boundary conditions. --- .../lib/channelSectionsHelpers.test.mjs | 79 +++++++ .../sidebar/lib/channelSectionsHelpers.ts | 21 ++ .../lib/channelSectionsStorage.test.mjs | 204 ++++++++++++++++++ .../sidebar/lib/useChannelSections.ts | 21 +- 4 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.test.mjs create mode 100644 desktop/src/features/sidebar/lib/channelSectionsHelpers.ts create mode 100644 desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs 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/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index ecad246c8..090b7e285 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -16,6 +16,7 @@ import { subscribeToSections, } from "./channelSectionsSync"; import type { RemoteSections } from "./channelSectionsSync"; +import { swapSectionOrder } from "./channelSectionsHelpers"; export type { ChannelSection } from "./channelSectionsStorage"; @@ -24,26 +25,6 @@ import type { ChannelSectionStore, } from "./channelSectionsStorage"; -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 }; -} - export function useChannelSections(pubkey: string | undefined): { sections: ChannelSection[]; assignments: Record; From a4b6ed51e5af1c1545d1705a8c60ae76a220d423 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 15:37:18 -0400 Subject: [PATCH 4/8] fix(desktop): align event ID tie-break with mobile + publish on DnD reorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings in useChannelSections.ts: 1. The same-second tie-break was inverted vs mobile — desktop kept the smaller event ID while mobile kept the larger. Flip >= to <= so both platforms converge to the lexicographically larger event ID. 2. reorderSections (the DnD path) wrote to localStorage but never called publishSections, so drag-reordered sections didn't sync to other devices until some unrelated mutation triggered a publish. --- desktop/src/features/sidebar/lib/useChannelSections.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 090b7e285..9a6cad490 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -87,7 +87,7 @@ export function useChannelSections(pubkey: string | undefined): { if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && - remote.eventId >= lastAppliedEventId.current + remote.eventId <= lastAppliedEventId.current ) return prev; lastAppliedRemoteTs.current = remote.createdAt; @@ -277,6 +277,7 @@ export function useChannelSections(pubkey: string | undefined): { }); const next: ChannelSectionStore = { ...prev, sections }; if (!writeChannelSectionsStore(pubkey, next)) return prev; + publishSections(next); return next; }); }, From e2da605ca9257e4df8c5a4ed5578bccc0b01e694 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 15:49:52 -0400 Subject: [PATCH 5/8] =?UTF-8?q?fix(desktop):=20address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20add=20payload=20version,=20remove=20pubkey=20assert?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add version: 1 to the published payload to match mobile's format, preventing future migration asymmetry. Replace pubkey! non-null assertion with an early return guard to silence the Biome warning. --- desktop/src/features/sidebar/lib/channelSectionsSync.ts | 1 + desktop/src/features/sidebar/lib/useChannelSections.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 52008f77d..6ff37b19b 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -84,6 +84,7 @@ export function publishSections(store: ChannelSectionStore): void { async function doPublish(store: ChannelSectionStore): Promise { try { const payload = { + version: 1, sections: store.sections, assignments: store.assignments, }; diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 9a6cad490..f4bd12ec1 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -84,6 +84,7 @@ export function useChannelSections(pubkey: string | undefined): { remote: RemoteSections, ): ((prev: ChannelSectionStore) => ChannelSectionStore) => { return (prev) => { + if (!pubkey) return prev; if (remote.createdAt < lastAppliedRemoteTs.current) return prev; if ( remote.createdAt === lastAppliedRemoteTs.current && @@ -93,7 +94,7 @@ export function useChannelSections(pubkey: string | undefined): { lastAppliedRemoteTs.current = remote.createdAt; lastAppliedEventId.current = remote.eventId; cancelPendingPublish(); - if (!writeChannelSectionsStore(pubkey!, remote.store)) return prev; + if (!writeChannelSectionsStore(pubkey, remote.store)) return prev; return remote.store; }; }, From cb82583a1f02c343136d587a19714406373c076f Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 29 May 2026 17:59:05 -0400 Subject: [PATCH 6/8] feat(mobile): add channel sections with relay sync User-defined channel groups for the mobile sidebar, matching the desktop feature. Sections are persisted locally via SharedPreferences and synced across devices through NIP-78 kind 30078 events with NIP-44 encryption, using the same data format as desktop so changes propagate bidirectionally. Adds CRUD (create/rename/delete sections, move up/down), channel assignment via long-press action sheet, and section rendering above the ungrouped "Channels" group. Follows the ReadStateManager pattern for relay fetch/subscribe/publish with 5-second debounced writes and last-write-wins conflict resolution. --- .../channel_sections_manager.dart | 347 +++++++++++++ .../channel_sections_provider.dart | 141 ++++++ .../channel_sections_storage.dart | 109 ++++ .../lib/features/channels/channels_page.dart | 473 +++++++++++++++++- 4 files changed, 1069 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/features/channels/channel_sections/channel_sections_manager.dart create mode 100644 mobile/lib/features/channels/channel_sections/channel_sections_provider.dart create mode 100644 mobile/lib/features/channels/channel_sections/channel_sections_storage.dart 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..ba94e1e1e 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,220 @@ 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(), + PopupMenuButton( + icon: Icon( + LucideIcons.ellipsisVertical, + size: 14, + color: context.colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + onSelected: (value) { + switch (value) { + case 'rename': + onRename(); + case 'move_up': + onMoveUp(); + case 'move_down': + onMoveDown(); + case 'delete': + onDelete(); + } + }, + itemBuilder: (_) => [ + 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')), + ], + ), + 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 +756,8 @@ class _ChannelSection extends StatelessWidget { isUnread: unreadChannelIds.contains(channel.id), currentPubkey: currentPubkey, onTap: () => onSelectChannel(channel), + onMarkRead: null, + sectionId: null, ), ], ], @@ -530,11 +851,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 +874,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 +957,146 @@ 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); + }, + ), + if (isUnread) + ListTile( + leading: const Icon(LucideIcons.checkCheck), + title: const Text('Mark as read'), + onTap: () { + Navigator.of(sheetContext).pop(); + onMarkRead?.call(); + final ts = dateTimeToUnixSeconds(channel.lastMessageAt); + if (ts != null) { + ref + .read(readStateProvider.notifier) + .markContextRead(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 { From c96c767c0125cbaab38c9e161d67bb7bf56da49d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 13:21:14 -0400 Subject: [PATCH 7/8] feat(mobile): add mark-as-unread to channel long-press menu The bottom sheet only showed "Mark as read" when a channel was unread. Now it always shows a toggling item matching desktop parity. Adds markContextUnread to ReadStateManager (rolls back to lastMessage - 1). --- .../lib/features/channels/channels_page.dart | 29 ++++++++++++------- .../read_state/read_state_manager.dart | 10 +++++++ .../read_state/read_state_provider.dart | 4 +++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba94e1e1e..f3294d624 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -980,21 +980,28 @@ class _ChannelTile extends ConsumerWidget { await _showMoveSectionSheet(context, ref, sections); }, ), - if (isUnread) - ListTile( - leading: const Icon(LucideIcons.checkCheck), - title: const Text('Mark as read'), - onTap: () { - Navigator.of(sheetContext).pop(); - onMarkRead?.call(); - final ts = dateTimeToUnixSeconds(channel.lastMessageAt); - if (ts != null) { + 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); } - }, - ), + } + }, + ), ], ), ), 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); } From 5f58577d2dcc3ab5844855ddd1e0105c3c1c2284 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 1 Jun 2026 15:49:58 -0400 Subject: [PATCH 8/8] fix(mobile): match custom section header height to built-in sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMenuButton inflates its tap target beyond the Row's natural height, creating a visible gap between custom section titles and channels. Replace it with GestureDetector + showMenu() so the Row contains only plain Icon and Text widgets — structurally identical to the built-in _SectionHeader. --- .../lib/features/channels/channels_page.dart | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index f3294d624..ad96e59cf 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -605,14 +605,33 @@ class _CustomSectionHeader extends StatelessWidget { ), ), const Spacer(), - PopupMenuButton( - icon: Icon( - LucideIcons.ellipsisVertical, - size: 14, - color: context.colors.onSurfaceVariant, - ), - padding: EdgeInsets.zero, - onSelected: (value) { + 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(); @@ -624,20 +643,11 @@ class _CustomSectionHeader extends StatelessWidget { onDelete(); } }, - itemBuilder: (_) => [ - 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')), - ], + child: Icon( + LucideIcons.ellipsisVertical, + size: 14, + color: context.colors.onSurfaceVariant, + ), ), const SizedBox(width: Grid.quarter), Icon(