From 47a04d72cde761532f6dfd91d5ea4cd9d4a964a6 Mon Sep 17 00:00:00 2001 From: Andrew Kuttig Date: Wed, 12 Aug 2026 10:17:36 -0600 Subject: [PATCH] feat(desktop): add smart channel sections Signed-off-by: Andrew Kuttig --- desktop/playwright.config.ts | 1 + .../lib/channelSectionsStorage.test.mjs | 54 +++ .../sidebar/lib/channelSectionsStorage.ts | 68 +++- .../sidebar/lib/channelSectionsSync.test.mjs | 43 +++ .../sidebar/lib/channelSectionsSync.ts | 17 +- .../sidebar/lib/smartChannelSections.test.mjs | 148 ++++++++ .../sidebar/lib/smartChannelSections.ts | 131 +++++++ .../sidebar/lib/useChannelSections.test.mjs | 21 +- .../sidebar/lib/useChannelSections.ts | 100 +++++- .../src/features/sidebar/ui/AppSidebar.tsx | 35 +- .../sidebar/ui/CustomChannelSection.tsx | 66 +++- .../sidebar/ui/SmartSectionDialogs.tsx | 326 ++++++++++++++++++ .../sidebar/ui/useSmartSectionDialogs.tsx | 96 ++++++ .../tests/e2e/smart-channel-sections.spec.ts | 86 +++++ 14 files changed, 1144 insertions(+), 48 deletions(-) create mode 100644 desktop/src/features/sidebar/lib/smartChannelSections.test.mjs create mode 100644 desktop/src/features/sidebar/lib/smartChannelSections.ts create mode 100644 desktop/src/features/sidebar/ui/SmartSectionDialogs.tsx create mode 100644 desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx create mode 100644 desktop/tests/e2e/smart-channel-sections.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a9ec17a249..719ea31755 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "**/custom-emoji-ui.spec.ts", "**/channel-mute.spec.ts", "**/channel-star.spec.ts", + "**/smart-channel-sections.spec.ts", "**/channel-controls.spec.ts", "**/channel-activity-popover.spec.ts", "**/active-turn-resilience.spec.ts", diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs index f28108e5b5..4ddb20df3b 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs @@ -388,3 +388,57 @@ test("parseChannelSectionPayload: omits icon field when empty or whitespace", () { id: "s3", name: "C", order: 2 }, ]); }); + +test("parseChannelSectionPayload: preserves Smart Section rules and manual overrides", () => { + const payload = { + version: 1, + sections: [ + { + id: "smart", + name: "Engineering", + order: 0, + smartRule: { + pattern: "^eng-", + isRegex: true, + caseSensitive: false, + }, + }, + ], + assignments: {}, + manuallyUnassignedChannelIds: ["channel-1", "channel-1", 42], + }; + assert.deepEqual(parseChannelSectionPayload(payload), { + version: 1, + sections: [ + { + id: "smart", + name: "Engineering", + order: 0, + smartRule: { + pattern: "^eng-", + isRegex: true, + caseSensitive: false, + }, + }, + ], + assignments: {}, + manuallyUnassignedChannelIds: ["channel-1"], + }); +}); + +test("parseChannelSectionPayload: ignores malformed Smart Section rules", () => { + const result = parseChannelSectionPayload({ + sections: [ + { + id: "regular", + name: "Regular", + order: 0, + smartRule: { pattern: "eng", isRegex: "yes", caseSensitive: false }, + }, + ], + assignments: {}, + }); + assert.deepEqual(result?.sections, [ + { id: "regular", name: "Regular", order: 0 }, + ]); +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index f01154751b..77b90f2013 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -3,18 +3,29 @@ import { normalizeRelayUrl } from "@/shared/lib/normalizeRelayUrl"; const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1"; export const MAX_CHANNEL_SECTIONS = 100; export const MAX_CHANNEL_SECTION_ASSIGNMENTS = 1_000; +export const MAX_MANUALLY_UNASSIGNED_CHANNELS = 1_000; +export const MAX_SMART_SECTION_PATTERN_LENGTH = 200; + +export type SmartSectionRule = { + pattern: string; + isRegex: boolean; + caseSensitive: boolean; +}; export type ChannelSection = { id: string; name: string; icon?: string; order: number; + smartRule?: SmartSectionRule; }; export type ChannelSectionStore = { version: 1; sections: ChannelSection[]; assignments: Record; + /** Channels explicitly moved to the default Channels group by the user. */ + manuallyUnassignedChannelIds?: string[]; }; export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({ @@ -52,13 +63,25 @@ export function boundChannelSectionsStore( .filter(([, sectionId]) => sectionIds.has(sectionId)) .slice(-MAX_CHANNEL_SECTION_ASSIGNMENTS), ); + const manuallyUnassignedChannelIds = Array.from( + new Set(store.manuallyUnassignedChannelIds ?? []), + ).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS); if ( sections.length === store.sections.length && - Object.keys(assignments).length === Object.keys(store.assignments).length + Object.keys(assignments).length === Object.keys(store.assignments).length && + manuallyUnassignedChannelIds.length === + (store.manuallyUnassignedChannelIds?.length ?? 0) ) { return store; } - return { ...store, sections, assignments }; + return { + ...store, + sections, + assignments, + ...(manuallyUnassignedChannelIds.length > 0 + ? { manuallyUnassignedChannelIds } + : { manuallyUnassignedChannelIds: undefined }), + }; } export function stripOrphanedAssignments( @@ -95,12 +118,14 @@ export function parseChannelSectionPayload( typeof section.icon === "string" && section.icon.trim().length > 0 ? section.icon.trim() : undefined; + const smartRule = parseSmartSectionRule(section.smartRule); return [ { id: section.id, name: section.name, ...(icon ? { icon } : {}), order: section.order, + ...(smartRule ? { smartRule } : {}), }, ]; }) @@ -115,7 +140,44 @@ export function parseChannelSectionPayload( ), ) : {}; - return stripOrphanedAssignments({ version: 1, sections, assignments }); + const manuallyUnassignedChannelIds = Array.isArray( + obj.manuallyUnassignedChannelIds, + ) + ? Array.from( + new Set( + obj.manuallyUnassignedChannelIds.filter( + (channelId): channelId is string => typeof channelId === "string", + ), + ), + ).slice(-MAX_MANUALLY_UNASSIGNED_CHANNELS) + : []; + return stripOrphanedAssignments({ + version: 1, + sections, + assignments, + ...(manuallyUnassignedChannelIds.length > 0 + ? { manuallyUnassignedChannelIds } + : {}), + }); +} + +function parseSmartSectionRule(value: unknown): SmartSectionRule | undefined { + if (typeof value !== "object" || value === null) return undefined; + const rule = value as Record; + if ( + typeof rule.pattern !== "string" || + rule.pattern.length === 0 || + rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH || + typeof rule.isRegex !== "boolean" || + typeof rule.caseSensitive !== "boolean" + ) { + return undefined; + } + return { + pattern: rule.pattern, + isRegex: rule.isRegex, + caseSensitive: rule.caseSensitive, + }; } function parseRaw(raw: string | null): ChannelSectionStore | null { diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index 904ac1f3f2..f9afdd1962 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -280,3 +280,46 @@ test("revert-fix: undecryptable live event advances watermark before decrypt att mock.reset(); } }); + +test("publishes Smart Section rules and manual placement overrides", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + mock.method(relayClient, "publishEvent", () => Promise.resolve()); + const fw = makeFakeWindow(); + const restore = installFakeWindow(fw); + const tauri = installTauriMock(""); + try { + const manager = new ChannelSectionSyncManager("pk-smart", RELAY); + manager.publishSections( + makeStore({ + sections: [ + { + id: "smart", + name: "Engineering", + order: 0, + smartRule: { + pattern: "^eng-", + isRegex: true, + caseSensitive: false, + }, + }, + ], + manuallyUnassignedChannelIds: ["channel-1"], + }), + ); + fw._fireTimer(); + await new Promise((resolve) => setTimeout(resolve, 20)); + const plaintext = tauri.capturedPlaintext(); + assert.ok(plaintext, "Smart Section payload should be encrypted"); + const payload = JSON.parse(plaintext); + assert.deepEqual(payload.sections[0].smartRule, { + pattern: "^eng-", + isRegex: true, + caseSensitive: false, + }); + assert.deepEqual(payload.manuallyUnassignedChannelIds, ["channel-1"]); + } finally { + tauri.restore(); + restore(); + mock.reset(); + } +}); diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index 858b62430f..2cce9f426d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -162,7 +162,10 @@ export class ChannelSectionSyncManager { last.id !== current.id || last.name !== current.name || last.icon !== current.icon || - last.order !== current.order + last.order !== current.order || + last.smartRule?.pattern !== current.smartRule?.pattern || + last.smartRule?.isRegex !== current.smartRule?.isRegex || + last.smartRule?.caseSensitive !== current.smartRule?.caseSensitive ) return false; } @@ -173,6 +176,13 @@ export class ChannelSectionSyncManager { if (this.lastPublishedStore.assignments[key] !== store.assignments[key]) return false; } + const lastManual = + this.lastPublishedStore.manuallyUnassignedChannelIds ?? []; + const currentManual = store.manuallyUnassignedChannelIds ?? []; + if (lastManual.length !== currentManual.length) return false; + for (let i = 0; i < currentManual.length; i++) { + if (lastManual[i] !== currentManual[i]) return false; + } return true; } @@ -191,6 +201,11 @@ export class ChannelSectionSyncManager { version: 1, sections: merged.sections, assignments: merged.assignments, + ...(merged.manuallyUnassignedChannelIds + ? { + manuallyUnassignedChannelIds: merged.manuallyUnassignedChannelIds, + } + : {}), }; const ciphertext = await nip44EncryptToSelf(JSON.stringify(payload)); const createdAt = Math.max( diff --git a/desktop/src/features/sidebar/lib/smartChannelSections.test.mjs b/desktop/src/features/sidebar/lib/smartChannelSections.test.mjs new file mode 100644 index 0000000000..750635bdd9 --- /dev/null +++ b/desktop/src/features/sidebar/lib/smartChannelSections.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyAutomaticSmartSectionAssignments, + assignChannelsToSection, + channelMatchesSmartRule, + getSmartSectionRuleError, + matchingChannelsForSection, + manuallyUnassignChannel, +} from "./smartChannelSections.ts"; + +function smartSection(overrides = {}) { + return { + id: "smart-1", + name: "Engineering", + order: 0, + smartRule: { + pattern: "eng-", + isRegex: false, + caseSensitive: false, + }, + ...overrides, + }; +} + +function store(overrides = {}) { + return { + version: 1, + sections: [smartSection()], + assignments: {}, + ...overrides, + }; +} + +test("literal smart rules match channel names by substring", () => { + assert.equal( + channelMatchesSmartRule("ENG-Platform", { + pattern: "eng-", + isRegex: false, + caseSensitive: false, + }), + true, + ); + assert.equal( + channelMatchesSmartRule("ENG-Platform", { + pattern: "eng-", + isRegex: false, + caseSensitive: true, + }), + false, + ); +}); + +test("regex rules honor case sensitivity and reject invalid expressions", () => { + assert.equal( + channelMatchesSmartRule("eng-platform", { + pattern: "^ENG-(platform|mobile)$", + isRegex: true, + caseSensitive: false, + }), + true, + ); + assert.equal( + channelMatchesSmartRule("eng-platform", { + pattern: "^ENG-(platform|mobile)$", + isRegex: true, + caseSensitive: true, + }), + false, + ); + assert.equal( + getSmartSectionRuleError({ + pattern: "[", + isRegex: true, + caseSensitive: false, + }), + "Enter a valid regular expression.", + ); +}); + +test("automatic assignment places only channels without a saved location", () => { + const original = store({ + assignments: { manual: "other", already: "smart-1" }, + manuallyUnassignedChannelIds: ["kept-in-channels"], + }); + const result = applyAutomaticSmartSectionAssignments(original, [ + { id: "new-match", name: "eng-infra" }, + { id: "manual", name: "eng-manual" }, + { id: "already", name: "eng-existing" }, + { id: "kept-in-channels", name: "eng-opted-out" }, + { id: "not-a-match", name: "design" }, + ]); + + assert.deepEqual(result.assignments, { + manual: "other", + already: "smart-1", + "new-match": "smart-1", + }); + assert.deepEqual(result.manuallyUnassignedChannelIds, ["kept-in-channels"]); +}); + +test("the first matching Smart Section by sidebar order wins", () => { + const result = applyAutomaticSmartSectionAssignments( + store({ + sections: [ + smartSection({ id: "second", order: 2 }), + smartSection({ id: "first", order: 1 }), + ], + }), + [{ id: "channel", name: "eng-platform" }], + ); + assert.equal(result.assignments.channel, "first"); +}); + +test("bulk matching returns matches regardless of current placement", () => { + const channels = [ + { id: "one", name: "eng-one" }, + { id: "two", name: "design" }, + { id: "three", name: "ENG-three" }, + ]; + assert.deepEqual( + matchingChannelsForSection(smartSection(), channels).map( + (channel) => channel.id, + ), + ["one", "three"], + ); +}); + +test("manual moves override automatic placement until explicitly moved back", () => { + const assigned = store({ assignments: { channel: "smart-1" } }); + const unassigned = manuallyUnassignChannel(assigned, "channel"); + assert.deepEqual(unassigned.assignments, {}); + assert.deepEqual(unassigned.manuallyUnassignedChannelIds, ["channel"]); + + const afterAutomaticScan = applyAutomaticSmartSectionAssignments(unassigned, [ + { id: "channel", name: "eng-platform" }, + ]); + assert.equal(afterAutomaticScan, unassigned); + + const movedBack = assignChannelsToSection( + afterAutomaticScan, + ["channel"], + "smart-1", + ); + assert.equal(movedBack.assignments.channel, "smart-1"); + assert.equal(movedBack.manuallyUnassignedChannelIds, undefined); +}); diff --git a/desktop/src/features/sidebar/lib/smartChannelSections.ts b/desktop/src/features/sidebar/lib/smartChannelSections.ts new file mode 100644 index 0000000000..d0c49c8390 --- /dev/null +++ b/desktop/src/features/sidebar/lib/smartChannelSections.ts @@ -0,0 +1,131 @@ +import type { + ChannelSection, + ChannelSectionStore, + SmartSectionRule, +} from "./channelSectionsStorage"; +import { MAX_SMART_SECTION_PATTERN_LENGTH } from "./channelSectionsStorage"; + +export type SmartSectionChannel = { + id: string; + name: string; +}; + +export function getSmartSectionRuleError( + rule: SmartSectionRule, +): string | null { + if (rule.pattern.trim().length === 0) return "Enter a channel name pattern."; + if (rule.pattern.length > MAX_SMART_SECTION_PATTERN_LENGTH) { + return `Keep the pattern under ${MAX_SMART_SECTION_PATTERN_LENGTH} characters.`; + } + if (!rule.isRegex) return null; + try { + new RegExp(rule.pattern, rule.caseSensitive ? "" : "i"); + return null; + } catch { + return "Enter a valid regular expression."; + } +} + +export function channelMatchesSmartRule( + channelName: string, + rule: SmartSectionRule, +): boolean { + if (getSmartSectionRuleError(rule)) return false; + if (rule.isRegex) { + return new RegExp(rule.pattern, rule.caseSensitive ? "" : "i").test( + channelName, + ); + } + const pattern = rule.caseSensitive + ? rule.pattern + : rule.pattern.toLocaleLowerCase(); + const candidate = rule.caseSensitive + ? channelName + : channelName.toLocaleLowerCase(); + return candidate.includes(pattern); +} + +export function matchingChannelsForSection( + section: ChannelSection, + channels: readonly T[], +): T[] { + const rule = section.smartRule; + if (!rule) return []; + return channels.filter((channel) => + channelMatchesSmartRule(channel.name, rule), + ); +} + +/** + * Assigns only channels without a saved placement. Persisting the assignment + * makes the rule a one-time arrival decision: later manual moves remain stable. + */ +export function applyAutomaticSmartSectionAssignments( + store: ChannelSectionStore, + channels: readonly SmartSectionChannel[], +): ChannelSectionStore { + const smartSections = store.sections + .filter((section) => section.smartRule) + .slice() + .sort((left, right) => left.order - right.order); + if (smartSections.length === 0) return store; + + const manuallyUnassigned = new Set(store.manuallyUnassignedChannelIds ?? []); + let assignments: Record | null = null; + for (const channel of channels) { + if (store.assignments[channel.id] || manuallyUnassigned.has(channel.id)) { + continue; + } + const matchingSection = smartSections.find( + (section) => + section.smartRule && + channelMatchesSmartRule(channel.name, section.smartRule), + ); + if (!matchingSection) continue; + assignments ??= { ...store.assignments }; + assignments[channel.id] = matchingSection.id; + } + return assignments ? { ...store, assignments } : store; +} + +export function assignChannelsToSection( + store: ChannelSectionStore, + channelIds: readonly string[], + sectionId: string, +): ChannelSectionStore { + if (channelIds.length === 0) return store; + const movedIds = new Set(channelIds); + const assignments = { ...store.assignments }; + for (const channelId of channelIds) { + delete assignments[channelId]; + assignments[channelId] = sectionId; + } + const manuallyUnassignedChannelIds = ( + store.manuallyUnassignedChannelIds ?? [] + ).filter((channelId) => !movedIds.has(channelId)); + return { + ...store, + assignments, + ...(manuallyUnassignedChannelIds.length > 0 + ? { manuallyUnassignedChannelIds } + : { manuallyUnassignedChannelIds: undefined }), + }; +} + +export function manuallyUnassignChannel( + store: ChannelSectionStore, + channelId: string, +): ChannelSectionStore { + const assignments = { ...store.assignments }; + delete assignments[channelId]; + return { + ...store, + assignments, + manuallyUnassignedChannelIds: [ + ...(store.manuallyUnassignedChannelIds ?? []).filter( + (existingId) => existingId !== channelId, + ), + channelId, + ], + }; +} diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index 401b59d9c1..1949cf9ca3 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -21,9 +21,11 @@ after(() => dom.window.close()); test("assignChannel refreshes an existing assignment before the next eviction", async () => { const { act, cleanup, renderHook } = await import("@testing-library/react"); const { relayClient } = await import("@/shared/api/relayClient"); - const { MAX_CHANNEL_SECTION_ASSIGNMENTS, storageKey } = await import( - "./channelSectionsStorage.ts" - ); + const { + MAX_CHANNEL_SECTION_ASSIGNMENTS, + readChannelSectionsStore, + storageKey, + } = await import("./channelSectionsStorage.ts"); const { useChannelSections } = await import("./useChannelSections.ts"); const originalFetchEvents = relayClient.fetchEvents; @@ -68,6 +70,19 @@ test("assignChannel refreshes an existing assignment before the next eviction", Object.keys(result.current.assignments).length, MAX_CHANNEL_SECTION_ASSIGNMENTS, ); + + act(() => result.current.unassignChannel("chan-0000")); + assert.equal(result.current.assignments["chan-0000"], undefined); + assert.deepEqual( + readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds, + ["chan-0000"], + ); + + act(() => result.current.assignChannel("chan-0000", "section-2")); + assert.equal( + readChannelSectionsStore(pubkey, relayUrl).manuallyUnassignedChannelIds, + undefined, + ); unmount(); } finally { cleanup(); diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts index 5a544e82bc..9587c88bec 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.ts +++ b/desktop/src/features/sidebar/lib/useChannelSections.ts @@ -11,27 +11,50 @@ import { import { ChannelSectionSyncManager } from "./channelSectionsSync"; import type { RemoteSections } from "./channelSectionsSync"; import { swapSectionOrder } from "./channelSectionsHelpers"; +import { + applyAutomaticSmartSectionAssignments, + assignChannelsToSection, + manuallyUnassignChannel, + type SmartSectionChannel, +} from "./smartChannelSections"; -export type { ChannelSection } from "./channelSectionsStorage"; +const EMPTY_SMART_SECTION_CHANNELS: readonly SmartSectionChannel[] = []; + +export type { + ChannelSection, + SmartSectionRule, +} from "./channelSectionsStorage"; import type { ChannelSection, ChannelSectionStore, + SmartSectionRule, } from "./channelSectionsStorage"; export function useChannelSections( pubkey: string | undefined, relayUrl?: string, + channels: readonly SmartSectionChannel[] = EMPTY_SMART_SECTION_CHANNELS, ): { sections: ChannelSection[]; assignments: Record; - createSection: (name: string, icon?: string) => ChannelSection | null; - renameSection: (sectionId: string, newName: string, icon?: string) => void; + createSection: ( + name: string, + icon?: string, + smartRule?: SmartSectionRule, + ) => ChannelSection | null; + renameSection: ( + sectionId: string, + newName: string, + icon?: string, + smartRule?: SmartSectionRule, + ) => void; deleteSection: (sectionId: string) => void; moveSectionUp: (sectionId: string) => void; moveSectionDown: (sectionId: string) => void; reorderSections: (orderedIds: string[]) => void; assignChannel: (channelId: string, sectionId: string) => void; + assignChannels: (channelIds: string[], sectionId: string) => void; unassignChannel: (channelId: string) => void; } { const [store, setStore] = React.useState(() => { @@ -168,7 +191,11 @@ export function useChannelSections( ); const createSection = React.useCallback( - (name: string, icon?: string): ChannelSection | null => { + ( + name: string, + icon?: string, + smartRule?: SmartSectionRule, + ): ChannelSection | null => { if (!pubkey) return null; const prev = readChannelSectionsStore(pubkey, relayUrl); const maxOrder = @@ -180,6 +207,7 @@ export function useChannelSections( name, ...(icon ? { icon } : {}), order: maxOrder + 1, + ...(smartRule ? { smartRule } : {}), }; setStore((current) => { const next = boundChannelSectionsStore({ @@ -196,7 +224,12 @@ export function useChannelSections( ); const renameSection = React.useCallback( - (sectionId: string, newName: string, icon?: string) => { + ( + sectionId: string, + newName: string, + icon?: string, + smartRule?: SmartSectionRule, + ) => { if (!pubkey) { return; } @@ -206,10 +239,11 @@ export function useChannelSections( sections: prev.sections.map((s) => s.id === sectionId ? { - id: s.id, + ...s, name: newName, ...(icon ? { icon } : {}), - order: s.order, + ...(icon ? {} : { icon: undefined }), + ...(smartRule ? { smartRule } : {}), } : s, ), @@ -302,13 +336,9 @@ export function useChannelSections( return; } setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - assignments[channelId] = sectionId; - const next = boundChannelSectionsStore({ - ...prev, - assignments, - }); + const next = boundChannelSectionsStore( + assignChannelsToSection(prev, [channelId], sectionId), + ); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } @@ -319,15 +349,30 @@ export function useChannelSections( [pubkey, relayUrl], ); + const assignChannels = React.useCallback( + (channelIds: string[], sectionId: string) => { + if (!pubkey || channelIds.length === 0) return; + setStore((prev) => { + const next = boundChannelSectionsStore( + assignChannelsToSection(prev, channelIds, sectionId), + ); + if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; + managerRef.current?.publishSections(next); + return next; + }); + }, + [pubkey, relayUrl], + ); + const unassignChannel = React.useCallback( (channelId: string) => { if (!pubkey) { return; } setStore((prev) => { - const assignments = { ...prev.assignments }; - delete assignments[channelId]; - const next: ChannelSectionStore = { ...prev, assignments }; + const next = boundChannelSectionsStore( + manuallyUnassignChannel(prev, channelId), + ); if (!writeChannelSectionsStore(pubkey, next, relayUrl)) { return prev; } @@ -338,6 +383,26 @@ export function useChannelSections( [pubkey, relayUrl], ); + const smartRulesKey = JSON.stringify( + store.sections.flatMap((section) => + section.smartRule + ? [[section.id, section.order, section.smartRule] as const] + : [], + ), + ); + + React.useEffect(() => { + if (!pubkey || channels.length === 0 || smartRulesKey === "[]") return; + setStore((prev) => { + const matched = applyAutomaticSmartSectionAssignments(prev, channels); + if (matched === prev) return prev; + const next = boundChannelSectionsStore(matched); + if (!writeChannelSectionsStore(pubkey, next, relayUrl)) return prev; + managerRef.current?.publishSections(next); + return next; + }); + }, [pubkey, relayUrl, channels, smartRulesKey]); + return { sections, assignments: store.assignments, @@ -348,6 +413,7 @@ export function useChannelSections( moveSectionDown, reorderSections, assignChannel, + assignChannels, unassignChannel, }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 227572febe..76cf56e408 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -45,6 +45,7 @@ import { SectionQuickAction, } from "@/features/sidebar/ui/CustomChannelSection"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { useSmartSectionDialogs } from "@/features/sidebar/ui/useSmartSectionDialogs"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { HuddleProfileControl } from "@/features/huddle"; import type { @@ -346,18 +347,12 @@ export function AppSidebar({ })); }, []); - const { - sections: channelSections, - assignments: channelAssignments, - createSection, - renameSection, - deleteSection, - moveSectionUp, - moveSectionDown, - reorderSections, - assignChannel, - unassignChannel, - } = useChannelSections(currentPubkey, activeCommunity?.relayUrl); + const streamChannels = React.useMemo( + () => channels.filter((channel) => channel.channelType === "stream"), + [channels], + ); + // biome-ignore format: keep compact to stay within file size limit + const { sections: channelSections, assignments: channelAssignments, createSection, renameSection, deleteSection, moveSectionUp, moveSectionDown, reorderSections, assignChannel, assignChannels, unassignChannel } = useChannelSections(currentPubkey, activeCommunity?.relayUrl, streamChannels); const sectionIds = React.useMemo( () => channelSections.map((s) => s.id), @@ -385,11 +380,8 @@ export function AppSidebar({ if (channel.id === selectedChannelId) onSelectHome(); }); - const streamChannels = React.useMemo( - () => channels.filter((channel) => channel.channelType === "stream"), - [channels], - ); - + // biome-ignore format: keep compact to stay within file size limit + const smartSectionDialogs = useSmartSectionDialogs({ sections: channelSections, channels: streamChannels, assignments: channelAssignments, createSection, renameSection, assignChannels }); const sectionBuckets = React.useMemo(() => { const bySection: Record = {}; const unassigned: Channel[] = []; @@ -717,6 +709,10 @@ export function AppSidebar({ handleCreateChannelInSection(section.id) } onRenameSection={() => setRenameSectionTarget(section)} + onEditSmartSection={smartSectionDialogs.openEditDialog} + onFindMatchingChannels={ + smartSectionDialogs.openMatchesDialog + } onDeleteSection={() => setDeleteSectionTarget(section)} onMoveSectionUp={() => moveSectionUp(section.id)} onMoveSectionDown={() => moveSectionDown(section.id)} @@ -760,6 +756,9 @@ export function AppSidebar({ onAssignChannel={assignChannel} onUnassignChannel={unassignChannel} onCreateSectionForChannel={handleCreateSectionForChannel} + onCreateSmartSection={ + smartSectionDialogs.openCreateDialog + } mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} @@ -952,7 +951,7 @@ export function AppSidebar({ }} onConfirm={handleCreateSectionConfirm} /> - + {smartSectionDialogs.dialogs} { diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 9885f13085..d84c7e5d9e 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -7,6 +7,7 @@ import { EllipsisVertical, Pencil, Plus, + Search, Trash2, } from "lucide-react"; @@ -128,9 +129,11 @@ export function SectionActionsMenu({ browseLabel, onCreate, createLabel, + onCreateSmartSection, onNewMessage, newMessageLabel, onRenameSection, + onRunSmartSection, onMoveSectionUp, onMoveSectionDown, onDeleteSection, @@ -149,9 +152,11 @@ export function SectionActionsMenu({ browseLabel?: string; onCreate?: () => void; createLabel?: string; + onCreateSmartSection?: () => void; onNewMessage?: () => void; newMessageLabel?: string; onRenameSection?: () => void; + onRunSmartSection?: () => void; onMoveSectionUp?: () => void; onMoveSectionDown?: () => void; onDeleteSection?: () => void; @@ -213,6 +218,14 @@ export function SectionActionsMenu({ {createLabel ?? "Create channel"} ) : null} + {onCreateSmartSection ? ( + deferMenuAction(onCreateSmartSection)} + > + + New smart section + + ) : null} {showSectionManagement ? ( <> {onRenameSection ? ( @@ -220,7 +233,17 @@ export function SectionActionsMenu({ onSelect={() => deferMenuAction(onRenameSection)} > - Rename section + + {onRunSmartSection ? "Edit smart section" : "Rename section"} + + + ) : null} + {onRunSmartSection ? ( + deferMenuAction(onRunSmartSection)} + > + + Find matching channels ) : null} {onMoveSectionUp ? ( @@ -345,6 +368,7 @@ export function ChannelGroupSection({ listTestId, onBrowseClick, onCreateClick, + onCreateSmartSection, onQuickCreateClick, quickCreateLabel, showQuickCreate, @@ -385,6 +409,7 @@ export function ChannelGroupSection({ listTestId: string; onBrowseClick?: () => void; onCreateClick?: () => void; + onCreateSmartSection?: () => void; /** * Overrides the quick-create (`+`) button's click handler. Defaults to * `onCreateClick`. Used to point the sidebar `+` at the unified @@ -521,6 +546,7 @@ export function ChannelGroupSection({ browseLabel={browseLabel} onCreate={onCreateClick} createLabel={createLabel} + onCreateSmartSection={onCreateSmartSection} sortMode={sortMode} onSortModeChange={onSortModeChange} /> @@ -566,6 +592,8 @@ export function CustomChannelSection({ onCreateSectionForChannel, onCreateChannel, onRenameSection, + onEditSmartSection, + onFindMatchingChannels, onDeleteSection, onMoveSectionUp, onMoveSectionDown, @@ -606,6 +634,8 @@ export function CustomChannelSection({ onCreateSectionForChannel: (channelId: string) => void; onCreateChannel: () => void; onRenameSection: () => void; + onEditSmartSection: (section: ChannelSection) => void; + onFindMatchingChannels: (section: ChannelSection) => void; onDeleteSection: () => void; onMoveSectionUp: () => void; onMoveSectionDown: () => void; @@ -620,6 +650,12 @@ export function CustomChannelSection({ }) { const contentId = `sidebar-section-${section.id}`; const [actionsMenuOpen, setActionsMenuOpen] = useState(false); + const onEditSection = section.smartRule + ? () => onEditSmartSection(section) + : onRenameSection; + const onRunSmartSection = section.smartRule + ? () => onFindMatchingChannels(section) + : undefined; return ( @@ -638,14 +674,16 @@ export function CustomChannelSection({
+ + + + + + + ); +} + +export function SmartSectionMatchesDialog({ + open, + onOpenChange, + section, + sections, + channels, + assignments, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + section: ChannelSection | null; + sections: ChannelSection[]; + channels: Channel[]; + assignments: Record; + onConfirm: (channelIds: string[]) => void; +}) { + const matches = React.useMemo( + () => (section ? matchingChannelsForSection(section, channels) : []), + [section, channels], + ); + const movableIds = React.useMemo( + () => + matches + .filter((channel) => assignments[channel.id] !== section?.id) + .map((channel) => channel.id), + [matches, assignments, section?.id], + ); + const [selectedIds, setSelectedIds] = React.useState>(new Set()); + const sectionNames = React.useMemo( + () => new Map(sections.map((candidate) => [candidate.id, candidate.name])), + [sections], + ); + + React.useEffect(() => { + if (open) setSelectedIds(new Set(movableIds)); + }, [open, movableIds]); + + function toggleChannel(channelId: string, checked: boolean) { + setSelectedIds((current) => { + const next = new Set(current); + if (checked) next.add(channelId); + else next.delete(channelId); + return next; + }); + } + + return ( + + + + Matching channels + + Review every channel matching{" "} + {section?.name ?? "this smart section"} + {section?.smartRule ? ` (${section.smartRule.pattern})` : ""}. + Selected channels will be moved into the section. + + + {matches.length > 0 ? ( + <> +
+ {matches.length} matching channels + {movableIds.length > 0 ? ( + + ) : null} +
+
+ {matches.map((channel) => { + const currentSectionId = assignments[channel.id]; + const alreadyInSection = currentSectionId === section?.id; + const location = alreadyInSection + ? "Already in section" + : currentSectionId + ? (sectionNames.get(currentSectionId) ?? "Another section") + : "Channels"; + return ( + + ); + })} +
+ + ) : ( +
+ +

No matching channels

+

+ Edit the smart section to broaden its pattern. +

+
+ )} + + + + + + +
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx b/desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx new file mode 100644 index 0000000000..874024278f --- /dev/null +++ b/desktop/src/features/sidebar/ui/useSmartSectionDialogs.tsx @@ -0,0 +1,96 @@ +import * as React from "react"; + +import type { + ChannelSection, + SmartSectionRule, +} from "@/features/sidebar/lib/channelSectionsStorage"; +import type { Channel } from "@/shared/api/types"; +import { + SmartSectionDialog, + SmartSectionMatchesDialog, +} from "./SmartSectionDialogs"; + +export function useSmartSectionDialogs({ + sections, + channels, + assignments, + createSection, + renameSection, + assignChannels, +}: { + sections: ChannelSection[]; + channels: Channel[]; + assignments: Record; + createSection: ( + name: string, + icon?: string, + smartRule?: SmartSectionRule, + ) => ChannelSection | null; + renameSection: ( + sectionId: string, + name: string, + icon?: string, + smartRule?: SmartSectionRule, + ) => void; + assignChannels: (channelIds: string[], sectionId: string) => void; +}) { + const [isCreateOpen, setIsCreateOpen] = React.useState(false); + const [editTarget, setEditTarget] = React.useState( + null, + ); + const [matchesTarget, setMatchesTarget] = + React.useState(null); + const openCreateDialog = React.useCallback(() => setIsCreateOpen(true), []); + + const dialogs = ( + <> + { + const created = createSection(value.name, undefined, value.smartRule); + if (created) setIsCreateOpen(false); + }} + onOpenChange={setIsCreateOpen} + open={isCreateOpen} + /> + { + if (!editTarget) return; + renameSection( + editTarget.id, + value.name, + editTarget.icon, + value.smartRule, + ); + setEditTarget(null); + }} + onOpenChange={(open) => { + if (!open) setEditTarget(null); + }} + open={editTarget !== null} + section={editTarget} + /> + { + if (!matchesTarget) return; + assignChannels(channelIds, matchesTarget.id); + setMatchesTarget(null); + }} + onOpenChange={(open) => { + if (!open) setMatchesTarget(null); + }} + open={matchesTarget !== null} + section={matchesTarget} + sections={sections} + /> + + ); + + return { + dialogs, + openCreateDialog, + openEditDialog: setEditTarget, + openMatchesDialog: setMatchesTarget, + }; +} diff --git a/desktop/tests/e2e/smart-channel-sections.spec.ts b/desktop/tests/e2e/smart-channel-sections.spec.ts new file mode 100644 index 0000000000..05c33061ab --- /dev/null +++ b/desktop/tests/e2e/smart-channel-sections.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/smart-channel-sections"; + +test("creates a smart section and restores a manually removed match", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + + const channelsGroup = page + .getByTestId("stream-list-section-label") + .locator('xpath=ancestor::*[@data-sidebar="group"]'); + await expect(channelsGroup).toBeVisible(); + await waitForAnimations(page); + await channelsGroup.screenshot({ path: `${SHOTS}/00-before.png` }); + + await page.getByTestId("section-actions-channels").click(); + await page.getByRole("menuitem", { name: "New smart section" }).click(); + + const createDialog = page.getByRole("dialog", { + name: "Create smart section", + }); + await expect(createDialog).toBeVisible(); + await createDialog.getByLabel("Section name").fill("Product & Engineering"); + await createDialog + .getByLabel("Channel name pattern") + .fill("^(engineering|agents)$"); + await createDialog.getByLabel("Use regular expression").click(); + await createDialog.getByLabel("Match case").click(); + await expect( + createDialog.getByLabel("Use regular expression"), + ).toHaveAttribute("data-state", "on"); + await expect(createDialog.getByLabel("Match case")).toHaveAttribute( + "data-state", + "on", + ); + await waitForAnimations(page); + await createDialog.screenshot({ + path: `${SHOTS}/01-create-smart-section.png`, + }); + + await createDialog.getByRole("button", { name: "Create" }).click(); + + const smartSection = page + .getByText("Product & Engineering", { exact: true }) + .locator('xpath=ancestor::*[@data-sidebar="group"]'); + await expect(smartSection).toBeVisible(); + await expect(smartSection.getByTestId("channel-agents")).toBeVisible(); + await expect(smartSection.getByTestId("channel-engineering")).toBeVisible(); + await waitForAnimations(page); + await smartSection.screenshot({ path: `${SHOTS}/02-matched-section.png` }); + + await smartSection.getByTestId("channel-engineering").click({ + button: "right", + }); + await page.getByRole("menuitem", { name: "Move to section" }).hover(); + await page.getByRole("menuitem", { name: "Remove from section" }).click(); + await expect(smartSection.getByTestId("channel-engineering")).toHaveCount(0); + + const sectionActions = page.locator( + 'button[aria-label="More actions for Product & Engineering"]', + ); + await sectionActions.click(); + await page.getByRole("menuitem", { name: "Find matching channels" }).click(); + + const matchesDialog = page.getByRole("dialog", { + name: "Matching channels", + }); + await expect(matchesDialog).toContainText("2 matching channels"); + await expect(matchesDialog).toContainText("Already in section"); + await expect(matchesDialog).toContainText("Channels"); + await expect( + matchesDialog.getByRole("button", { name: "Move 1 channel" }), + ).toBeEnabled(); + await waitForAnimations(page); + await matchesDialog.screenshot({ + path: `${SHOTS}/03-review-matches.png`, + }); + + await matchesDialog.getByRole("button", { name: "Move 1 channel" }).click(); + await expect(smartSection.getByTestId("channel-engineering")).toBeVisible(); +});