From dc1ee8ff0e47fec725d0dcea213f784f48bb3f32 Mon Sep 17 00:00:00 2001 From: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Date: Sun, 12 Jul 2026 08:49:33 +0100 Subject: [PATCH 01/28] feat(desktop): add conversation-style DM composer Replace the new-direct-message modal with a routed conversation compose surface, keyboard-driven recipient picker, and first-message send flow. Keep recipient removal and the shared poof sound synchronized. Signed-off-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> --- desktop/src/app/AppShell.helpers.ts | 8 + desktop/src/app/AppShell.tsx | 11 +- .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 + desktop/src/app/routes.ts | 1 + desktop/src/app/routes/messages.new.tsx | 7 + .../messages/ui/NewMessageResultRow.tsx | 106 +++ .../features/messages/ui/NewMessageScreen.tsx | 471 ++++++++++ .../messages/ui/useNewMessageRecipients.ts | 305 ++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 25 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 1 + .../sidebar/ui/NewDirectMessageDialog.tsx | 877 ------------------ desktop/src/shared/ui/PoofBurstProvider.tsx | 73 +- desktop/tests/e2e/channels.spec.ts | 135 ++- .../tests/e2e/identity-archive-hide.spec.ts | 9 +- .../e2e/pubkey-display-screenshots.spec.ts | 47 +- desktop/tests/helpers/bridge.ts | 3 +- 17 files changed, 1152 insertions(+), 960 deletions(-) create mode 100644 desktop/src/app/routes/messages.new.tsx create mode 100644 desktop/src/features/messages/ui/NewMessageResultRow.tsx create mode 100644 desktop/src/features/messages/ui/NewMessageScreen.tsx create mode 100644 desktop/src/features/messages/ui/useNewMessageRecipients.ts delete mode 100644 desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 166c06725d..b0ce894931 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -5,6 +5,7 @@ import type { SearchHit } from "@/shared/api/types"; export type AppView = | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" @@ -117,6 +118,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/messages/new") { + return { + selectedChannelId: null, + selectedView: "messages", + }; + } + if (pathname === "/agents") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 905235aa88..f7c6c496f7 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -109,7 +109,6 @@ export function AppShell() { const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(null); - const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); @@ -119,6 +118,7 @@ export function AppShell() { goAgents, goChannel, goHome, + goNewMessage, goProjects, goPulse, goSettings, @@ -547,7 +547,10 @@ export function AppShell() { // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []); + const handleOpenNewDm = React.useCallback( + () => void goNewMessage(), + [goNewMessage], + ); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -754,8 +757,6 @@ export function AppShell() { isCreatingChannel={createChannelMutation.isPending} isCreatingForum={createForumMutation.isPending} isLoading={channelsQuery.isLoading} - isOpeningDm={openDmMutation.isPending} - isNewDmOpen={isNewDmOpen} isCreateChannelOpen={isCreateChannelOpen} isPresencePending={presenceSession.isPending} onAddWorkspace={(workspace) => { @@ -763,7 +764,7 @@ export function AppShell() { handleSwitchWorkspace(id); }} onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} - onNewDmOpenChange={setIsNewDmOpen} + onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} onUpdateWorkspace={workspacesHook.updateWorkspace} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index e6ecae125e..ae2adc31e6 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -185,6 +185,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goNewMessage = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/messages/new", + }, + behavior, + ), + [commitNavigation], + ); + const goForumPost = React.useCallback( ( channelId: string, @@ -284,6 +295,7 @@ export function useAppNavigation() { goChannel, goForumPost, goHome, + goNewMessage, goProject, goProjects, goPulse, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index d38c7100af..2bc2c8ddb6 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId"; import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId"; +import { Route as messagesDotnewRouteImport } from "./routes/messages.new"; import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId"; import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId"; @@ -62,6 +63,11 @@ const projectsDotprojectIdRoute = projectsDotprojectIdRouteImport.update({ path: "/projects/$projectId", getParentRoute: () => rootRouteImport, } as any); +const messagesDotnewRoute = messagesDotnewRouteImport.update({ + id: "/messages/new", + path: "/messages/new", + getParentRoute: () => rootRouteImport, +} as any); const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({ id: "/channels/$channelId", path: "/channels/$channelId", @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -96,6 +103,7 @@ export interface FileRoutesByTo { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -110,6 +118,7 @@ export interface FileRoutesById { "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; + "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; "/workflows/$workflowId": typeof workflowsDotworkflowIdRoute; "/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -125,6 +134,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -138,6 +148,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -151,6 +162,7 @@ export interface FileRouteTypes { | "/settings" | "/workflows" | "/channels/$channelId" + | "/messages/new" | "/projects/$projectId" | "/workflows/$workflowId" | "/channels/$channelId/posts/$postId"; @@ -165,6 +177,7 @@ export interface RootRouteChildren { settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; + messagesDotnewRoute: typeof messagesDotnewRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute; channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute; @@ -235,6 +248,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof projectsDotprojectIdRouteImport; parentRoute: typeof rootRouteImport; }; + "/messages/new": { + id: "/messages/new"; + path: "/messages/new"; + fullPath: "/messages/new"; + preLoaderRoute: typeof messagesDotnewRouteImport; + parentRoute: typeof rootRouteImport; + }; "/channels/$channelId": { id: "/channels/$channelId"; path: "/channels/$channelId"; @@ -261,6 +281,7 @@ const rootRouteChildren: RootRouteChildren = { settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, + messagesDotnewRoute: messagesDotnewRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute, channelsDotchannelIdDotpostsDotpostIdRoute: diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index 5dc4789ae6..f5c6938e11 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,7 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( "/channels/$channelId/posts/$postId", diff --git a/desktop/src/app/routes/messages.new.tsx b/desktop/src/app/routes/messages.new.tsx new file mode 100644 index 0000000000..dbb5497e55 --- /dev/null +++ b/desktop/src/app/routes/messages.new.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { NewMessageScreen } from "@/features/messages/ui/NewMessageScreen"; + +export const Route = createFileRoute("/messages/new")({ + component: NewMessageScreen, +}); diff --git a/desktop/src/features/messages/ui/NewMessageResultRow.tsx b/desktop/src/features/messages/ui/NewMessageResultRow.tsx new file mode 100644 index 0000000000..9d25f0cfc6 --- /dev/null +++ b/desktop/src/features/messages/ui/NewMessageResultRow.tsx @@ -0,0 +1,106 @@ +import { Bot } from "lucide-react"; + +import { formatOwnerLabel } from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +import { formatRecipientName } from "./useNewMessageRecipients"; + +const RESULT_ROW_INSET_DIVIDER_CLASS = + "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; + +/** + * A single selectable person/agent row in the new-message directory. Extracted + * from the former NewDirectMessageDialog so the compose page renders identical + * rows (avatar, agent badge, owner label, hover-revealed npub). + */ +export function NewMessageResultRow({ + currentPubkey, + disabled, + isKeyboardHighlighted = false, + onSelect, + ownerProfiles, + user, +}: { + currentPubkey?: string; + disabled: boolean; + isKeyboardHighlighted?: boolean; + onSelect: (user: UserSearchResult) => void; + ownerProfiles?: UserProfileLookup; + user: UserSearchResult; +}) { + const name = formatRecipientName(user); + const ownerLabel = formatOwnerLabel( + user.ownerPubkey, + currentPubkey, + ownerProfiles, + ); + + return ( +
+
+ ); +} diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx new file mode 100644 index 0000000000..63de0027e4 --- /dev/null +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -0,0 +1,471 @@ +import { Bot, X } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { useSendMessageMutation } from "@/features/messages/hooks"; +import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { cn } from "@/shared/lib/cn"; +import { + POOF_ORIGIN_CLASS, + POOF_TRIGGER_CLASS, +} from "@/shared/ui/PoofBurstProvider"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; + +import { MessageComposer } from "./MessageComposer"; +import { NewMessageResultRow } from "./NewMessageResultRow"; +import { + formatRecipientName, + useNewMessageRecipients, +} from "./useNewMessageRecipients"; + +/** + * Conversation-shaped compose surface for starting a direct message. The + * normal chat header becomes an inline "To:" field, while recipient discovery + * lives in an attached popover instead of taking over the message area. + */ +export function NewMessageScreen() { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const openDmMutation = useOpenDmMutation(); + const sendMessageMutation = useSendMessageMutation(null, identityQuery.data); + const { goChannel } = useAppNavigation(); + + const [isRecipientPickerOpen, setIsRecipientPickerOpen] = + React.useState(true); + const [highlightedRecipientPubkey, setHighlightedRecipientPubkey] = + React.useState(null); + const [submitErrorMessage, setSubmitErrorMessage] = React.useState< + string | null + >(null); + const searchInputRef = React.useRef(null); + const isPending = openDmMutation.isPending || sendMessageMutation.isPending; + + const { + deferredSearchQuery, + handleDirectoryScroll, + hasReachedRecipientLimit, + isDirectoryLoading, + ownerProfiles, + removeUser, + searchError, + searchQuery, + searchResults, + selectUser, + selectedUsers, + setSearchQuery, + } = useNewMessageRecipients({ active: true, currentPubkey }); + + const showRecipientPicker = + isRecipientPickerOpen && !isPending && !hasReachedRecipientLimit; + const highlightedRecipientIndex = React.useMemo(() => { + if (!showRecipientPicker || searchResults.length === 0) { + return -1; + } + + if (highlightedRecipientPubkey === null) { + return 0; + } + + return searchResults.findIndex( + (user) => user.pubkey === highlightedRecipientPubkey, + ); + }, [highlightedRecipientPubkey, searchResults, showRecipientPicker]); + const highlightedRecipient = + highlightedRecipientIndex < 0 + ? null + : (searchResults[highlightedRecipientIndex] ?? null); + + React.useEffect(() => { + if (highlightedRecipientPubkey && highlightedRecipientIndex < 0) { + setHighlightedRecipientPubkey(null); + } + }, [highlightedRecipientIndex, highlightedRecipientPubkey]); + + React.useEffect(() => { + if (!highlightedRecipient || !showRecipientPicker) { + return; + } + + document + .getElementById(`new-dm-option-${highlightedRecipient.pubkey}`) + ?.scrollIntoView({ block: "nearest" }); + }, [highlightedRecipient, showRecipientPicker]); + + React.useEffect(() => { + searchInputRef.current?.focus({ preventScroll: true }); + }, []); + + const handleRemoveUser = React.useCallback( + (pubkey: string) => { + removeUser(pubkey); + }, + [removeUser], + ); + + const handleSelectUser = React.useCallback( + (user: Parameters[0]) => { + selectUser(user); + setHighlightedRecipientPubkey(null); + setSubmitErrorMessage(null); + setIsRecipientPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + }, + [selectUser], + ); + + const openDirectMessage = React.useCallback(async () => { + if (isPending || selectedUsers.length === 0) { + return null; + } + + setSubmitErrorMessage(null); + + try { + return await openDmMutation.mutateAsync({ + pubkeys: selectedUsers.map((user) => user.pubkey), + }); + } catch (error) { + setSubmitErrorMessage( + error instanceof Error + ? error.message + : "Failed to open direct message.", + ); + return null; + } + }, [isPending, openDmMutation, selectedUsers]); + + const sendFirstMessage = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + const directMessage = await openDirectMessage(); + if (!directMessage) { + throw new Error( + submitErrorMessage ?? "Choose at least one recipient first.", + ); + } + + try { + await sendMessageMutation.mutateAsync({ + channelId: directMessage.id, + content, + mentionPubkeys, + mediaTags, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to send message."; + setSubmitErrorMessage(message); + throw error; + } + + await goChannel(directMessage.id, { replace: true }); + }, + [goChannel, openDirectMessage, sendMessageMutation, submitErrorMessage], + ); + + const composerPlaceholder = + selectedUsers.length === 0 + ? "Choose a recipient to start a message" + : selectedUsers.length === 1 + ? `Message ${formatRecipientName(selectedUsers[0])}` + : `Message ${selectedUsers.length} people`; + + return ( +
+
+
+ + + {/* biome-ignore lint/a11y/noStaticElementInteractions: clicking anywhere in the recipient field focuses its input */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: the nested combobox is the keyboard-accessible focus target */} +
{ + setIsRecipientPickerOpen(true); + searchInputRef.current?.focus({ preventScroll: true }); + }} + > + + To: + + {selectedUsers.map((user) => ( + + ))} + { + setSearchQuery(event.target.value); + setHighlightedRecipientPubkey(null); + setSubmitErrorMessage(null); + setIsRecipientPickerOpen(true); + }} + onFocus={() => setIsRecipientPickerOpen(true)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setHighlightedRecipientPubkey(null); + setIsRecipientPickerOpen(false); + return; + } + + if ( + (event.key === "ArrowDown" || event.key === "ArrowUp") && + searchResults.length > 0 + ) { + event.preventDefault(); + setIsRecipientPickerOpen(true); + setHighlightedRecipientPubkey(() => { + const current = highlightedRecipientIndex; + if (current < 0) { + const initialIndex = + event.key === "ArrowDown" + ? 0 + : searchResults.length - 1; + return searchResults[initialIndex]?.pubkey ?? null; + } + + const direction = event.key === "ArrowDown" ? 1 : -1; + const nextIndex = + (current + direction + searchResults.length) % + searchResults.length; + return searchResults[nextIndex]?.pubkey ?? null; + }); + return; + } + + if ( + event.key === "Backspace" && + searchQuery.length === 0 && + selectedUsers.length > 0 + ) { + event.preventDefault(); + const lastRecipient = + selectedUsers[selectedUsers.length - 1]; + if (lastRecipient) { + document + .querySelector( + `[data-testid="new-dm-selected-${lastRecipient.pubkey}"]`, + ) + ?.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + detail: 0, + }), + ); + } + return; + } + + if (event.key !== "Enter") { + return; + } + + if (searchQuery.trim().length === 0) { + if (highlightedRecipient) { + event.preventDefault(); + handleSelectUser(highlightedRecipient); + } + return; + } + + const keyboardSelection = + highlightedRecipient ?? + getKeyboardSearchSelection({ + currentQuery: searchQuery, + rankedQuery: deferredSearchQuery, + results: searchResults, + }); + if (!keyboardSelection) { + return; + } + + event.preventDefault(); + handleSelectUser(keyboardSelection); + }} + ref={searchInputRef} + role="combobox" + spellCheck={false} + type="text" + value={searchQuery} + /> +
+
+ event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + side="bottom" + sideOffset={6} + > +
+ {searchResults.length > 0 ? ( +
+ {searchResults.map((user) => ( + + ))} +
+ ) : isDirectoryLoading ? ( +

+ {deferredSearchQuery.length === 0 + ? "Loading people and agents…" + : "Searching…"} +

+ ) : ( +

+ {deferredSearchQuery.length === 0 + ? "No people or agents available to message." + : "No matching users."} +

+ )} +
+
+
+ + {isPending ? ( + + Opening… + + ) : null} +
+
+ +
+ + {hasReachedRecipientLimit ? ( +

+ DMs support up to nine people, including you. +

+ ) : null} + {searchError ? ( +

+ {searchError.message} +

+ ) : null} + {submitErrorMessage ? ( +

+ {submitErrorMessage} +

+ ) : null} + + + + ); +} diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts new file mode 100644 index 0000000000..5d0a6f48b0 --- /dev/null +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -0,0 +1,305 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { + coalesceAgentAutocompleteCandidates, + getMentionableAgentPubkeys, + getSharedChannelIds, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUserSearchFetchMoreOnScroll, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +/** Maximum recipients (excluding the current user) a DM can address. */ +export const NEW_MESSAGE_RECIPIENT_LIMIT = 8; + +const DIRECTORY_PAGE_SIZE = 50; + +export type NewMessageRecipientCandidate = UserSearchResult & { + isManagedAgent?: boolean; + isMember?: boolean; + personaId?: string | null; +}; + +export function formatRecipientName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +function candidateWithAgentMetadata( + candidate: UserSearchResult, + managedAgentsByPubkey: ReadonlyMap, +): NewMessageRecipientCandidate { + const agent = managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)); + return { + ...candidate, + isManagedAgent: Boolean(agent), + personaId: agent?.personaId, + }; +} + +/** + * Shared recipient-picker state for the new-message compose surface: search + * query, selected recipients (chips), the ranked candidate directory, and the + * paging/owner-profile helpers the row UI needs. Extracted from the former + * NewDirectMessageDialog so the compose page (and any future surface) share a + * single, tested selection model. + */ +export function useNewMessageRecipients({ + active, + currentPubkey, +}: { + /** When false, the directory/agent queries stay idle. */ + active: boolean; + currentPubkey?: string; +}) { + const [searchQuery, setSearchQuery] = React.useState(""); + const [selectedUsers, setSelectedUsers] = React.useState( + [], + ); + const selectedUsersCountRef = React.useRef(0); + const deferredSearchQuery = React.useDeferredValue(searchQuery.trim()); + const hasReachedRecipientLimit = + selectedUsers.length >= NEW_MESSAGE_RECIPIENT_LIMIT; + + const selectedPubkeys = React.useMemo( + () => new Set(selectedUsers.map((user) => normalizePubkey(user.pubkey))), + [selectedUsers], + ); + + const identityQuery = useIdentityQuery(); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: active }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: active }); + const channelsQuery = useChannelsQuery({ enabled: active }); + const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { + allowEmpty: true, + enabled: active && !hasReachedRecipientLimit, + limit: DIRECTORY_PAGE_SIZE, + }); + const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); + const isArchivedDiscovery = useIsArchivedPredicate(); + + const searchResults = React.useMemo(() => { + const candidatesByPubkey = new Map(); + const managedAgentsByPubkey = new Map( + (managedAgentsQuery.data ?? []).map((agent) => [ + normalizePubkey(agent.pubkey), + agent, + ]), + ); + const currentPubkeyNormalized = currentPubkey + ? normalizePubkey(currentPubkey) + : null; + const eligibleAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( + (agent) => agent.pubkey, + ), + relayAgents: relayAgentsQuery.data, + sharedChannelIds: getSharedChannelIds(channelsQuery.data), + }); + + const addCandidate = (candidate: NewMessageRecipientCandidate) => { + const pubkey = normalizePubkey(candidate.pubkey); + + if ( + pubkey === currentPubkeyNormalized || + selectedPubkeys.has(pubkey) || + isArchivedDiscovery(pubkey) || + (candidate.isAgent && !eligibleAgentPubkeys.has(pubkey)) + ) { + return; + } + + const current = candidatesByPubkey.get(pubkey); + if (!current) { + candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); + return; + } + + const candidateName = candidate.displayName?.trim() || null; + const currentName = current.displayName?.trim() || null; + + candidatesByPubkey.set(pubkey, { + pubkey, + avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + candidate.isAgent && candidateName + ? candidateName + : current.isAgent + ? currentName + : (currentName ?? candidateName), + nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, + isAgent: current.isAgent || candidate.isAgent, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + isMember: current.isMember || candidate.isMember, + personaId: current.personaId ?? candidate.personaId, + }); + }; + + for (const user of userSearchResults) { + addCandidate(candidateWithAgentMetadata(user, managedAgentsByPubkey)); + } + + for (const agent of relayAgentsQuery.data ?? []) { + if (!eligibleAgentPubkeys.has(normalizePubkey(agent.pubkey))) { + continue; + } + + addCandidate({ + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }); + } + + for (const agent of managedAgentsQuery.data ?? []) { + addCandidate({ + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: currentPubkey ?? null, + isAgent: true, + isManagedAgent: true, + personaId: agent.personaId, + }); + } + + const coalescedCandidates = coalesceAgentAutocompleteCandidates( + [...candidatesByPubkey.values()], + { + currentPubkey, + getLabel: formatRecipientName, + }, + ); + + return rankUserCandidatesBySearch({ + allowEmptyQuery: true, + candidates: coalescedCandidates, + getLabel: formatRecipientName, + limit: Math.max(DIRECTORY_PAGE_SIZE, coalescedCandidates.length), + query: deferredSearchQuery, + }); + }, [ + channelsQuery.data, + currentPubkey, + deferredSearchQuery, + isArchivedDiscovery, + managedAgentsQuery.data, + relayAgentsQuery.data, + selectedPubkeys, + userSearchResults, + ]); + + const isDirectoryLoading = + userSearchQuery.isLoading || + managedAgentsQuery.isLoading || + relayAgentsQuery.isLoading || + channelsQuery.isLoading; + const handleDirectoryScroll = useUserSearchFetchMoreOnScroll( + userSearchQuery, + !hasReachedRecipientLimit, + ); + + const searchOwnerPubkeys = React.useMemo( + () => [ + ...new Set( + searchResults + .map((user) => user.ownerPubkey) + .filter((pubkey): pubkey is string => + Boolean( + pubkey && + pubkey.toLowerCase() !== + identityQuery.data?.pubkey?.toLowerCase(), + ), + ), + ), + ], + [identityQuery.data?.pubkey, searchResults], + ); + const ownerProfilesQuery = useUsersBatchQuery(searchOwnerPubkeys, { + enabled: active && searchOwnerPubkeys.length > 0, + }); + + // Clearing the query on each new chip mirrors the modal's behavior: the + // search box empties so the next recipient can be typed immediately. + React.useEffect(() => { + if (selectedUsers.length > selectedUsersCountRef.current) { + setSearchQuery(""); + } + selectedUsersCountRef.current = selectedUsers.length; + }, [selectedUsers.length]); + + const selectUser = React.useCallback( + (user: UserSearchResult) => { + if (selectedUsers.length >= NEW_MESSAGE_RECIPIENT_LIMIT) { + return; + } + + setSelectedUsers((current) => { + const pubkey = normalizePubkey(user.pubkey); + if ( + current.some( + (candidate) => normalizePubkey(candidate.pubkey) === pubkey, + ) + ) { + return current; + } + + return [...current, user]; + }); + setSearchQuery(""); + }, + [selectedUsers.length], + ); + + const removeUser = React.useCallback((pubkey: string) => { + setSelectedUsers((current) => + current.filter((candidate) => candidate.pubkey !== pubkey), + ); + }, []); + + const reset = React.useCallback(() => { + setSearchQuery(""); + setSelectedUsers([]); + selectedUsersCountRef.current = 0; + }, []); + + return { + currentPubkey: currentPubkey ?? identityQuery.data?.pubkey, + deferredSearchQuery, + handleDirectoryScroll, + hasReachedRecipientLimit, + isDirectoryLoading, + ownerProfiles: ownerProfilesQuery.data?.profiles, + removeUser, + reset, + searchError: + userSearchQuery.error instanceof Error ? userSearchQuery.error : null, + searchQuery, + searchResults, + selectUser, + selectedUsers, + setSearchQuery, + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 47b8389e94..02081b639c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -38,7 +38,6 @@ import { SectionQuickAction, } from "@/features/sidebar/ui/CustomChannelSection"; import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; -import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard"; import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -86,7 +85,6 @@ type AppSidebarProps = { isLoading: boolean; isCreatingChannel: boolean; isCreatingForum: boolean; - isOpeningDm: boolean; profile?: Profile; relayConnectionCard: ReturnType; selfPresenceStatus: PresenceStatus; @@ -95,6 +93,7 @@ type AppSidebarProps = { selectedView: | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" @@ -155,8 +154,7 @@ type AppSidebarProps = { onSwitchWorkspace: (id: string) => void; selfUserStatus?: UserStatus; isPresencePending?: boolean; - isNewDmOpen?: boolean; - onNewDmOpenChange?: (open: boolean) => void; + onNewMessage: () => void; isCreateChannelOpen?: boolean; onCreateChannelOpenChange?: (open: boolean) => void; mutedChannelIds?: ReadonlySet; @@ -177,7 +175,6 @@ export function AppSidebar({ isLoading, isCreatingChannel, isCreatingForum, - isOpeningDm, profile, relayConnectionCard, selfPresenceStatus, @@ -217,8 +214,7 @@ export function AppSidebar({ onSwitchWorkspace, selfUserStatus, isPresencePending, - isNewDmOpen: isNewDmOpenProp, - onNewDmOpenChange, + onNewMessage, isCreateChannelOpen: isCreateChannelOpenProp, onCreateChannelOpenChange, mutedChannelIds, @@ -237,9 +233,6 @@ export function AppSidebar({ React.useState(false); const showSidebarUpdateCard = canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed; - const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); - const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; - const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); @@ -755,14 +748,14 @@ export function AppSidebar({
setIsNewDmOpen(true)} + onClick={onNewMessage} testId="section-actions-dms-quick-create" /> setIsNewDmOpen(true)} + onNewMessage={onNewMessage} sortMode={sortModeFor("dms")} onSortModeChange={(mode) => setSortModeFor("dms", mode)} /> @@ -876,14 +869,6 @@ export function AppSidebar({ onCreate={handleCreateFromDialog} /> - - {})} onSubmit={onAddWorkspace} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index c36b18aaf7..79249dfa90 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -14,6 +14,7 @@ import { type SidebarSelectedView = | "home" | "channel" + | "messages" | "agents" | "workflows" | "pulse" diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx deleted file mode 100644 index 9ab7a99421..0000000000 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ /dev/null @@ -1,877 +0,0 @@ -import { Bot, Search, X } from "lucide-react"; -import * as React from "react"; - -import { - useManagedAgentsQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; -import { - coalesceAgentAutocompleteCandidates, - getMentionableAgentPubkeys, - getSharedChannelIds, -} from "@/features/agents/lib/agentAutocompleteEligibility"; -import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; -import { - useFlattenedUserSearchResults, - useInfiniteUserSearchQuery, - useUserSearchFetchMoreOnScroll, - useUsersBatchQuery, -} from "@/features/profile/hooks"; -import { formatOwnerLabel } from "@/features/profile/lib/identity"; -import { - getKeyboardSearchSelection, - rankUserCandidatesBySearch, -} from "@/features/profile/lib/userCandidateSearch"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import { useChannelsQuery } from "@/features/channels/hooks"; -import { useIdentityQuery } from "@/shared/api/hooks"; -import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { safeNpub } from "@/shared/lib/nostrUtils"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { PubKey } from "@/shared/ui/PubKey"; -import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; -import { - MODAL_SEARCH_INPUT_CLASS, - MODAL_SEARCH_SHELL_CLASS, -} from "@/shared/ui/modalSearchStyles"; - -const DIRECT_MESSAGE_RECIPIENT_LIMIT = 50; -const DM_RESULT_ROW_INSET_DIVIDER_CLASS = - "after:pointer-events-none after:absolute after:bottom-0 after:left-[3.75rem] after:right-0 after:h-px after:bg-border/60 after:content-[''] last:after:hidden"; -const BUTTON_LABEL_MORPH_DURATION_MS = 220; -const BUTTON_LABEL_MORPH_EASE = "cubic-bezier(0.23, 1, 0.32, 1)"; -const BUTTON_LABEL_FADE_MS = Math.min( - BUTTON_LABEL_MORPH_DURATION_MS * 0.5, - 150, -); -const BUTTON_LABEL_EXIT_ATTR = "data-button-label-exiting"; -const BUTTON_LABEL_CURRENT_ATTR = "data-button-label-current"; - -function formatUserName(user: UserSearchResult) { - return ( - user.displayName?.trim() || - user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) - ); -} - -type DirectMessageSearchCandidate = UserSearchResult & { - isManagedAgent?: boolean; - isMember?: boolean; - personaId?: string | null; -}; - -function directMessageCandidateWithAgentMetadata( - candidate: UserSearchResult, - managedAgentsByPubkey: ReadonlyMap, -): DirectMessageSearchCandidate { - const agent = managedAgentsByPubkey.get(normalizePubkey(candidate.pubkey)); - return { - ...candidate, - isManagedAgent: Boolean(agent), - personaId: agent?.personaId, - }; -} - -function formatDirectMessageSearchName(user: DirectMessageSearchCandidate) { - return formatUserName(user); -} - -function prefersReducedMotion() { - return ( - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ); -} - -function createButtonLabelSpan(text: string) { - const span = document.createElement("span"); - span.setAttribute(BUTTON_LABEL_CURRENT_ATTR, ""); - span.textContent = text; - span.style.display = "inline-block"; - span.style.willChange = "opacity, transform"; - return span; -} - -function clearButtonLabelRoot(root: HTMLElement, text: string) { - root.replaceChildren(createButtonLabelSpan(text)); - root.style.width = "auto"; - root.style.height = "auto"; -} - -function MorphingButtonLabel({ text }: { text: string }) { - const rootRef = React.useRef(null); - const currentTextRef = React.useRef(""); - const cleanupSizeTransitionRef = React.useRef<(() => void) | null>(null); - - React.useLayoutEffect(() => { - const root = rootRef.current; - if (!root || text === currentTextRef.current) { - return; - } - const morphRoot = root; - - cleanupSizeTransitionRef.current?.(); - cleanupSizeTransitionRef.current = null; - - if (!currentTextRef.current || prefersReducedMotion()) { - clearButtonLabelRoot(root, text); - currentTextRef.current = text; - return; - } - - root.querySelectorAll(`[${BUTTON_LABEL_EXIT_ATTR}]`).forEach((element) => { - element.remove(); - }); - - const oldRect = root.getBoundingClientRect(); - const oldWidth = oldRect.width; - const oldHeight = oldRect.height; - const rootRect = root.getBoundingClientRect(); - const currentChild = root.querySelector( - `[${BUTTON_LABEL_CURRENT_ATTR}]`, - ); - - if (!currentChild || oldWidth === 0 || oldHeight === 0) { - clearButtonLabelRoot(root, text); - currentTextRef.current = text; - return; - } - - const currentChildRect = currentChild.getBoundingClientRect(); - const currentChildOpacity = - Number(getComputedStyle(currentChild).opacity) || 1; - currentChild.getAnimations().forEach((animation) => { - animation.cancel(); - }); - currentChild.removeAttribute(BUTTON_LABEL_CURRENT_ATTR); - currentChild.setAttribute(BUTTON_LABEL_EXIT_ATTR, ""); - currentChild.style.position = "absolute"; - currentChild.style.pointerEvents = "none"; - currentChild.style.left = `${currentChildRect.left - rootRect.left}px`; - currentChild.style.top = `${currentChildRect.top - rootRect.top}px`; - currentChild.style.width = `${currentChildRect.width}px`; - currentChild.style.height = `${currentChildRect.height}px`; - currentChild.style.opacity = String(currentChildOpacity); - - const nextChild = createButtonLabelSpan(text); - root.appendChild(nextChild); - - root.style.width = "auto"; - root.style.height = "auto"; - void root.offsetWidth; - - const nextRect = root.getBoundingClientRect(); - - root.style.width = `${oldWidth}px`; - root.style.height = `${oldHeight}px`; - void root.offsetWidth; - - root.style.width = `${nextRect.width}px`; - root.style.height = `${nextRect.height}px`; - - function cleanupSizeTransition() { - morphRoot.removeEventListener("transitionend", handleTransitionEnd); - window.clearTimeout(fallbackTimer); - cleanupSizeTransitionRef.current = null; - if (currentTextRef.current === text) { - morphRoot.style.width = "auto"; - morphRoot.style.height = "auto"; - } - } - - function handleTransitionEnd(event: TransitionEvent) { - if (event.target !== morphRoot) { - return; - } - if (event.propertyName !== "width" && event.propertyName !== "height") { - return; - } - cleanupSizeTransition(); - } - - root.addEventListener("transitionend", handleTransitionEnd); - const fallbackTimer = window.setTimeout( - cleanupSizeTransition, - BUTTON_LABEL_MORPH_DURATION_MS + 50, - ); - cleanupSizeTransitionRef.current = () => { - root.removeEventListener("transitionend", handleTransitionEnd); - window.clearTimeout(fallbackTimer); - }; - - currentChild.animate( - [{ transform: "none" }, { transform: "scale(0.95)" }], - { - duration: BUTTON_LABEL_MORPH_DURATION_MS, - easing: BUTTON_LABEL_MORPH_EASE, - fill: "both", - }, - ); - const exitFade = currentChild.animate( - [{ opacity: currentChildOpacity }, { opacity: 0 }], - { - duration: Math.min(BUTTON_LABEL_MORPH_DURATION_MS * 0.25, 150), - easing: "linear", - fill: "both", - }, - ); - exitFade.onfinish = () => currentChild.remove(); - - nextChild.animate([{ transform: "scale(0.95)" }, { transform: "none" }], { - duration: BUTTON_LABEL_MORPH_DURATION_MS, - easing: BUTTON_LABEL_MORPH_EASE, - fill: "both", - }); - nextChild.animate([{ opacity: 0 }, { opacity: 1 }], { - delay: Math.min(BUTTON_LABEL_MORPH_DURATION_MS * 0.25, 150), - duration: BUTTON_LABEL_FADE_MS, - easing: "linear", - fill: "both", - }); - - currentTextRef.current = text; - }, [text]); - - React.useEffect(() => { - return () => { - cleanupSizeTransitionRef.current?.(); - rootRef.current?.getAnimations({ subtree: true }).forEach((animation) => { - animation.cancel(); - }); - }; - }, []); - - return ( - <> -