From 19dc33bda6e9e7f09703c4efcd721235de644ce9 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:25:44 -0700 Subject: [PATCH 01/12] Persist agent audiences with native inline mentions (#1949) Signed-off-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1n4y9luxx9y27pz5qz93vr9w8auyk7mmpgwf9gpe9tn4zv4kyhzjqtcntu7 <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co> --- desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 5 +- .../src/features/channels/ui/ChannelPane.tsx | 1 + .../src/features/home/ui/InboxDetailPane.tsx | 4 + .../effectiveExplicitAgentPubkeys.test.mjs | 25 ++ .../lib/effectiveExplicitAgentPubkeys.ts | 19 ++ .../lib/persistentAgentAudience.test.mjs | 247 ++++++++++++++++++ .../messages/lib/persistentAgentAudience.ts | 236 +++++++++++++++++ .../src/features/messages/lib/useMentions.ts | 61 +++-- .../features/messages/ui/MessageComposer.tsx | 55 ++++ .../messages/ui/MessageThreadPanel.tsx | 4 + .../features/messages/ui/NewMessageScreen.tsx | 1 + .../ui/persistentAgentAudienceHosts.test.mjs | 47 ++++ .../messages/ui/useMentionSendFlow.ts | 45 +++- .../ui/usePersistentAgentMentionHydration.ts | 123 +++++++++ .../settings/ui/PreventSleepSettingsCard.tsx | 28 +- .../e2e/persistent-agent-audience.spec.ts | 109 ++++++++ 17 files changed, 986 insertions(+), 25 deletions(-) create mode 100644 desktop/src/features/messages/lib/effectiveExplicitAgentPubkeys.test.mjs create mode 100644 desktop/src/features/messages/lib/effectiveExplicitAgentPubkeys.ts create mode 100644 desktop/src/features/messages/lib/persistentAgentAudience.test.mjs create mode 100644 desktop/src/features/messages/lib/persistentAgentAudience.ts create mode 100644 desktop/src/features/messages/ui/persistentAgentAudienceHosts.test.mjs create mode 100644 desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts create mode 100644 desktop/tests/e2e/persistent-agent-audience.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 340abfcdc..874e7e189 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -54,6 +54,7 @@ export default defineConfig({ "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", "**/team-mentions.spec.ts", + "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", "**/relay-reconnect-affordance.spec.ts", "**/workflows.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 117cf60c1..11ebd3103 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -460,7 +460,10 @@ const overrides = new Map([ // +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for // the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef // pattern. Queued to split with the rest of this list. - ["src/features/messages/ui/MessageComposer.tsx", 1036], + // +35: persistent audience scope/hook wiring and chip component handoff. The + // chip markup lives separately; remaining lines connect existing composer + // send state to the audience store. Queued with the existing split. + ["src/features/messages/ui/MessageComposer.tsx", 1091], // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation // + globalProvider threading into getPersonaProviderOptions. All load-bearing diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index a84eec0c1..4523e69d7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -754,6 +754,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null}
{ + assert.deepEqual( + filterEffectiveExplicitAgentPubkeys([agentA, agentB], [agentA, person]), + [agentA], + ); +}); + +test("effective audience promotion keeps authored order and dedupes", () => { + assert.deepEqual( + filterEffectiveExplicitAgentPubkeys( + [agentB.toUpperCase(), agentA, agentB], + [agentA, agentB], + ), + [agentB, agentA], + ); +}); diff --git a/desktop/src/features/messages/lib/effectiveExplicitAgentPubkeys.ts b/desktop/src/features/messages/lib/effectiveExplicitAgentPubkeys.ts new file mode 100644 index 000000000..f4cbd2bc8 --- /dev/null +++ b/desktop/src/features/messages/lib/effectiveExplicitAgentPubkeys.ts @@ -0,0 +1,19 @@ +function normalizePubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +function uniqueNormalizedPubkeys(pubkeys: Iterable): string[] { + return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); +} + +export function filterEffectiveExplicitAgentPubkeys( + explicitAgentPubkeys: Iterable, + effectiveMentionPubkeys: Iterable, +): string[] { + const effectivePubkeys = new Set( + uniqueNormalizedPubkeys(effectiveMentionPubkeys), + ); + return uniqueNormalizedPubkeys(explicitAgentPubkeys).filter((pubkey) => + effectivePubkeys.has(pubkey), + ); +} diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs new file mode 100644 index 000000000..4b43d30c2 --- /dev/null +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +function createStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +} + +const agentA = "a".repeat(64); +const agentB = "b".repeat(64); +const agentC = "c".repeat(64); +const ownerA = "1".repeat(64); +const ownerB = "2".repeat(64); +const storageKey = "buzz:persistent-agent-audiences:v2"; + +let loadSequence = 0; + +async function loadStore(offset = 0) { + globalThis.window = { localStorage: createStorage() }; + loadSequence += 1; + return import( + `./persistentAgentAudience.ts?test=${Date.now()}-${offset}-${loadSequence}` + ); +} + +function savedAudiences() { + return JSON.parse(window.localStorage.getItem(storageKey)); +} + +test("conversation scopes isolate identities, channels, and threads", async () => { + const store = await loadStore(); + const channelA = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + }); + const channelB = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-b", + }); + const threadA1 = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + threadRootId: "root-1", + }); + const threadA2 = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + threadRootId: "root-2", + }); + const otherIdentity = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerB, + channelId: "channel-a", + }); + + for (const scope of [channelA, channelB, threadA1, threadA2, otherIdentity]) { + assert.ok(scope); + store.setPersistentAgentAudience(scope, [agentA]); + } + + assert.equal(new Set(Object.keys(savedAudiences())).size, 5); +}); + +test("successful fast send promotes without a persisted draft key", async () => { + const store = await loadStore(1); + const scope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + }); + store.setPersistentAgentAudienceEnabled(true); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA] }); +}); + +test("explicit recipients merge and dedupe after successful send", async () => { + const store = await loadStore(2); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revision = store.getPersistentAgentAudienceRevision(scope); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revision, + explicitAgentPubkeys: [agentA, agentB], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA, agentB] }); +}); + +test("successful send makes authored mention order authoritative", async () => { + const store = await loadStore(100); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA, agentB]); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA, agentC], + }); + + assert.deepEqual(savedAudiences(), { + [scope]: [agentB, agentA, agentC], + }); +}); + +test("successful send retains saved targets absent from the draft", async () => { + const store = await loadStore(101); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA, agentC]); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA], + }); + + assert.deepEqual(savedAudiences(), { + [scope]: [agentB, agentA, agentC], + }); +}); + +test("removal while send awaits wins over late success", async () => { + const store = await loadStore(3); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope); + + store.removePersistentAgentAudienceMember(scope, agentA); + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revisionAtSubmit, + explicitAgentPubkeys: [agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [] }); +}); + +test("removing final chip preserves an explicit empty scope", async () => { + const store = await loadStore(4); + const scope = `${ownerA}:channel-a:thread:root`; + store.setPersistentAgentAudience(scope, [agentA]); + store.removePersistentAgentAudienceMember(scope, agentA); + + assert.deepEqual(savedAudiences(), { [scope]: [] }); +}); + +test("completion after disabling cannot repopulate audiences", async () => { + const store = await loadStore(5); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + store.setPersistentAgentAudience(scope, [agentA]); + const revisionAtSubmit = store.getPersistentAgentAudienceRevision(scope); + store.setPersistentAgentAudienceEnabled(false); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: revisionAtSubmit, + explicitAgentPubkeys: [agentB], + }); + + assert.deepEqual(savedAudiences(), {}); +}); + +test("invalid, duplicate, and differently-cased pubkeys normalize", async () => { + const store = await loadStore(6); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudience(scope, [ + agentA.toUpperCase(), + agentA, + "bad", + ]); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA] }); +}); + +test("new recipients retain explicit mention order", async () => { + const store = await loadStore(9); + const scope = `${ownerA}:channel-a:timeline`; + store.setPersistentAgentAudienceEnabled(true); + + store.promotePersistentAgentAudience({ + expectedGeneration: store.getPersistentAgentAudienceGeneration(), + scope, + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + explicitAgentPubkeys: [agentB, agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentB, agentA] }); +}); + +test("first new-message send resolves its destination after capturing generation", async () => { + const store = await loadStore(7); + const capturedGeneration = store.getPersistentAgentAudienceGeneration(); + store.setPersistentAgentAudienceEnabled(true); + const scope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "resolved-dm", + }); + + store.promotePersistentAgentAudience({ + expectedGeneration: capturedGeneration, + expectedRevision: null, + scope, + explicitAgentPubkeys: [agentA], + }); + + assert.deepEqual(savedAudiences(), { [scope]: [agentA] }); +}); + +test("disable during new-message destination preparation invalidates promotion", async () => { + const store = await loadStore(8); + store.setPersistentAgentAudienceEnabled(true); + const capturedGeneration = store.getPersistentAgentAudienceGeneration(); + store.setPersistentAgentAudienceEnabled(false); + store.setPersistentAgentAudienceEnabled(true); + const scope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "resolved-dm", + }); + + store.promotePersistentAgentAudience({ + expectedGeneration: capturedGeneration, + expectedRevision: null, + scope, + explicitAgentPubkeys: [agentA], + }); + + assert.deepEqual(savedAudiences(), {}); +}); diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts new file mode 100644 index 000000000..286a1e972 --- /dev/null +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -0,0 +1,236 @@ +import * as React from "react"; + +const ENABLED_STORAGE_KEY = "buzz:keep-addressed-agents-active"; +const AUDIENCES_STORAGE_KEY = "buzz:persistent-agent-audiences:v2"; + +const listeners = new Set<() => void>(); +const revisions = new Map(); +let revisionClock = 0; +let defaultRevision = 0; +let generation = 0; +let enabled = readEnabled(); +let audiences = readAudiences(); +let snapshot = buildSnapshot(); + +export type PersistentAgentAudienceSnapshot = Readonly<{ + enabled: boolean; + audiences: Readonly>; + generation: number; +}>; + +type PersistentAgentAudienceScopeInput = { + ownerPubkey: string; + channelId: string; + threadRootId?: string | null; +}; + +function normalizePubkeys(pubkeys: Iterable): string[] { + return [ + ...new Set([...pubkeys].map((pubkey) => pubkey.trim().toLowerCase())), + ].filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)); +} + +function readEnabled(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ENABLED_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function readAudiences(): Record { + if (typeof window === "undefined") return {}; + try { + const parsed: unknown = JSON.parse( + window.localStorage.getItem(AUDIENCES_STORAGE_KEY) ?? "{}", + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + return {}; + + const result: Record = {}; + for (const [scope, value] of Object.entries(parsed)) { + if (scope && Array.isArray(value)) { + result[scope] = normalizePubkeys( + value.filter((entry): entry is string => typeof entry === "string"), + ); + } + } + return result; + } catch { + return {}; + } +} + +function buildSnapshot(): PersistentAgentAudienceSnapshot { + return { enabled, audiences, generation }; +} + +function emit(): void { + snapshot = buildSnapshot(); + for (const listener of listeners) listener(); +} + +function persistAudiences(): void { + try { + window.localStorage.setItem( + AUDIENCES_STORAGE_KEY, + JSON.stringify(audiences), + ); + } catch { + // Persistence is best-effort; the live session still uses in-memory state. + } +} + +function advanceRevision(scope: string): void { + revisionClock += 1; + revisions.set(scope, revisionClock); +} + +export function setPersistentAgentAudienceEnabled(nextEnabled: boolean): void { + if (enabled === nextEnabled) return; + enabled = nextEnabled; + if (!nextEnabled) { + generation += 1; + revisionClock += 1; + defaultRevision = revisionClock; + revisions.clear(); + audiences = {}; + persistAudiences(); + } + try { + window.localStorage.setItem(ENABLED_STORAGE_KEY, nextEnabled ? "1" : "0"); + } catch { + // Persistence is best-effort. + } + emit(); +} + +export function getPersistentAgentAudienceScope({ + ownerPubkey, + channelId, + threadRootId = null, +}: PersistentAgentAudienceScopeInput): string | null { + const owner = ownerPubkey.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null; + const conversation = threadRootId ? `thread:${threadRootId}` : "timeline"; + return `${owner}:${channelId}:${conversation}`; +} + +export function getPersistentAgentAudienceGeneration(): number { + return generation; +} + +export function getPersistentAgentAudienceRevision(scope: string): number { + return revisions.get(scope) ?? defaultRevision; +} + +export function setPersistentAgentAudience( + scope: string, + pubkeys: Iterable, +): void { + if (!scope) return; + const normalized = normalizePubkeys(pubkeys); + const current = audiences[scope]; + if ( + current !== undefined && + current.length === normalized.length && + current.every((pubkey, index) => pubkey === normalized[index]) + ) { + return; + } + + audiences = { ...audiences, [scope]: normalized }; + advanceRevision(scope); + persistAudiences(); + emit(); +} + +export function promotePersistentAgentAudience({ + expectedGeneration, + expectedRevision, + explicitAgentPubkeys, + scope, +}: { + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + scope: string | null; +}): void { + if ( + !enabled || + expectedGeneration !== generation || + !scope || + (expectedRevision !== null && + getPersistentAgentAudienceRevision(scope) !== expectedRevision) + ) { + return; + } + setPersistentAgentAudience(scope, [ + ...explicitAgentPubkeys, + ...(audiences[scope] ?? []), + ]); +} + +export function removePersistentAgentAudienceMember( + scope: string, + pubkey: string, +): void { + setPersistentAgentAudience( + scope, + (audiences[scope] ?? []).filter( + (candidate) => candidate !== pubkey.trim().toLowerCase(), + ), + ); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): PersistentAgentAudienceSnapshot { + return snapshot; +} + +const serverSnapshot: PersistentAgentAudienceSnapshot = { + enabled: false, + audiences: {}, + generation: 0, +}; + +export function usePersistentAgentAudience(scope: string | null): { + enabled: boolean; + pubkeys: readonly string[]; + generation: number; + revision: number; + setEnabled: (enabled: boolean) => void; + promotePubkeys: typeof promotePersistentAgentAudience; + removePubkey: (pubkey: string) => void; + clear: () => void; +} { + const state = React.useSyncExternalStore( + subscribe, + getSnapshot, + () => serverSnapshot, + ); + const resolvedScope = scope ?? ""; + return { + enabled: state.enabled, + pubkeys: resolvedScope ? (state.audiences[resolvedScope] ?? []) : [], + generation: state.generation, + revision: resolvedScope + ? getPersistentAgentAudienceRevision(resolvedScope) + : 0, + setEnabled: setPersistentAgentAudienceEnabled, + promotePubkeys: promotePersistentAgentAudience, + removePubkey: React.useCallback( + (pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey), + [resolvedScope], + ), + clear: React.useCallback( + () => setPersistentAgentAudience(resolvedScope, []), + [resolvedScope], + ), + }; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 7a750f473..18c259efd 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -74,6 +74,14 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) { return null; } +function appendUniqueName(current: string[], name: string): string[] { + return current.some( + (candidate) => candidate.toLowerCase() === name.toLowerCase(), + ) + ? current + : [...current, name]; +} + export function useMentions( channelId: string | null, externalMembers?: ChannelMember[], @@ -694,35 +702,43 @@ export function useMentions( personaMentionMapRef.current.delete(trimmedName); trimMapToSize(mentionMapRef.current, 200); - setSelectedMentionNames((current) => { - if ( - current.some( - (name) => name.toLowerCase() === trimmedName.toLowerCase(), - ) - ) { - return current; - } - - return [...current, trimmedName]; - }); + setSelectedMentionNames((current) => + appendUniqueName(current, trimmedName), + ); if (options?.isAgent) { - setSelectedAgentMentionNames((current) => { - if ( - current.some( - (name) => name.toLowerCase() === trimmedName.toLowerCase(), - ) - ) { - return current; - } - - return [...current, trimmedName]; - }); + setSelectedAgentMentionNames((current) => + appendUniqueName(current, trimmedName), + ); } }, [], ); + const insertResolvedMention = React.useCallback( + ({ + displayName, + pubkey, + replaceFromOffset, + replaceToOffset, + isAgent = false, + }: { + displayName: string; + pubkey: string; + replaceFromOffset: number; + replaceToOffset: number; + isAgent?: boolean; + }): AutocompleteEdit => { + registerMentionPubkey(displayName, pubkey, { isAgent }); + return { + replaceFromOffset, + replaceToOffset, + insertText: `@${displayName.trim()} `, + }; + }, + [registerMentionPubkey], + ); + const getMentionDisplayName = React.useCallback( (pubkey: string): string | null => { const normalizedPubkey = normalizePubkey(pubkey); @@ -956,6 +972,7 @@ export function useMentions( handleMentionKeyDown, hasResolvedMembers: members !== undefined, insertMention, + insertResolvedMention, agentKnownNames: agentHighlightNames, isAgentPubkey, isManagedAgentPubkey, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 2cd9a7b6c..0fc42a9ff 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -26,6 +26,8 @@ import { useMediaUpload, } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { hasMentionClipboardHtml, @@ -54,10 +56,16 @@ import { import { MessageComposerToolbar } from "./MessageComposerToolbar"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; +import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +type MessageComposerAudienceContext = + | { type: "timeline" } + | { type: "thread"; threadRootId: string }; + type MessageComposerProps = { + audienceContext?: MessageComposerAudienceContext | null; channelId?: string | null; channelName: string; channelType?: ChannelType | null; @@ -137,6 +145,7 @@ type MessageComposerProps = { }; function MessageComposerImpl({ + audienceContext = null, channelId = null, channelName, channelType = null, @@ -186,7 +195,19 @@ function MessageComposerImpl({ }, []); const drafts = useDrafts(); + const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; + const ownerPubkey = identityQuery.data?.pubkey ?? null; + const audienceThreadRootId = + audienceContext?.type === "thread" ? audienceContext.threadRootId : null; + const audienceScope = + audienceContext && channelId && ownerPubkey + ? getPersistentAgentAudienceScope({ + ownerPubkey, + channelId, + threadRootId: audienceThreadRootId, + }) + : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; // Snapshot composer state before edit mode so cancel can restore it. @@ -321,6 +342,8 @@ function MessageComposerImpl({ channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); + persistentMentionHydrationRef.current?.reconcile(text); + if (text.trim().length > 0) { notifyTyping(); } @@ -338,6 +361,19 @@ function MessageComposerImpl({ onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); + const persistentMentionHydration = usePersistentAgentMentionHydration({ + audienceScope, + hydrationKey: effectiveDraftKey, + isEditing: editTarget != null, + mentions, + richText, + }); + const persistentAudience = persistentMentionHydration.audience; + const persistentMentionHydrationRef = React.useRef( + persistentMentionHydration, + ); + persistentMentionHydrationRef.current = persistentMentionHydration; + const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -354,6 +390,17 @@ function MessageComposerImpl({ setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, setSpoileredAttachmentUrls, + onSuccessfulExplicitAgentAudience: + persistentAudience.enabled && audienceContext && ownerPubkey + ? ({ channelId: successfulChannelId, ...promotion }) => { + const scope = getPersistentAgentAudienceScope({ + ownerPubkey, + channelId: successfulChannelId, + threadRootId: audienceThreadRootId, + }); + persistentAudience.promotePubkeys({ ...promotion, scope }); + } + : undefined, }); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger @@ -617,6 +664,7 @@ function MessageComposerImpl({ } onPreparingMentionSendChange?.(true); + persistentMentionHydration.beginSubmit(); try { await mentionSendFlow.sendMessageWithMentionFlow({ capturedChannelId: channelId, @@ -628,8 +676,11 @@ function MessageComposerImpl({ ), spoileredAttachmentUrls, trimmed, + audienceGeneration: persistentAudience.generation, + audienceRevision: audienceScope ? persistentAudience.revision : null, }); } finally { + persistentMentionHydration.endSubmit(); onPreparingMentionSendChange?.(false); } }, [ @@ -650,6 +701,10 @@ function MessageComposerImpl({ syncComposerContentFromEditor, onCaptureSendContext, onPreparingMentionSendChange, + audienceScope, + persistentMentionHydration, + persistentAudience.generation, + persistentAudience.revision, ]); submitMessageRef.current = submitMessage; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index c01ff9f9c..952a16fcb 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -881,6 +881,10 @@ export function MessageThreadPanel({ >
{ + const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all( + [ + source("../../channels/ui/ChannelPane.tsx"), + source("./MessageThreadPanel.tsx"), + source("./NewMessageScreen.tsx"), + source("../../home/ui/InboxDetailPane.tsx"), + ], + ); + + assert.match(channelPane, /audienceContext=\{\{ type: "timeline" \}\}/); + assert.match(newMessage, /audienceContext=\{\{ type: "timeline" \}\}/); + assert.match( + threadPanel, + /type: "thread"[\s\S]*threadRootId: threadHead\.id/, + ); + assert.match( + inboxDetail, + /type: "thread"[\s\S]*threadRootId: item\.conversationId/, + ); +}); + +test("video review remains explicitly outside persistent audience routing", async () => { + const videoPlayer = await source("../../../shared/ui/VideoPlayer.tsx"); + const composer = videoPlayer.slice(videoPlayer.indexOf("") + 2), + /audienceContext=/, + ); +}); + +test("composer never derives audience context from draft keys", async () => { + const composer = await source("./MessageComposer.tsx"); + + assert.doesNotMatch(composer, /draftKey\?\.startsWith\("thread:"\)/); + assert.match(composer, /audienceContext\?\.type === "thread"/); +}); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index c76c0cb52..6dc4db95e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -12,6 +12,7 @@ import { } from "@/features/agents/hooks"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; +import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -45,6 +46,10 @@ type PendingNonMemberMentionSend = { savedImeta: ImetaMedia[]; savedSpoileredAttachmentUrls: Set; sentDraftKey: string | null | undefined; + audienceGeneration: number; + audienceRevision: number | null; + /** Agent mentions explicitly authored in this draft (never inferred). */ + explicitAgentPubkeys: string[]; }; type SendMessageWithMentionFlowInput = { @@ -58,6 +63,8 @@ type SendMessageWithMentionFlowInput = { sentDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; + audienceGeneration?: number; + audienceRevision?: number | null; }; type UseMentionSendFlowOptions = { @@ -91,6 +98,12 @@ type UseMentionSendFlowOptions = { setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; + onSuccessfulExplicitAgentAudience?: (audience: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => void; }; function mergeOutgoingTagsWithReferenceMentions( @@ -145,6 +158,7 @@ export function useMentionSendFlow({ setIsEmojiPickerOpen, setPendingImeta, setSpoileredAttachmentUrls, + onSuccessfulExplicitAgentAudience, }: UseMentionSendFlowOptions) { const [pendingNonMemberSend, setPendingNonMemberSend] = React.useState(null); @@ -487,6 +501,22 @@ export function useMentionSendFlow({ sendChannelId, draft.capturedThreadContext, ); + const effectiveExplicitAgentPubkeys = + filterEffectiveExplicitAgentPubkeys( + draft.explicitAgentPubkeys, + mentionPubkeys, + ); + if (effectiveExplicitAgentPubkeys.length > 0) { + // Promote only explicitly authored agents that remained effective + // for this successful send. "Send without inviting" removes its + // excluded recipients here as well as from event routing. + onSuccessfulExplicitAgentAudience?.({ + channelId: sendChannelId ?? draft.capturedChannelId ?? "", + expectedGeneration: draft.audienceGeneration, + expectedRevision: draft.audienceRevision, + explicitAgentPubkeys: effectiveExplicitAgentPubkeys, + }); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -525,6 +555,7 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, onPrepareSendChannel, onSendRef, + onSuccessfulExplicitAgentAudience, richText.setContent, setContent, setPendingImeta, @@ -597,6 +628,8 @@ export function useMentionSendFlow({ sentDraftKey, spoileredAttachmentUrls = new Set(), trimmed, + audienceGeneration = 0, + audienceRevision = null, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { return; @@ -643,10 +676,16 @@ export function useMentionSendFlow({ const createdPersonaAgentPubkeySet = new Set( createdPersonaAgentPubkeys.map(normalizePubkey), ); - const pubkeys = uniqueNormalizedPubkeys([ + const explicitMentionPubkeys = uniqueNormalizedPubkeys([ ...mentions.extractMentionPubkeys(trimmed), ...createdPersonaAgentPubkeys, ]); + const explicitAgentPubkeys = explicitMentionPubkeys.filter( + (pubkey) => + mentions.isAgentPubkey(pubkey) || + createdPersonaAgentPubkeySet.has(pubkey), + ); + const pubkeys = explicitMentionPubkeys; const { content: finalContent, mediaTags } = buildOutgoingMessage( trimmed, pendingImeta, @@ -691,6 +730,9 @@ export function useMentionSendFlow({ savedImeta: [...pendingImeta], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, + audienceGeneration, + audienceRevision, + explicitAgentPubkeys, }; if (promptNonMemberPubkeys.length > 0) { @@ -714,6 +756,7 @@ export function useMentionSendFlow({ getNonMemberMentionPubkeys, getDmThreadAgentMentionError, mentions.extractMentionPubkeys, + mentions.isAgentPubkey, mentions.isManagedAgentPubkey, onPrepareSendChannel, ], diff --git a/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts new file mode 100644 index 000000000..bec049b0c --- /dev/null +++ b/desktop/src/features/messages/ui/usePersistentAgentMentionHydration.ts @@ -0,0 +1,123 @@ +import * as React from "react"; + +import { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; + +export function usePersistentAgentMentionHydration({ + audienceScope, + hydrationKey, + isEditing, + mentions, + richText, +}: { + audienceScope: string | null; + hydrationKey: string | null | undefined; + isEditing: boolean; + mentions: UseMentionsResult; + richText: UseRichTextEditorResult; +}) { + const audience = usePersistentAgentAudience(audienceScope); + const audienceRef = React.useRef(audience); + audienceRef.current = audience; + const scopeRef = React.useRef(audienceScope); + scopeRef.current = audienceScope; + const isEditingRef = React.useRef(isEditing); + isEditingRef.current = isEditing; + const isRestoringRef = React.useRef(false); + const isSubmittingRef = React.useRef(false); + const hydratedRef = React.useRef(false); + + const hydrate = React.useCallback(() => { + const capturedScope = audienceScope; + if ( + !audience.enabled || + !capturedScope || + isEditingRef.current || + audience.pubkeys.length === 0 + ) { + hydratedRef.current = true; + return; + } + isRestoringRef.current = true; + const current = richText.getPlainTextAndCursor().text; + const targets = audience.pubkeys + .map((pubkey) => ({ + pubkey, + displayName: mentions.getMentionDisplayName(pubkey), + })) + .filter((target): target is { pubkey: string; displayName: string } => + Boolean(target.displayName), + ); + for (const target of targets) + mentions.registerMentionPubkey(target.displayName, target.pubkey, { + isAgent: true, + }); + if (scopeRef.current !== capturedScope) { + isRestoringRef.current = false; + return; + } + const present = new Set(mentions.extractMentionPubkeys(current)); + let prefixLength = 0; + for (const target of targets.filter( + (candidate) => !present.has(candidate.pubkey), + )) { + if (scopeRef.current !== capturedScope) break; + const edit = mentions.insertResolvedMention({ + ...target, + isAgent: true, + replaceFromOffset: prefixLength, + replaceToOffset: prefixLength, + }); + richText.replacePlainTextRange( + edit.replaceFromOffset, + edit.replaceToOffset, + edit.insertText, + ); + prefixLength += edit.insertText.length; + } + hydratedRef.current = scopeRef.current === capturedScope; + isRestoringRef.current = false; + }, [audience.enabled, audience.pubkeys, audienceScope, mentions, richText]); + + const reconcile = React.useCallback( + (text: string) => { + if ( + !hydratedRef.current || + isRestoringRef.current || + isSubmittingRef.current || + isEditingRef.current + ) + return; + const present = new Set(mentions.extractMentionPubkeys(text)); + for (const pubkey of audienceRef.current.pubkeys) { + if (!present.has(pubkey)) audienceRef.current.removePubkey(pubkey); + } + }, + [mentions.extractMentionPubkeys], + ); + + const scheduleHydration = React.useCallback( + () => requestAnimationFrame(hydrate), + [hydrate], + ); + React.useEffect(() => { + void hydrationKey; + hydratedRef.current = false; + const frame = scheduleHydration(); + return () => cancelAnimationFrame(frame); + }, [hydrationKey, scheduleHydration]); + + return { + audience, + beginSubmit: () => { + isSubmittingRef.current = true; + }, + endSubmit: () => { + isSubmittingRef.current = false; + scheduleHydration(); + }, + reconcile, + scheduleHydration, + }; +} diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index c146b3171..56e9c294b 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,20 +1,46 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { + setPersistentAgentAudienceEnabled, + usePersistentAgentAudience, +} from "@/features/messages/lib/persistentAgentAudience"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = usePreventSleepContext(); + const persistentAudience = usePersistentAgentAudience(null); return (
+ +
+ +

+ Keep agents you address selected for future messages in the same + channel or thread. Remove them from the composer at any time. +

+
+ +
+
+ + {joinPolicy && policyRelayUrl ? ( + + { + setAgeConfirmed(confirmed); + setError(null); + }} + onAgreementConfirmedChange={(confirmed) => { + setAgreementConfirmed(confirmed); + setError(null); + }} + policy={joinPolicy} + relayWsUrl={policyRelayUrl} + /> + + ) : null} + +
) : null} + {policyError ? ( +

{policyError}

+ ) : null} + {error ? (

{error}

) : null} + + {joinPolicy && policyInvite ? ( + + { + setAgeConfirmed(confirmed); + setPolicyError(null); + }} + onAgreementConfirmedChange={(confirmed) => { + setAgreementConfirmed(confirmed); + setPolicyError(null); + }} + policy={joinPolicy} + relayWsUrl={policyInvite.relayWsUrl} + /> + + ) : null} + + + ) : null} + {policy.termsMarkdown && policy.privacyMarkdown ? " and " : null} + {policy.privacyMarkdown ? ( + + ) : null} + . + +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx index fc70b4977..ce7df5425 100644 --- a/desktop/src/features/onboarding/ui/MembershipDenied.tsx +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -84,11 +84,12 @@ export function MembershipDenied({ }, [onImportKey, previewNpub, trimmedNsec]); const handleInviteRedeem = React.useCallback( - (relayWsUrl: string, code: string) => { + (relayWsUrl: string, code: string, policyReceipt?: string) => { communityOnboarding.start({ source: "membership-recovery", relayUrl: relayWsUrl, inviteCode: code, + policyReceipt, }); }, [communityOnboarding], diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index afb99a5ff..2f4d5ade4 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -41,7 +41,7 @@ export function NostrKeyImportForm({ const trimmedInput = nsecInput.trim(); const hasInput = trimmedInput.length > 0; const isValid = previewNpub !== null; - const isBusy = disabled || isImporting; + const isInteractionDisabled = disabled || isImporting; const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; @@ -50,12 +50,12 @@ export function NostrKeyImportForm({ }, []); const openFilePicker = React.useCallback(() => { - if (isBusy) { + if (isInteractionDisabled) { return; } fileInputRef.current?.click(); - }, [isBusy]); + }, [isInteractionDisabled]); const handleFiles = React.useCallback(async (files: FileList | null) => { const file = files?.[0]; @@ -98,7 +98,7 @@ export function NostrKeyImportForm({ await onImport(trimmedInput); } catch (error) { setImportError( - error instanceof Error ? error.message : "Failed to import key.", + error instanceof Error ? error.message : "Couldn't import this key.", ); } finally { setIsImporting(false); @@ -141,7 +141,7 @@ export function NostrKeyImportForm({ { void handleFiles(event.currentTarget.files); event.currentTarget.value = ""; @@ -159,12 +159,12 @@ export function NostrKeyImportForm({ )} data-dragging={isDragging ? "true" : undefined} data-testid="nostr-import-drop" - disabled={isBusy} + disabled={isInteractionDisabled} onClick={openFilePicker} onDragEnter={(event) => { event.preventDefault(); event.stopPropagation(); - if (!isBusy) { + if (!isInteractionDisabled) { setIsDragging(true); } }} @@ -181,7 +181,7 @@ export function NostrKeyImportForm({ onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); - if (!isBusy) { + if (!isInteractionDisabled) { event.dataTransfer.dropEffect = "copy"; } }} @@ -189,7 +189,7 @@ export function NostrKeyImportForm({ event.preventDefault(); event.stopPropagation(); setIsDragging(false); - if (isBusy) { + if (isInteractionDisabled) { return; } void handleFiles(event.dataTransfer.files); @@ -250,10 +250,10 @@ export function NostrKeyImportForm({ + ) : ( + "Terms of Service" + )}{" "} + and{" "} + {policy.privacy_markdown ? ( + + ) : ( + "Privacy Policy" + )} + . + + ) : null} + + ); +} diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 3a460a1ba..72a1d11c7 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -1,21 +1,72 @@ import buzzAppIcon from "@/assets/app-icon@3x.png"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; +import * as React from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest"; +type JoinPolicy = { + terms_markdown?: string; + privacy_markdown?: string; + age_attestation_required: boolean; + version: string; +}; -/** - * Landing page for a community invite link (`/invite/`). - * - * The code is not validated here — validation happens in the desktop app when - * the invite is claimed against `POST /api/invites/claim`, signed by the - * joining key. This page only hands the code off via the `buzz://join` deep - * link (or tells the visitor where to get the app first). - */ +type PolicyDocument = { title: string; markdown: string }; + +/** Landing page for a community invite link (`/invite/`). */ export function InvitePage({ code }: { code: string }) { const relay = relayWsUrl(); const host = relay.replace(/^wss?:\/\//, ""); - const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`; + const [policy, setPolicy] = React.useState( + undefined, + ); + const [document, setDocument] = React.useState(null); + const [ageConfirmed, setAgeConfirmed] = React.useState(false); + const [opening, setOpening] = React.useState(false); + + React.useEffect(() => { + fetch("/api/join-policy") + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const config = (await response.json()) as { policy?: JoinPolicy }; + setPolicy(config.policy ?? null); + }) + .catch(() => setPolicy(undefined)); + }, []); + + const openInvite = async () => { + setOpening(true); + try { + let receipt: string | undefined; + if (policy) { + const response = await fetch("/api/invites/accept-policy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + policy_version: policy.version, + age_confirmed: ageConfirmed, + }), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + receipt = ((await response.json()) as { receipt: string }).receipt; + } + const query = new URLSearchParams({ relay, code }); + if (receipt) query.set("policy_receipt", receipt); + window.location.href = `buzz://join?${query.toString()}`; + } finally { + setOpening(false); + } + }; + + const disabled = + policy === undefined || + opening || + Boolean(policy?.age_attestation_required && !ageConfirmed); + const showDocument = (title: string, markdown: string) => + setDocument({ title, markdown }); return (
-
-
- Buzz -
-

- You're invited to join -

-

{host}

- -
- -
+ Buzz +
+

+ You're invited to +

+

{host}

-

+ {policy?.age_attestation_required && ( + + )} +

+ {policy === null ? ( + + ) : ( + + )} +
+ {policy && (policy.terms_markdown || policy.privacy_markdown) && ( +

+ By proceeding you agree to the Buzz{" "} + {policy.terms_markdown && ( + + )} + {policy.terms_markdown && policy.privacy_markdown && " and "} + {policy.privacy_markdown && ( + + )} + . +

+ )} +
+

Don't have the app?{" "}

+ + {document && ( +
{ + if (event.currentTarget === event.target) setDocument(null); + }} + > +
+
+

{document.title}

+ +
+
+ + {document.markdown} + +
+
+
+ )} ); } diff --git a/web/tests/e2e/smoke.spec.ts b/web/tests/e2e/smoke.spec.ts index 183197dac..3e897b12f 100644 --- a/web/tests/e2e/smoke.spec.ts +++ b/web/tests/e2e/smoke.spec.ts @@ -9,3 +9,67 @@ test("home page shows repositories section", async ({ page }) => { await page.goto("/"); await expect(page.getByText("Repositories")).toBeVisible(); }); + +test("invite requires age and legal consent before opening Buzz", async ({ + page, +}) => { + await page.route("**/api/join-policy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + policy: { + terms_markdown: "# Terms", + privacy_markdown: "# Privacy", + age_attestation_required: true, + version: "policy-v1", + }, + }), + }); + }); + await page.goto("/invite/demo-code"); + + const ageConfirmation = page.getByLabel("I am 18 years of age or older."); + const agreementConfirmation = page.getByLabel( + "I agree to the Buzz Terms of Service and Privacy Policy.", + ); + const acceptInvite = page.getByRole("button", { + name: "Accept invite in Buzz", + }); + + await expect(ageConfirmation).toBeVisible(); + await expect(agreementConfirmation).toBeVisible(); + await expect(acceptInvite).toBeDisabled(); + + const termsLink = page.getByRole("button", { name: "Terms of Service" }); + const privacyLink = page.getByRole("button", { name: "Privacy Policy" }); + await expect(termsLink).toHaveCSS("text-decoration-line", "none"); + await expect(privacyLink).toHaveCSS("text-decoration-line", "none"); + await termsLink.hover(); + await expect(termsLink).toHaveCSS("text-decoration-line", "underline"); + await page.mouse.move(0, 0); + await privacyLink.hover(); + await expect(privacyLink).toHaveCSS("text-decoration-line", "underline"); + + await page + .locator("label") + .filter({ hasText: "I am 18 years of age or older." }) + .click(); + await expect(ageConfirmation).toBeChecked(); + await expect(acceptInvite).toBeDisabled(); + await page + .locator("label") + .filter({ + hasText: "I agree to the Buzz Terms of Service and Privacy Policy.", + }) + .click({ position: { x: 8, y: 8 } }); + await expect(agreementConfirmation).toBeChecked(); + await expect(acceptInvite).toBeEnabled(); + + const consentBox = await page + .getByTestId("invite-join-policy-notice") + .boundingBox(); + const acceptButtonBox = await acceptInvite.boundingBox(); + expect(consentBox?.y).toBeLessThan(acceptButtonBox?.y ?? 0); + expect(consentBox?.width).toBe(acceptButtonBox?.width); +}); From 3dd236eb6fd7c0dd86a9174d2852bc9ae9861912 Mon Sep 17 00:00:00 2001 From: tulsi Date: Thu, 16 Jul 2026 14:01:53 -0400 Subject: [PATCH 04/12] unify channel add + search into one entry point (#1964) --- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.tsx | 134 ++-- desktop/src/app/AppShellOverlays.tsx | 7 + .../channels/lib/channelSearchScore.test.mjs | 113 +++ .../channels/lib/channelSearchScore.ts | 119 ++++ .../channels/ui/ChannelBrowserDialog.tsx | 670 ++++++++++++------ .../sidebar/lib/useCreateChannelForm.ts | 214 ++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 6 +- .../sidebar/ui/CreateChannelDialog.tsx | 385 +--------- .../sidebar/ui/CreateChannelFormFields.tsx | 301 ++++++++ .../sidebar/ui/CustomChannelSection.tsx | 16 +- .../tests/e2e/channel-add-screenshots.spec.ts | 46 ++ desktop/tests/e2e/channel-browser.spec.ts | 191 +++++ desktop/tests/helpers/bridge.ts | 22 +- 14 files changed, 1598 insertions(+), 627 deletions(-) create mode 100644 desktop/src/features/channels/lib/channelSearchScore.test.mjs create mode 100644 desktop/src/features/channels/lib/channelSearchScore.ts create mode 100644 desktop/src/features/sidebar/lib/useCreateChannelForm.ts create mode 100644 desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx create mode 100644 desktop/tests/e2e/channel-add-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 874e7e189..805fcb289 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ "**/channel-shared-header-backdrop.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", + "**/channel-add-screenshots.spec.ts", "**/messaging.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b4e95344e..eadb84d9d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -80,7 +80,7 @@ import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; -import type { SearchHit } from "@/shared/api/types"; +import type { ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; @@ -441,6 +441,83 @@ export function AppShell() { [queryClient], ); + const handleCreateChannel = React.useCallback( + async ({ + description, + name, + visibility, + ttlSeconds, + templateId, + }: { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; + }) => { + const createdChannel = await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await applyCanvas(templateId, createdChannel.id, name); + await goChannel(createdChannel.id); + void applyAgents(templateId, createdChannel.id); + }, + [applyAgents, applyCanvas, createChannelMutation, goChannel], + ); + + const handleCreateForum = React.useCallback( + async ({ + description, + name, + visibility, + ttlSeconds, + templateId, + }: { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; + }) => { + const createdForum = await createForumMutation.mutateAsync({ + name, + description, + channelType: "forum", + visibility, + ttlSeconds, + }); + + await applyCanvas(templateId, createdForum.id, name); + await goChannel(createdForum.id); + void applyAgents(templateId, createdForum.id); + }, + [applyAgents, applyCanvas, createForumMutation, goChannel], + ); + + // The channel browser can create either a stream or a forum depending on + // which section opened it. Route to the matching handler. + const handleBrowseChannelCreate = React.useCallback( + async (input: { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; + }) => { + if (browseDialogType === "forum") { + await handleCreateForum(input); + } else { + await handleCreateChannel(input); + } + }, + [browseDialogType, handleCreateChannel, handleCreateForum], + ); + const handleHideDm = React.useCallback( async (channelId: string) => { try { @@ -779,54 +856,8 @@ export function AppShell() { onCreateAgent={() => requestOpenCreateAgent()} selfPresenceStatus={presenceSession.currentStatus} communities={communitiesHook.communities} - onCreateChannel={async ({ - description, - name, - visibility, - ttlSeconds, - templateId, - }) => { - const createdChannel = - await createChannelMutation.mutateAsync({ - name, - description, - channelType: "stream", - visibility, - ttlSeconds, - }); - - await applyCanvas( - templateId, - createdChannel.id, - name, - ); - await goChannel(createdChannel.id); - void applyAgents(templateId, createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - templateId, - }) => { - const createdForum = - await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); - - await applyCanvas( - templateId, - createdForum.id, - name, - ); - await goChannel(createdForum.id); - void applyAgents(templateId, createdForum.id); - }} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} onHideDm={handleHideDm} onMarkAllChannelsRead={markAllChannelsRead} onMarkChannelRead={markChannelRead} @@ -913,7 +944,12 @@ export function AppShell() { channels={channels} currentPubkey={identityQuery.data?.pubkey} isChannelManagementOpen={isChannelManagementOpen} + isCreatingBrowseChannel={ + createChannelMutation.isPending || + createForumMutation.isPending + } onBrowseChannelJoin={handleBrowseChannelJoin} + onBrowseChannelCreate={handleBrowseChannelCreate} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} onChannelManagementOpenChange={(open) => { setIsChannelManagementOpen(open); diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 514492a29..5311a2933 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { Channel } from "@/shared/api/types"; +import type { CreateChannelInput } from "@/features/sidebar/lib/useCreateChannelForm"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; const ChannelBrowserDialog = React.lazy(async () => { @@ -21,7 +22,9 @@ type AppShellOverlaysProps = { channels: Channel[]; currentPubkey?: string; isChannelManagementOpen: boolean; + isCreatingBrowseChannel?: boolean; onBrowseChannelJoin: (channelId: string) => Promise; + onBrowseChannelCreate?: (input: CreateChannelInput) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; onChannelManagementOpenChange: (open: boolean) => void; onDeleteActiveChannel: () => void; @@ -34,7 +37,9 @@ export function AppShellOverlays({ channels, currentPubkey, isChannelManagementOpen, + isCreatingBrowseChannel, onBrowseChannelJoin, + onBrowseChannelCreate, onBrowseDialogOpenChange, onChannelManagementOpenChange, onDeleteActiveChannel, @@ -67,6 +72,8 @@ export function AppShellOverlays({ { + assert.equal(scoreChannelName("release-notes", ""), 0); +}); + +test("scoreChannelName: exact name beats prefix beats word matches", () => { + const exact = scoreChannelName("general", "general"); + const prefix = scoreChannelName("general-chat", "general"); + const wordExact = scoreChannelName("team-general", "general"); + assert.ok(exact < prefix, "exact should rank above prefix"); + assert.ok(prefix < wordExact, "prefix should rank above later-word match"); +}); + +test("scoreChannelName: matches a later whole word", () => { + // "notes" is the second word of "release-notes" + assert.notEqual(scoreChannelName("release-notes", "notes"), null); +}); + +test("scoreChannelName: matches a later word prefix", () => { + assert.notEqual(scoreChannelName("release-notes", "not"), null); +}); + +test("scoreChannelName: plain substring still matches", () => { + assert.notEqual(scoreChannelName("release-notes", "ease"), null); +}); + +test("scoreChannelName: collapsing separators matches 'releasenotes'", () => { + // The core pain point: dropping the dash should still find the channel. + const score = scoreChannelName("release-notes", "releasenotes"); + assert.notEqual(score, null); +}); + +test("scoreChannelName: partial across the separator ('releasenot')", () => { + assert.notEqual(scoreChannelName("release-notes", "releasenot"), null); +}); + +test("scoreChannelName: subsequence matches 'reln'", () => { + // r-e-l...n appears in order inside "release-notes" + assert.notEqual(scoreChannelName("release-notes", "reln"), null); +}); + +test("scoreChannelName: subsequence works across underscores and dots", () => { + assert.notEqual(scoreChannelName("build_and_deploy", "bd"), null); + assert.notEqual(scoreChannelName("v1.2.release", "vrel"), null); +}); + +test("scoreChannelName: single-char subsequence noise is rejected", () => { + // A lone char that isn't a prefix/substring should NOT fuzzy-match, or every + // channel containing that letter would show up. + assert.equal(scoreChannelName("release-notes", "z"), null); + // "x" doesn't appear at all + assert.equal(scoreChannelName("release-notes", "x"), null); +}); + +test("scoreChannelName: unrelated query returns null", () => { + assert.equal(scoreChannelName("release-notes", "budget"), null); +}); + +test("scoreChannelName: subsequence requires correct order", () => { + // "sn" — 's' then 'n' — is NOT in order in "release-notes" (n comes... yes it + // is: relea-s-e-n-otes). Use a genuinely out-of-order example instead. + assert.equal(scoreChannelName("abc", "ca"), null); +}); + +test("scoreChannelName: better matches score lower than fuzzier ones", () => { + const prefix = scoreChannelName("release-notes", "release"); + const collapsed = scoreChannelName("release-notes", "releasenotes"); + const subsequence = scoreChannelName("release-notes", "reln"); + assert.ok(prefix < collapsed, "prefix beats collapsed-separator match"); + assert.ok(collapsed < subsequence, "collapsed beats subsequence match"); +}); + +test("scoreChannelMatch: name match outranks description match", () => { + const nameHit = scoreChannelMatch( + { name: "release-notes", description: "" }, + "release", + ); + const descHit = scoreChannelMatch( + { name: "random", description: "release coordination" }, + "release", + ); + assert.ok( + nameHit !== null && descHit !== null && nameHit < descHit, + "a name match should rank above a description-only match", + ); +}); + +test("scoreChannelMatch: description only does plain substring, not fuzzy", () => { + // "reln" should not fuzzy-match the description. + assert.equal( + scoreChannelMatch({ name: "random", description: "release notes" }, "reln"), + null, + ); + // But a real substring in the description matches. + assert.notEqual( + scoreChannelMatch( + { name: "random", description: "release notes" }, + "notes", + ), + null, + ); +}); + +test("scoreChannelMatch: no match anywhere returns null", () => { + assert.equal( + scoreChannelMatch({ name: "general", description: "chat" }, "budget"), + null, + ); +}); diff --git a/desktop/src/features/channels/lib/channelSearchScore.ts b/desktop/src/features/channels/lib/channelSearchScore.ts new file mode 100644 index 000000000..3f27a8fbc --- /dev/null +++ b/desktop/src/features/channels/lib/channelSearchScore.ts @@ -0,0 +1,119 @@ +/** + * Lightweight fuzzy matching for the channel browser search box. + * + * Mirrors the philosophy of `mentionRanking.ts`: cheap, dependency-free, + * separator-aware scoring — no Levenshtein / typo-tolerance (which reorders + * results unpredictably and hides the channel a user can plainly see). + * + * The one thing plain substring search gets wrong is contiguity across + * separators: typing `releasenotes` or `reln` should still find + * `release-notes`. We fix that with two extra passes on top of substring: + * + * 1. word-boundary tokens (split on space/`-`/`_`), so multi-word names match + * when the separators are dropped or a later word is typed, and + * 2. an in-order subsequence check, so `reln` matches `release-notes`. + * + * Lower score === better match. `null` means "no match". + */ + +/** Separators that delimit words in a channel name/description. */ +const WORD_SEPARATORS = /[\s\-_./]+/; + +// Score bands. Kept as named steps so the intent (and ordering) is legible. +const SCORE_EXACT = 0; +const SCORE_PREFIX = 1; +const SCORE_WORD_EXACT = 2; +const SCORE_WORD_PREFIX = 3; +const SCORE_SUBSTRING = 4; +const SCORE_COLLAPSED_SEPARATORS = 5; +const SCORE_SUBSEQUENCE = 6; +const SCORE_DESCRIPTION = 7; + +/** Strip separators so `release-notes` and `releasenotes` compare equal. */ +function collapseSeparators(value: string): string { + return value.replace(/[\s\-_./]+/g, ""); +} + +/** + * Whether every char of `query` appears in `text` in order (not necessarily + * contiguously). e.g. `reln` is a subsequence of `release-notes`. + */ +function isSubsequence(query: string, text: string): boolean { + if (query.length === 0) return true; + let queryIndex = 0; + for (const char of text) { + if (char === query[queryIndex]) { + queryIndex += 1; + if (queryIndex === query.length) return true; + } + } + return false; +} + +/** + * Score how well `name` matches `lowerQuery`. Returns the best (lowest) band, + * or `null` if the name doesn't match at all. `lowerQuery` must already be + * lowercased and trimmed. + */ +export function scoreChannelName( + name: string, + lowerQuery: string, +): number | null { + if (lowerQuery.length === 0) return SCORE_EXACT; + + const lower = name.toLowerCase(); + + if (lower === lowerQuery) return SCORE_EXACT; + if (lower.startsWith(lowerQuery)) return SCORE_PREFIX; + + const words = lower.split(WORD_SEPARATORS).filter(Boolean); + if (words.some((word) => word === lowerQuery)) return SCORE_WORD_EXACT; + if (words.some((word) => word.startsWith(lowerQuery))) { + return SCORE_WORD_PREFIX; + } + + if (lower.includes(lowerQuery)) return SCORE_SUBSTRING; + + // `releasenotes` → matches `release-notes` once separators are removed. + const collapsedName = collapseSeparators(lower); + const collapsedQuery = collapseSeparators(lowerQuery); + if (collapsedQuery.length > 0 && collapsedName.includes(collapsedQuery)) { + return SCORE_COLLAPSED_SEPARATORS; + } + + // `reln` → matches `release-notes` as an in-order subsequence. Guard against + // 1-char queries producing noise by requiring at least 2 chars here. + if (collapsedQuery.length >= 2 && isSubsequence(collapsedQuery, lower)) { + return SCORE_SUBSEQUENCE; + } + + return null; +} + +export type ChannelSearchable = { + name: string; + description: string; +}; + +/** + * Score a channel against a query, considering both name and description. + * Description matches are always ranked below any name match. Returns `null` + * when neither field matches. + */ +export function scoreChannelMatch( + channel: ChannelSearchable, + lowerQuery: string, +): number | null { + if (lowerQuery.length === 0) return SCORE_EXACT; + + const nameScore = scoreChannelName(channel.name, lowerQuery); + if (nameScore !== null) return nameScore; + + // Description only does plain substring — it's supplementary context, so we + // don't want fuzzy description hits outranking or crowding out name matches. + if (channel.description.toLowerCase().includes(lowerQuery)) { + return SCORE_DESCRIPTION; + } + + return null; +} diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index 5eb2cd431..8f1d162af 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -1,7 +1,15 @@ import * as React from "react"; -import { Compass, Search, X, type LucideIcon } from "lucide-react"; +import { + ArrowLeft, + Compass, + Plus, + Search, + X, + type LucideIcon, +} from "lucide-react"; import type { Channel } from "@/shared/api/types"; +import { scoreChannelMatch } from "@/features/channels/lib/channelSearchScore"; import { ListSortDescending } from "@/shared/ui/icons"; import { Dialog, @@ -26,6 +34,16 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { + type CreateChannelInput, + useCreateChannelForm, +} from "@/features/sidebar/lib/useCreateChannelForm"; +import { + CREATE_CHANNEL_FORM_ID, + CreateChannelFormFields, + CreateChannelFormFooter, +} from "@/features/sidebar/ui/CreateChannelFormFields"; + type BrowserTab = "all" | "joined" | "archived"; type ChannelSort = "alphabetical" | "members"; @@ -63,6 +81,13 @@ type ChannelBrowserDialogProps = { onOpenChange: (open: boolean) => void; onJoinChannel: (channelId: string) => Promise; onSelectChannel: (channelId: string) => void; + /** + * Create a new channel/forum from within the browser. When provided, the + * dialog surfaces a "Create …" affordance (Are.na style) so search and + * create live behind a single entry point. + */ + onCreateChannel?: (input: CreateChannelInput) => Promise; + isCreatingChannel?: boolean; }; export function ChannelBrowserDialog({ @@ -72,6 +97,8 @@ export function ChannelBrowserDialog({ onOpenChange, onJoinChannel, onSelectChannel, + onCreateChannel, + isCreatingChannel = false, }: ChannelBrowserDialogProps) { const [query, setQuery] = React.useState(""); const [activeTab, setActiveTab] = React.useState("all"); @@ -80,6 +107,8 @@ export function ChannelBrowserDialog({ const [joiningChannelId, setJoiningChannelId] = React.useState( null, ); + const [mode, setMode] = React.useState<"browse" | "create">("browse"); + const [createInitialName, setCreateInitialName] = React.useState(""); const inputRef = React.useRef(null); const tabListRef = React.useRef(null); const tabTriggerRefs = React.useRef< @@ -94,14 +123,47 @@ export function ChannelBrowserDialog({ width: 0, }); const deferredQuery = React.useDeferredValue(query.trim().toLowerCase()); + const trimmedQuery = query.trim(); + // Immediate (non-deferred) lowercased query. The create row's visibility + // (via hasExactMatch) and its label both read from the live query so they + // can never disagree for a frame while the fuzzy filter catches up. + const normalizedQuery = trimmedQuery.toLowerCase(); const isForumMode = channelTypeFilter === "forum"; - const browseTitle = isForumMode ? "Browse Forums" : "Browse Channels"; - const searchPlaceholder = isForumMode - ? "Search forums by name or description" - : "Search channels by name or description"; + const canCreate = Boolean(onCreateChannel); + const createKind = isForumMode ? "forum" : "stream"; + const browseTitle = isForumMode ? "Add a forum" : "Add a channel"; + const searchPlaceholder = canCreate + ? isForumMode + ? "Search or create a forum" + : "Search or create a channel" + : isForumMode + ? "Search forums by name or description" + : "Search channels by name or description"; const entityLabel = isForumMode ? "forum" : "channel"; + const noopCreate = React.useCallback(async () => {}, []); + const createForm = useCreateChannelForm({ + channelKind: createKind, + active: open && mode === "create", + initialName: createInitialName, + isCreating: isCreatingChannel, + onCreate: onCreateChannel ?? noopCreate, + onCreated: () => onOpenChange(false), + }); + + // Fuzzy match score per channel id for the current query, so both filtering + // and relevance-ordering share one source of truth. Empty when no query. + const matchScoreById = React.useMemo(() => { + const scores = new Map(); + if (deferredQuery.length === 0) return scores; + for (const channel of channels) { + const score = scoreChannelMatch(channel, deferredQuery); + if (score !== null) scores.set(channel.id, score); + } + return scores; + }, [channels, deferredQuery]); + const matchingChannels = React.useMemo(() => { const filtered = channels.filter( (channel) => @@ -116,12 +178,8 @@ export function ChannelBrowserDialog({ return filtered; } - return filtered.filter( - (channel) => - channel.name.toLowerCase().includes(deferredQuery) || - channel.description.toLowerCase().includes(deferredQuery), - ); - }, [channels, channelTypeFilter, deferredQuery]); + return filtered.filter((channel) => matchScoreById.has(channel.id)); + }, [channels, channelTypeFilter, deferredQuery, matchScoreById]); const currentChannels = React.useMemo( () => matchingChannels.filter((channel) => channel.archivedAt === null), @@ -145,23 +203,67 @@ export function ChannelBrowserDialog({ ? joinedChannels : matchingChannels; + const isSearching = deferredQuery.length > 0; + const orderedVisibleChannels = React.useMemo(() => { return [...visibleChannels].sort((a, b) => { + // While searching, best match wins so the channel you meant floats to + // the top; ties fall back to the user's chosen sort below. + if (isSearching) { + const scoreA = matchScoreById.get(a.id) ?? Number.POSITIVE_INFINITY; + const scoreB = matchScoreById.get(b.id) ?? Number.POSITIVE_INFINITY; + if (scoreA !== scoreB) return scoreA - scoreB; + } + if (sort === "members" && b.memberCount !== a.memberCount) { return b.memberCount - a.memberCount; } return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }); - }, [sort, visibleChannels]); + }, [isSearching, matchScoreById, sort, visibleChannels]); const allTabLabel = isForumMode ? "All forums" : "All channels"; + // Whether an exact name match already exists — if so we don't offer to + // create a duplicate, mirroring how you'd never make two "#general"s. + const hasExactMatch = React.useMemo( + () => + channels.some( + (channel) => + channel.channelType !== "dm" && + channel.name.toLowerCase() === normalizedQuery && + (channelTypeFilter + ? channel.channelType === channelTypeFilter + : true), + ), + [channels, channelTypeFilter, normalizedQuery], + ); + + // The pinned create row (Are.na style) appears for any non-empty query that + // isn't already an exact channel name — covering both partial-match and + // no-match cases, so a dedicated empty-state button would be redundant. + // The create row is present from the moment the dialog opens (so it's clear + // you can browse *or* create), then specializes to "Create «query»" as you + // type. It only hides when the query is an exact match for an existing name + // — creating a duplicate "#general" makes no sense. + const showCreateRow = canCreate && !hasExactMatch; + + // The create row participates in keyboard navigation as a virtual item so + // arrow keys reach it and Enter activates it — not just Tab. It's rendered + // pinned at the top, so it takes nav index 0 and channels shift down by one, + // keeping keyboard order identical to visual order. + const channelNavOffset = showCreateRow ? 1 : 0; + const createRowIndex = showCreateRow ? 0 : null; + const navItemCount = orderedVisibleChannels.length + channelNavOffset; + const isCreateRowSelected = + createRowIndex !== null && selectedIndex === createRowIndex; + const updateTabIndicator = React.useCallback(() => { const list = tabListRef.current; const trigger = tabTriggerRefs.current[activeTab]; - if (!open || !list || !trigger) { + if (!open || mode !== "browse" || !list || !trigger) { return; } @@ -176,12 +278,12 @@ export function ChannelBrowserDialog({ ? current : nextIndicator, ); - }, [activeTab, open]); + }, [activeTab, mode, open]); React.useLayoutEffect(() => { updateTabIndicator(); - if (!open) { + if (!open || mode !== "browse") { return; } @@ -212,7 +314,7 @@ export function ChannelBrowserDialog({ window.cancelAnimationFrame(frameId); observer.disconnect(); }; - }, [open, updateTabIndicator]); + }, [mode, open, updateTabIndicator]); React.useEffect(() => { if (!open) { @@ -221,19 +323,21 @@ export function ChannelBrowserDialog({ setSort("alphabetical"); setSelectedIndex(null); setJoiningChannelId(null); + setMode("browse"); + setCreateInitialName(""); return; } }, [open]); React.useEffect(() => { setSelectedIndex((current) => { - if (current === null || orderedVisibleChannels.length === 0) { + if (current === null || navItemCount === 0) { return null; } - return Math.min(current, orderedVisibleChannels.length - 1); + return Math.min(current, navItemCount - 1); }); - }, [orderedVisibleChannels]); + }, [navItemCount]); async function handleJoin(channelId: string) { setJoiningChannelId(channelId); @@ -252,8 +356,25 @@ export function ChannelBrowserDialog({ onSelectChannel(channel.id); } + function enterCreateMode(prefillName: string) { + setCreateInitialName(prefillName); + setMode("create"); + } + + function exitCreateMode() { + setMode("browse"); + // Return focus to the search field so keyboard users stay oriented. + window.requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + } + + // Map the flat nav index back to a channel, accounting for the create row + // occupying index 0 when present. const selectedItem = - selectedIndex !== null ? orderedVisibleChannels[selectedIndex] : undefined; + selectedIndex !== null && !isCreateRowSelected + ? orderedVisibleChannels[selectedIndex - channelNavOffset] + : undefined; const emptyTitle = deferredQuery.length > 0 ? `No ${entityLabel}s match your search` @@ -264,7 +385,9 @@ export function ChannelBrowserDialog({ : `No ${entityLabel}s to browse`; const emptyDescription = deferredQuery.length > 0 - ? "Try a different name or keyword." + ? canCreate + ? `No ${entityLabel} by that name yet — create it to get started.` + : "Try a different name or keyword." : activeTab === "archived" ? `Archived ${entityLabel}s you have joined will appear here.` : activeTab === "joined" @@ -281,206 +404,343 @@ export function ChannelBrowserDialog({ } onOpenAutoFocus={(event) => { event.preventDefault(); - inputRef.current?.focus({ preventScroll: true }); + if (mode === "browse") { + inputRef.current?.focus({ preventScroll: true }); + } }} showCloseButton={false} > - -
- {browseTitle} - - - Close - -
-
- - - - - - - Sort by - + { + setQuery(event.target.value); + setSelectedIndex(null); + }} + onKeyDown={(event) => { + // Arrow keys traverse the pinned create row (index 0) + // and the channel list beneath it, in visual order. + if (event.key === "ArrowDown" && navItemCount > 0) { + event.preventDefault(); + setSelectedIndex((current) => + current === null + ? 0 + : Math.min(current + 1, navItemCount - 1), + ); + return; + } + + if (event.key === "ArrowUp" && navItemCount > 0) { + event.preventDefault(); + setSelectedIndex((current) => + current === null + ? navItemCount - 1 + : Math.max(current - 1, 0), + ); + return; + } + + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing + ) { + // If the create row is highlighted — or it's the only + // actionable item (no channel matches) — Enter creates. + if ( + showCreateRow && + (isCreateRowSelected || + orderedVisibleChannels.length === 0) + ) { + event.preventDefault(); + enterCreateMode(trimmedQuery); + return; + } + + if (orderedVisibleChannels.length > 0) { + event.preventDefault(); + handleSelect( + selectedItem ?? orderedVisibleChannels[0], + ); + } + } + }} + placeholder={searchPlaceholder} + ref={inputRef} + spellCheck={false} + type="text" + value={query} + /> + + + + + + + Sort by + { + setSort(value as ChannelSort); + setSelectedIndex(null); + }} + value={sort} + > + {CHANNEL_SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+ +
+
+ { - setSort(value as ChannelSort); + setActiveTab(value as BrowserTab); setSelectedIndex(null); }} - value={sort} + value={activeTab} > - {CHANNEL_SORT_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - -
- - -
-
- { - setActiveTab(value as BrowserTab); - setSelectedIndex(null); - }} - value={activeTab} - > - - - - -
- {orderedVisibleChannels.length === 0 ? ( - 0 ? Search : Compass} - title={emptyTitle} - /> - ) : ( -
- {orderedVisibleChannels.map((channel, index) => ( - { - void handleJoin(channel.id); - } - : undefined - } - onSelect={() => handleSelect(channel)} + + + + +
+ {showCreateRow ? ( +
+ enterCreateMode(trimmedQuery)} + query={trimmedQuery} + /> +
+ ) : null} + + {orderedVisibleChannels.length === 0 ? ( + 0 ? Search : Compass} + title={emptyTitle} + /> + ) : ( +
+ {orderedVisibleChannels.map((channel, index) => ( + { + void handleJoin(channel.id); + } + : undefined + } + onSelect={() => handleSelect(channel)} + /> + ))} +
+ )}
- )} +
-
-
+ + )} ); } +function CreateChannelRow({ + entityLabel, + isSelected, + onClick, + query, +}: { + entityLabel: string; + isSelected: boolean; + onClick: () => void; + query: string; +}) { + const hasQuery = query.length > 0; + return ( + + ); +} + +function ChannelCreateView({ + entityLabel, + form, + onBack, + onClose, +}: { + entityLabel: string; + form: ReturnType; + onBack: () => void; + onClose: () => void; +}) { + return ( +
+ +
+
+ + + {`New ${entityLabel}`} + +
+ +
+
+ +
+
+ + +
+ +
+ +
+
+ ); +} + function ChannelCard({ channel, isJoining, diff --git a/desktop/src/features/sidebar/lib/useCreateChannelForm.ts b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts new file mode 100644 index 000000000..4b3021950 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts @@ -0,0 +1,214 @@ +import { ClockFading, Hash, type LucideIcon } from "lucide-react"; +import * as React from "react"; + +import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; +import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel"; +import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types"; + +export type CreateChannelKind = "stream" | "forum"; + +export type CreateChannelInput = { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; +}; + +type UseCreateChannelFormOptions = { + channelKind: CreateChannelKind; + /** + * When this flips to `true` the form resets its fields (and applies + * `initialName`). Pass the dialog/mode's open state. + */ + active: boolean; + initialName?: string; + isCreating: boolean; + onCreate: (input: CreateChannelInput) => Promise; + onCreated?: () => void; + autoFocusName?: boolean; +}; + +export type CreateChannelFormState = { + channelKind: CreateChannelKind; + kindLabel: string; + name: string; + setName: (value: string) => void; + description: string; + setDescription: (value: string) => void; + visibility: ChannelVisibility; + setVisibility: (value: ChannelVisibility) => void; + ephemeral: boolean; + setEphemeral: (value: boolean) => void; + durationLabel: string; + DurationIcon: LucideIcon; + typePopoverOpen: boolean; + setTypePopoverOpen: (open: boolean) => void; + errorMessage: string | null; + selectedTemplateId: string | null; + handleTemplateChange: (templateId: string) => void; + templates: ChannelTemplate[]; + nameInputRef: React.RefObject; + isCreating: boolean; + canSubmit: boolean; + handleSubmit: (event: React.FormEvent) => void; +}; + +/** + * Shared state + submit logic for the create-channel form. Powers both the + * standalone `CreateChannelDialog` and the create mode of the unified + * "Add channel" browser dialog, so the two stay behaviorally identical. + */ +export function useCreateChannelForm({ + channelKind, + active, + initialName, + isCreating, + onCreate, + onCreated, + autoFocusName = true, +}: UseCreateChannelFormOptions): CreateChannelFormState { + const [name, setName] = React.useState(initialName ?? ""); + const [description, setDescription] = React.useState(""); + const [visibility, setVisibility] = React.useState("open"); + const [ephemeral, setEphemeral] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [selectedTemplateId, setSelectedTemplateId] = React.useState< + string | null + >(null); + const [typePopoverOpen, setTypePopoverOpen] = React.useState(false); + const nameInputRef = React.useRef(null); + + const templatesQuery = useChannelTemplatesQuery(); + const templates = templatesQuery.data ?? []; + + const kindLabel = channelKind === "forum" ? "forum" : "channel"; + const durationLabel = ephemeral ? "Temporary" : "Ongoing"; + const DurationIcon = ephemeral ? ClockFading : Hash; + + React.useEffect(() => { + if (!active) return; + + setName(initialName ?? ""); + setDescription(""); + setVisibility("open"); + setEphemeral(false); + setErrorMessage(null); + setSelectedTemplateId(null); + setTypePopoverOpen(false); + + if (!autoFocusName) return; + + // Small delay to let the dialog animation start before focusing. + const timerId = globalThis.setTimeout(() => { + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + activeElement.closest("#create-channel-form") + ) { + return; + } + const input = nameInputRef.current; + if (!input) return; + input.focus(); + // Place the caret at the end of any prefilled name. + const end = input.value.length; + input.setSelectionRange(end, end); + }, 50); + return () => globalThis.clearTimeout(timerId); + }, [active, autoFocusName, initialName]); + + const handleTemplateChange = React.useCallback( + (templateId: string) => { + if (!templateId) { + setSelectedTemplateId(null); + setDescription(""); + setVisibility("open"); + setErrorMessage(null); + return; + } + + const template = templates.find( + (t: ChannelTemplate) => t.id === templateId, + ); + if (!template) return; + + setSelectedTemplateId(templateId); + setDescription(template.description ?? ""); + setVisibility(template.visibility); + setErrorMessage(null); + }, + [templates], + ); + + const handleSubmit = React.useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedName = name.trim(); + if (!trimmedName) return; + + setErrorMessage(null); + + void (async () => { + try { + await onCreate({ + name: trimmedName, + description: description.trim() || undefined, + visibility, + ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined, + templateId: selectedTemplateId ?? undefined, + }); + onCreated?.(); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : `Failed to create ${kindLabel}.`, + ); + } + })(); + }, + [ + description, + ephemeral, + kindLabel, + name, + onCreate, + onCreated, + selectedTemplateId, + visibility, + ], + ); + + return { + channelKind, + kindLabel, + name, + setName: (value: string) => { + setName(value); + setErrorMessage(null); + }, + description, + setDescription: (value: string) => { + setDescription(value); + setErrorMessage(null); + }, + visibility, + setVisibility, + ephemeral, + setEphemeral, + durationLabel, + DurationIcon, + typePopoverOpen, + setTypePopoverOpen, + errorMessage, + selectedTemplateId, + handleTemplateChange, + templates, + nameInputRef, + isCreating, + canSubmit: name.trim().length > 0 && !isCreating, + handleSubmit, + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index bc86d97ab..7010986fe 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -678,8 +678,6 @@ export function AppSidebar({ /> ))} 0} isCollapsed={collapsedGroups.channels} @@ -692,8 +690,8 @@ export function AppSidebar({ } actionsTestId="section-actions-channels" listTestId="stream-list" - onBrowseClick={onBrowseChannels} - onCreateClick={() => openCreateDialog("stream")} + quickCreateLabel="Add channel" + onQuickCreateClick={onBrowseChannels} showQuickCreate onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} diff --git a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx index bb98754a1..b2554c52f 100644 --- a/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx +++ b/desktop/src/features/sidebar/ui/CreateChannelDialog.tsx @@ -1,24 +1,16 @@ -import { ChevronDown, ClockFading, Hash, type LucideIcon } from "lucide-react"; -import * as React from "react"; - -import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; -import { DEFAULT_EPHEMERAL_TTL_SECONDS } from "@/features/channels/lib/ephemeralChannel"; -import type { ChannelTemplate, ChannelVisibility } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; +import type { ChannelVisibility } from "@/shared/api/types"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; -import { Switch } from "@/shared/ui/switch"; -import { Textarea } from "@/shared/ui/textarea"; -const CREATE_FIELD_SHELL_CLASS = - "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; -const CREATE_FIELD_CONTROL_CLASS = - "border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0"; -const CREATE_LABEL_OPTIONAL_CLASS = - "ml-1 text-xs font-normal text-muted-foreground/50"; +import { + type CreateChannelInput, + useCreateChannelForm, +} from "@/features/sidebar/lib/useCreateChannelForm"; +import { + CREATE_CHANNEL_FORM_ID, + CreateChannelFormFields, + CreateChannelFormFooter, +} from "@/features/sidebar/ui/CreateChannelFormFields"; type ChannelKind = "stream" | "forum"; @@ -43,100 +35,16 @@ export function CreateChannelDialog({ onCreate, }: CreateChannelDialogProps) { const open = channelKind !== null; - const [name, setName] = React.useState(""); - const [description, setDescription] = React.useState(""); - const [visibility, setVisibility] = React.useState("open"); - const [ephemeral, setEphemeral] = React.useState(false); - const [errorMessage, setErrorMessage] = React.useState(null); - const [selectedTemplateId, setSelectedTemplateId] = React.useState< - string | null - >(null); - const [typePopoverOpen, setTypePopoverOpen] = React.useState(false); - const nameInputRef = React.useRef(null); - const templatesQuery = useChannelTemplatesQuery(); - const templates = templatesQuery.data ?? []; + const form = useCreateChannelForm({ + channelKind: channelKind ?? "stream", + active: open, + isCreating, + onCreate: onCreate as (input: CreateChannelInput) => Promise, + onCreated: () => onOpenChange(false), + }); const kindLabel = channelKind === "forum" ? "forum" : "channel"; - const durationLabel = ephemeral ? "Temporary" : "Ongoing"; - const DurationIcon = ephemeral ? ClockFading : Hash; - - React.useEffect(() => { - if (!open) return; - - setName(""); - setDescription(""); - setVisibility("open"); - setEphemeral(false); - setErrorMessage(null); - setSelectedTemplateId(null); - setTypePopoverOpen(false); - - // Small delay to let dialog animation start before focusing - const timerId = globalThis.setTimeout(() => { - const activeElement = document.activeElement; - if ( - activeElement instanceof HTMLElement && - activeElement.closest("#create-channel-form") - ) { - return; - } - nameInputRef.current?.focus(); - }, 50); - return () => globalThis.clearTimeout(timerId); - }, [open]); - - function handleTemplateChange(templateId: string) { - if (!templateId) { - setSelectedTemplateId(null); - setDescription(""); - setVisibility("open"); - setErrorMessage(null); - return; - } - - const template = templates.find( - (t: ChannelTemplate) => t.id === templateId, - ); - if (!template) return; - - setSelectedTemplateId(templateId); - - setDescription(template.description ?? ""); - setVisibility(template.visibility); - - // If the template's channel type differs from current dialog kind, - // we still apply the visibility but don't change the kind - // (kind is determined by how the dialog was opened) - setErrorMessage(null); - } - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - - const trimmedName = name.trim(); - if (!trimmedName) return; - - setErrorMessage(null); - - try { - await onCreate({ - name: trimmedName, - description: description.trim() || undefined, - visibility, - ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined, - templateId: selectedTemplateId ?? undefined, - }); - - onOpenChange(false); - } catch (error) { - setErrorMessage( - error instanceof Error - ? error.message - : `Failed to create ${kindLabel}.`, - ); - } - } return ( - - - - - -
- Channel type -
-
- Channel type - { - setEphemeral(false); - setTypePopoverOpen(false); - }} - /> - { - setEphemeral(true); - setTypePopoverOpen(false); - }} - /> -
-
-
- -
- } + footer={} >
{ - void handleSubmit(event); - }} + id={CREATE_CHANNEL_FORM_ID} + onSubmit={form.handleSubmit} > -
- -
- { - setName(event.target.value); - setErrorMessage(null); - }} - placeholder={ - channelKind === "forum" - ? "design-discussions" - : "release-notes" - } - ref={nameInputRef} - spellCheck={false} - value={name} - /> -
-
- -
- -
-