0 ? "-ml-2" : ""}
data-testid="message-thread-summary-participant"
style={{
- zIndex: 10 - index,
- ...(index > 0 && {
- mask: "radial-gradient(circle 16px at -4px 50%, transparent 99%, #fff 100%)",
+ zIndex: index + 1,
+ ...(index < participantCount - 1 && {
+ mask: "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
- "radial-gradient(circle 16px at -4px 50%, transparent 99%, #fff 100%)",
+ "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
@@ -44,11 +46,13 @@ export function MessageThreadSummaryRow({
depth = 0,
message,
onOpenThread,
+ showDepthGuides = true,
summary,
}: {
depth?: number;
message: TimelineMessage;
onOpenThread: (message: TimelineMessage) => void;
+ showDepthGuides?: boolean;
summary: TimelineThreadSummary;
}) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
@@ -74,7 +78,7 @@ export function MessageThreadSummaryRow({
return (
- {depthGuideOffsets.length > 0 ? (
+ {showDepthGuides && depthGuideOffsets.length > 0 ? (
))}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index c4e5ea72dd2..04aaec1142e 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -1,16 +1,17 @@
import * as React from "react";
import { ArrowDown, Hash } from "lucide-react";
+import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { SkeletonReveal } from "@/shared/ui/skeleton";
import { TooltipProvider } from "@/shared/ui/tooltip";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
@@ -24,8 +25,8 @@ type MessageTimelineProps = {
channelType?: ChannelType | null;
messages: TimelineMessage[];
directMessageIntro?: {
- avatarUrl: string | null;
displayName: string;
+ participants: DirectMessageIntroParticipant[];
} | null;
isLoading?: boolean;
emptyTitle?: string;
@@ -89,6 +90,12 @@ type ChannelIntro = {
icon?: React.ReactNode;
};
+type DirectMessageIntroParticipant = {
+ avatarUrl: string | null;
+ displayName: string;
+ pubkey: string;
+};
+
export const MessageTimeline = React.memo(function MessageTimeline({
agentPubkeys,
channelId,
@@ -246,12 +253,8 @@ export const MessageTimeline = React.memo(function MessageTimeline({
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
data-testid="message-dm-intro"
>
-
{directMessageIntro.displayName}
@@ -438,3 +441,55 @@ export const MessageTimeline = React.memo(function MessageTimeline({
);
});
+
+function DirectMessageIntroAvatarStack({
+ participants,
+}: {
+ participants: DirectMessageIntroParticipant[];
+}) {
+ const { hiddenCount, visibleParticipants } =
+ getDmParticipantPreview(participants);
+ const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
+
+ return (
+
+ {visibleParticipants.map((participant, index) => (
+
0 ? "-ml-5" : ""}
+ data-testid="message-dm-intro-avatar-stack-participant"
+ key={participant.pubkey}
+ style={{
+ zIndex: index + 1,
+ ...(index < stackItemCount - 1 && {
+ mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
+ WebkitMask:
+ "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
+ }),
+ }}
+ >
+
+
+ ))}
+ {hiddenCount > 0 ? (
+
0 ? "-ml-5" : ""}
+ data-testid="message-dm-intro-avatar-stack-more"
+ style={{ zIndex: stackItemCount }}
+ >
+
+ +{hiddenCount}
+
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index d68978e3354..46b1f5eaf32 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -211,12 +211,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
: undefined
}
profiles={profiles}
+ showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
@@ -249,6 +251,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onReply={onReply}
profiles={profiles}
searchQuery={isSearchMatch ? searchQuery : undefined}
+ showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
{footer}
diff --git a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs
new file mode 100644
index 00000000000..d8b1a299043
--- /dev/null
+++ b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs
@@ -0,0 +1,129 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ getKeyboardSearchSelection,
+ rankUserCandidatesBySearch,
+ scoreUserCandidate,
+} from "./userCandidateSearch.ts";
+
+function makeUser(overrides = {}) {
+ return {
+ avatarUrl: null,
+ displayName: null,
+ isAgent: false,
+ nip05Handle: null,
+ pubkey: "abcdef1234567890",
+ ...overrides,
+ };
+}
+
+test("scoreUserCandidate ranks display labels before pubkeys", () => {
+ const user = makeUser({
+ displayName: "Alice Johnson",
+ nip05Handle: "alice@example.com",
+ });
+
+ assert.equal(
+ scoreUserCandidate({ label: "Alice Johnson", query: "ali", user }),
+ 0,
+ );
+ assert.equal(
+ scoreUserCandidate({ label: "Alice Johnson", query: "joh", user }),
+ 1,
+ );
+ assert.equal(
+ scoreUserCandidate({ label: "Alice Johnson", query: "ice", user }),
+ 2,
+ );
+ assert.equal(
+ scoreUserCandidate({ label: "Alice Johnson", query: "abcd", user }),
+ 3,
+ );
+ assert.equal(
+ scoreUserCandidate({ label: "Alice Johnson", query: "3456", user }),
+ 4,
+ );
+});
+
+test("scoreUserCandidate supports agent labels and empty-query defaults", () => {
+ const agent = makeUser({ isAgent: true });
+
+ assert.equal(
+ scoreUserCandidate({ label: "Build Buddy", query: "agent", user: agent }),
+ 0,
+ );
+ assert.equal(
+ scoreUserCandidate({ label: "Build Buddy", query: "", user: agent }),
+ null,
+ );
+ assert.equal(
+ scoreUserCandidate({
+ allowEmptyQuery: true,
+ label: "Build Buddy",
+ query: "",
+ user: agent,
+ }),
+ 0,
+ );
+});
+
+test("rankUserCandidatesBySearch applies score, label, and stable order sorting", () => {
+ const candidates = [
+ makeUser({ displayName: "Charlie", pubkey: "3000" }),
+ makeUser({ displayName: "Alice", pubkey: "1000" }),
+ makeUser({ displayName: "Beta Team", pubkey: "2000" }),
+ makeUser({ displayName: "Beta Build", pubkey: "2001" }),
+ ];
+
+ assert.deepEqual(
+ rankUserCandidatesBySearch({
+ candidates,
+ getLabel: (user) => user.displayName ?? user.pubkey,
+ limit: 3,
+ query: "be",
+ }).map((user) => user.displayName),
+ ["Beta Build", "Beta Team"],
+ );
+
+ assert.deepEqual(
+ rankUserCandidatesBySearch({
+ allowEmptyQuery: true,
+ candidates,
+ getLabel: (user) => user.displayName ?? user.pubkey,
+ limit: 2,
+ query: "",
+ }).map((user) => user.displayName),
+ ["Alice", "Beta Build"],
+ );
+});
+
+test("getKeyboardSearchSelection ignores stale ranked results", () => {
+ const alice = makeUser({ displayName: "Alice", pubkey: "1000" });
+ const charlie = makeUser({ displayName: "Charlie", pubkey: "3000" });
+
+ assert.equal(
+ getKeyboardSearchSelection({
+ currentQuery: "charlie",
+ rankedQuery: "",
+ results: [alice],
+ }),
+ null,
+ );
+ assert.equal(
+ getKeyboardSearchSelection({
+ currentQuery: "charlie",
+ rankedQuery: "charlie",
+ results: [charlie],
+ }),
+ charlie,
+ );
+ assert.equal(
+ getKeyboardSearchSelection({
+ currentQuery: " ",
+ rankedQuery: "",
+ results: [alice],
+ }),
+ null,
+ );
+});
diff --git a/desktop/src/features/profile/lib/userCandidateSearch.ts b/desktop/src/features/profile/lib/userCandidateSearch.ts
new file mode 100644
index 00000000000..7447b01c4d4
--- /dev/null
+++ b/desktop/src/features/profile/lib/userCandidateSearch.ts
@@ -0,0 +1,112 @@
+import type { UserSearchResult } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+
+type ScoreUserCandidateInput = {
+ allowEmptyQuery?: boolean;
+ label: string;
+ query: string;
+ user: UserSearchResult;
+};
+
+type RankUserCandidatesInput = {
+ allowEmptyQuery?: boolean;
+ candidates: UserSearchResult[];
+ getLabel: (user: UserSearchResult) => string;
+ limit: number;
+ query: string;
+};
+
+type KeyboardSearchSelectionInput = {
+ currentQuery: string;
+ rankedQuery: string;
+ results: UserSearchResult[];
+};
+
+export function scoreUserCandidate({
+ allowEmptyQuery = false,
+ label,
+ query,
+ user,
+}: ScoreUserCandidateInput) {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ if (normalizedQuery.length === 0) {
+ return allowEmptyQuery ? 0 : null;
+ }
+
+ const labels = [
+ label,
+ user.nip05Handle?.trim() ?? "",
+ user.isAgent ? "agent" : "",
+ ];
+
+ for (const candidateLabel of labels) {
+ const lower = candidateLabel.toLowerCase();
+ if (lower.startsWith(normalizedQuery)) return 0;
+ if (
+ lower.split(/[\s\-_]+/).some((word) => word.startsWith(normalizedQuery))
+ ) {
+ return 1;
+ }
+ if (lower.includes(normalizedQuery)) return 2;
+ }
+
+ const pubkey = normalizePubkey(user.pubkey);
+ if (pubkey.startsWith(normalizedQuery)) return 3;
+ if (pubkey.includes(normalizedQuery)) return 4;
+
+ return null;
+}
+
+export function rankUserCandidatesBySearch({
+ allowEmptyQuery = false,
+ candidates,
+ getLabel,
+ limit,
+ query,
+}: RankUserCandidatesInput) {
+ return candidates
+ .map((candidate, order) => {
+ const label = getLabel(candidate);
+
+ return {
+ candidate,
+ label,
+ order,
+ score: scoreUserCandidate({
+ allowEmptyQuery,
+ label,
+ query,
+ user: candidate,
+ }),
+ };
+ })
+ .filter(
+ (item): item is typeof item & { score: number } => item.score !== null,
+ )
+ .sort(
+ (left, right) =>
+ left.score - right.score ||
+ left.label.localeCompare(right.label) ||
+ left.order - right.order,
+ )
+ .slice(0, limit)
+ .map(({ candidate }) => candidate);
+}
+
+export function getKeyboardSearchSelection({
+ currentQuery,
+ rankedQuery,
+ results,
+}: KeyboardSearchSelectionInput) {
+ const trimmedCurrentQuery = currentQuery.trim();
+ if (trimmedCurrentQuery.length === 0) {
+ return null;
+ }
+
+ if (rankedQuery.trim() !== trimmedCurrentQuery) {
+ return null;
+ }
+
+ return results[0] ?? null;
+}
diff --git a/desktop/src/features/sidebar/lib/channelLabels.ts b/desktop/src/features/sidebar/lib/channelLabels.ts
index 893e4f31892..8408c34d8c0 100644
--- a/desktop/src/features/sidebar/lib/channelLabels.ts
+++ b/desktop/src/features/sidebar/lib/channelLabels.ts
@@ -2,6 +2,7 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
+import { formatDmParticipantDisplayName } from "@/features/channels/lib/dmParticipantDisplay";
import type { Channel } from "@/shared/api/types";
function isGenericDmChannelName(name: string) {
@@ -46,5 +47,9 @@ export function resolveChannelDisplayLabel(
);
const uniqueLabels = [...new Set(resolvedLabels)];
- return uniqueLabels.length > 0 ? uniqueLabels.join(", ") : channel.name;
+ return uniqueLabels.length > 0
+ ? formatDmParticipantDisplayName(
+ uniqueLabels.map((displayName) => ({ displayName })),
+ )
+ : channel.name;
}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index d8c3a445b2a..6f4b92d2021 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -6,7 +6,7 @@ import {
Bot,
FolderGit2,
Home,
- PenSquare,
+ MessageCirclePlus,
Zap,
} from "lucide-react";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
@@ -47,7 +47,7 @@ import {
SidebarLoadingContent,
useSidebarLoadingShape,
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
-import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
+import { SECTION_ICON_BUTTON_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
import type {
Channel,
ChannelVisibility,
@@ -55,12 +55,10 @@ import type {
Profile,
UserStatus,
} from "@/shared/api/types";
-import { cn } from "@/shared/lib/cn";
import {
Sidebar,
SidebarContent,
SidebarFooter,
- SidebarGroupAction,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
@@ -499,10 +497,10 @@ export function AppSidebar({
{shouldShowAgentCount ? (
- {totalAgentCount}
+ {totalAgentCount}
) : null}
@@ -686,21 +684,21 @@ export function AppSidebar({
{
- setIsNewDmOpen(true);
- }}
- type="button"
- >
-
-
+
+
+
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx
index 7d472042394..2a63cd0c5d0 100644
--- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx
+++ b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx
@@ -1,20 +1,42 @@
-import { Search, X } from "lucide-react";
+import { Bot, Search, X } from "lucide-react";
import * as React from "react";
+import {
+ useManagedAgentsQuery,
+ useRelayAgentsQuery,
+} from "@/features/agents/hooks";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useUserSearchQuery } from "@/features/profile/hooks";
import { truncatePubkey } from "@/features/profile/lib/identity";
+import {
+ getKeyboardSearchSelection,
+ rankUserCandidatesBySearch,
+} from "@/features/profile/lib/userCandidateSearch";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { UserSearchResult } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
Dialog,
+ DialogClose,
DialogContent,
- DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
-import { Input } from "@/shared/ui/input";
+import {
+ MODAL_SEARCH_INPUT_CLASS,
+ MODAL_SEARCH_SHELL_CLASS,
+} from "@/shared/ui/modalSearchStyles";
+
+const DIRECT_MESSAGE_RECIPIENT_LIMIT = 50;
+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 (
@@ -24,15 +46,186 @@ function formatUserName(user: UserSearchResult) {
);
}
-function formatUserSecondary(user: UserSearchResult) {
- const displayName = user.displayName?.trim();
- const nip05Handle = user.nip05Handle?.trim();
+function prefersReducedMotion() {
+ return (
+ typeof window !== "undefined" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
- if (displayName && nip05Handle) {
- return nip05Handle;
- }
+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;
+}
- return truncatePubkey(user.pubkey);
+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 (
+ <>
+
+ {text}
+ >
+ );
}
export function NewDirectMessageDialog({
@@ -56,49 +249,168 @@ export function NewDirectMessageDialog({
string | null
>(null);
const searchInputRef = React.useRef(null);
+ const selectedRecipientsRef = React.useRef(null);
+ const [selectedRecipientsHeight, setSelectedRecipientsHeight] =
+ React.useState(0);
const deferredSearchQuery = React.useDeferredValue(searchQuery.trim());
const hasReachedRecipientLimit = selectedUsers.length >= 8;
const selectedPubkeys = React.useMemo(
- () => new Set(selectedUsers.map((user) => user.pubkey.toLowerCase())),
+ () => new Set(selectedUsers.map((user) => normalizePubkey(user.pubkey))),
[selectedUsers],
);
+ const managedAgentsQuery = useManagedAgentsQuery({ enabled: open });
+ const relayAgentsQuery = useRelayAgentsQuery({ enabled: open });
const userSearchQuery = useUserSearchQuery(deferredSearchQuery, {
- enabled:
- open && deferredSearchQuery.length > 0 && !hasReachedRecipientLimit,
- limit: 8,
+ allowEmpty: true,
+ enabled: open && !hasReachedRecipientLimit,
+ limit: DIRECT_MESSAGE_RECIPIENT_LIMIT,
});
const isArchivedDiscovery = useIsArchivedPredicate();
- const searchResults = React.useMemo(
- () =>
- (userSearchQuery.data ?? []).filter((user) => {
- const normalizedPubkey = user.pubkey.toLowerCase();
- return (
- normalizedPubkey !== currentPubkey?.toLowerCase() &&
- !selectedPubkeys.has(normalizedPubkey) &&
- !isArchivedDiscovery(user.pubkey)
- );
- }),
- [currentPubkey, isArchivedDiscovery, selectedPubkeys, userSearchQuery.data],
- );
+ const searchResults = React.useMemo(() => {
+ const candidatesByPubkey = new Map();
+ const currentPubkeyNormalized = currentPubkey
+ ? normalizePubkey(currentPubkey)
+ : null;
+ const eligibleAgentPubkeys = new Set([
+ ...(managedAgentsQuery.data ?? []).map((agent) =>
+ normalizePubkey(agent.pubkey),
+ ),
+ ...(relayAgentsQuery.data ?? [])
+ .filter((agent) => agent.respondTo === "anyone")
+ .map((agent) => normalizePubkey(agent.pubkey)),
+ ]);
+
+ const addCandidate = (candidate: UserSearchResult) => {
+ 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,
+ isAgent: current.isAgent || candidate.isAgent,
+ });
+ };
+
+ for (const user of userSearchQuery.data ?? []) {
+ addCandidate(user);
+ }
+
+ for (const agent of relayAgentsQuery.data ?? []) {
+ if (agent.respondTo !== "anyone") {
+ continue;
+ }
+
+ addCandidate({
+ pubkey: agent.pubkey,
+ displayName: agent.name,
+ avatarUrl: null,
+ nip05Handle: null,
+ isAgent: true,
+ });
+ }
+
+ for (const agent of managedAgentsQuery.data ?? []) {
+ addCandidate({
+ pubkey: agent.pubkey,
+ displayName: agent.name,
+ avatarUrl: null,
+ nip05Handle: null,
+ isAgent: true,
+ });
+ }
+
+ return rankUserCandidatesBySearch({
+ allowEmptyQuery: true,
+ candidates: [...candidatesByPubkey.values()],
+ getLabel: formatUserName,
+ limit: DIRECT_MESSAGE_RECIPIENT_LIMIT,
+ query: deferredSearchQuery,
+ });
+ }, [
+ currentPubkey,
+ deferredSearchQuery,
+ isArchivedDiscovery,
+ managedAgentsQuery.data,
+ relayAgentsQuery.data,
+ selectedPubkeys,
+ userSearchQuery.data,
+ ]);
+ const isDirectoryLoading =
+ userSearchQuery.isLoading ||
+ managedAgentsQuery.isLoading ||
+ relayAgentsQuery.isLoading;
React.useEffect(() => {
if (!open) {
setSearchQuery("");
setSelectedUsers([]);
setSubmitErrorMessage(null);
+ setSelectedRecipientsHeight(0);
return;
}
searchInputRef.current?.focus();
}, [open]);
+ React.useEffect(() => {
+ const node = selectedRecipientsRef.current;
+ if (!node) {
+ setSelectedRecipientsHeight(0);
+ return;
+ }
+
+ const updateHeight = () => {
+ setSelectedRecipientsHeight(
+ selectedUsers.length > 0 ? node.scrollHeight : 0,
+ );
+ };
+
+ const animationFrame = window.requestAnimationFrame(updateHeight);
+ const resizeObserver = new ResizeObserver(updateHeight);
+ resizeObserver.observe(node);
+
+ return () => {
+ window.cancelAnimationFrame(animationFrame);
+ resizeObserver.disconnect();
+ };
+ }, [selectedUsers.length]);
+
function handleSelectUser(user: UserSearchResult) {
if (hasReachedRecipientLimit) {
return;
}
setSelectedUsers((current) => {
- if (current.some((candidate) => candidate.pubkey === user.pubkey)) {
+ const pubkey = normalizePubkey(user.pubkey);
+ if (
+ current.some(
+ (candidate) => normalizePubkey(candidate.pubkey) === pubkey,
+ )
+ ) {
return current;
}
@@ -108,87 +420,118 @@ export function NewDirectMessageDialog({
setSubmitErrorMessage(null);
}
+ async function submitDirectMessage() {
+ if (isPending || selectedUsers.length === 0) {
+ return;
+ }
+
+ setSubmitErrorMessage(null);
+
+ try {
+ await onSubmit({
+ pubkeys: selectedUsers.map((user) => user.pubkey),
+ });
+ onOpenChange(false);
+ } catch (error) {
+ setSubmitErrorMessage(
+ error instanceof Error
+ ? error.message
+ : "Failed to open direct message.",
+ );
+ }
+ }
+
return (