Polish direct message and members modals (#1054)

This commit is contained in:
klopez4212
2026-06-15 22:24:07 +01:00
committed by GitHub
parent 5234fc8167
commit 89ae31d203
29 changed files with 1938 additions and 572 deletions
@@ -0,0 +1,106 @@
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export const DM_PARTICIPANT_PREVIEW_LIMIT = 3;
export type DmParticipantDisplay = {
displayName: string;
};
export type DirectMessageIntroParticipant = {
avatarUrl: string | null;
displayName: string;
pubkey: string;
};
export type DirectMessageIntro = {
displayName: string;
participants: DirectMessageIntroParticipant[];
};
export function getDmParticipantPreview<T>(participants: readonly T[]) {
const visibleParticipants = participants.slice(
0,
DM_PARTICIPANT_PREVIEW_LIMIT,
);
return {
hiddenCount: Math.max(
0,
participants.length - DM_PARTICIPANT_PREVIEW_LIMIT,
),
visibleParticipants,
};
}
export function formatDmParticipantDisplayName(
participants: readonly DmParticipantDisplay[],
) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const names = visibleParticipants.map(
(participant) => participant.displayName,
);
return hiddenCount > 0
? [...names, `+${hiddenCount} more`].join(", ")
: names.join(", ");
}
export function buildDirectMessageIntro({
channel,
currentPubkey,
profiles,
}: {
channel: Channel | null;
currentPubkey?: string;
profiles?: UserProfileLookup;
}): DirectMessageIntro | null {
if (channel?.channelType !== "dm") {
return null;
}
const participants = channel.participantPubkeys.map((pubkey, index) => ({
fallbackName: channel.participants[index] ?? null,
pubkey,
}));
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
const otherParticipants = normalizedCurrentPubkey
? participants.filter(
(participant) =>
normalizePubkey(participant.pubkey) !== normalizedCurrentPubkey,
)
: participants;
const displayParticipants =
otherParticipants.length > 0 ? otherParticipants : participants;
if (displayParticipants.length === 0) {
return null;
}
const introParticipants = displayParticipants.map((participant) => {
const profile = profiles?.[normalizePubkey(participant.pubkey)] ?? null;
return {
avatarUrl: profile?.avatarUrl ?? null,
displayName: resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles,
pubkey: participant.pubkey,
}),
pubkey: participant.pubkey,
};
});
return {
displayName: formatDmParticipantDisplayName(introParticipants),
participants: introParticipants,
};
}
@@ -1,4 +1,4 @@
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
import { Search, UserPlus, X } from "lucide-react";
import * as React from "react";
import { formatPubkey } from "@/features/channels/lib/memberUtils";
@@ -9,10 +9,8 @@ import type {
ChannelMember,
UserSearchResult,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
function formatSearchUserName(user: UserSearchResult) {
@@ -23,17 +21,6 @@ function formatSearchUserName(user: UserSearchResult) {
);
}
function formatSearchUserSecondary(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return formatPubkey(user.pubkey);
}
export function ChannelMemberInviteCard({
canAssignElevatedRoles,
existingMembers,
@@ -52,10 +39,7 @@ export function ChannelMemberInviteCard({
open: boolean;
requestErrorMessage?: string | null;
}) {
const [invitePubkeys, setInvitePubkeys] = React.useState("");
const [inviteQuery, setInviteQuery] = React.useState("");
const [isDirectPubkeyEntryOpen, setIsDirectPubkeyEntryOpen] =
React.useState(false);
const [selectedInvitees, setSelectedInvitees] = React.useState<
UserSearchResult[]
>([]);
@@ -122,36 +106,17 @@ export function ChannelMemberInviteCard({
React.useEffect(() => {
if (!open) {
setInvitePubkeys("");
setInviteQuery("");
setIsDirectPubkeyEntryOpen(false);
setSelectedInvitees([]);
setSubmissionErrors([]);
}
}, [open]);
const parsedInvitePubkeys = React.useMemo(
() =>
invitePubkeys
.split(/[\s,]+/)
.map((value) => value.trim())
.filter((value) => value.length > 0),
[invitePubkeys],
);
const inviteTargets = [
...new Set([
...selectedInvitees.map((invitee) => invitee.pubkey),
...parsedInvitePubkeys,
]),
];
const directEntryLabel =
parsedInvitePubkeys.length > 0 && !isDirectPubkeyEntryOpen
? `Direct pubkey entry (${parsedInvitePubkeys.length} ready)`
: "Direct pubkey entry";
const inviteTargets = selectedInvitees.map((invitee) => invitee.pubkey);
return (
<form
className="space-y-2.5 rounded-xl border border-border/80 bg-muted/15 p-3"
className="space-y-2.5 rounded-xl border border-border/70 bg-background/70 p-3"
onSubmit={(event) => {
event.preventDefault();
void onSubmit({
@@ -166,13 +131,6 @@ export function ChannelMemberInviteCard({
(invitee) => !addedPubkeys.has(invitee.pubkey.toLowerCase()),
),
);
const remainingPubkeys = parsedInvitePubkeys
.filter((pubkey) => !addedPubkeys.has(pubkey.toLowerCase()))
.join("\n");
setInvitePubkeys(remainingPubkeys);
if (remainingPubkeys.length > 0) {
setIsDirectPubkeyEntryOpen(true);
}
setInviteQuery("");
setSubmissionErrors(result.errors);
});
@@ -202,7 +160,7 @@ export function ChannelMemberInviteCard({
disabled={isPending}
id="channel-management-search-users"
onChange={(event) => setInviteQuery(event.target.value)}
placeholder="Search by name or NIP-05."
placeholder="Search people and agents"
value={inviteQuery}
/>
</div>
@@ -265,14 +223,14 @@ export function ChannelMemberInviteCard({
displayName={formatSearchUserName(result)}
size="xs"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium leading-5">
{formatSearchUserName(result)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatSearchUserSecondary(result)}
</p>
</div>
<p className="truncate text-sm font-medium leading-5">
{formatSearchUserName(result)}
</p>
{result.isAgent ? (
<span className="shrink-0 text-xs text-muted-foreground">
agent
</span>
) : null}
</div>
<span className="text-xs text-muted-foreground">Add</span>
</button>
@@ -292,49 +250,6 @@ export function ChannelMemberInviteCard({
</p>
) : null}
</div>
<div className="space-y-2">
<button
aria-controls="channel-management-direct-pubkeys-panel"
aria-expanded={isDirectPubkeyEntryOpen}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
data-testid="channel-management-toggle-direct-pubkeys"
onClick={() => {
setIsDirectPubkeyEntryOpen((current) => !current);
}}
type="button"
>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
isDirectPubkeyEntryOpen && "rotate-180",
)}
/>
<span>{directEntryLabel}</span>
</button>
{isDirectPubkeyEntryOpen ? (
<div
className="space-y-1.5 rounded-lg border border-dashed border-border/80 bg-background/70 p-2.5"
id="channel-management-direct-pubkeys-panel"
>
<label className="sr-only" htmlFor="channel-management-add-pubkeys">
Paste pubkeys
</label>
<p className="text-xs text-muted-foreground">
For exact pubkeys when search is not the right fit.
</p>
<Textarea
className="min-h-24"
data-testid="channel-management-add-pubkeys"
disabled={isPending}
id="channel-management-add-pubkeys"
onChange={(event) => setInvitePubkeys(event.target.value)}
placeholder="Paste one or more pubkeys, separated by spaces, commas, or new lines."
value={invitePubkeys}
/>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="sr-only" htmlFor="channel-member-role">
Role
@@ -10,6 +10,7 @@ import {
} from "@/features/messages/ui/MessageThreadPanel";
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewContextForMessage,
@@ -40,10 +41,7 @@ import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
import type { Channel } from "@/shared/api/types";
@@ -492,43 +490,15 @@ export const ChannelPane = React.memo(function ChannelPane({
}, [botTypingEntries, openThreadHeadId]);
const hasThreadComposerBotActivity =
threadComposerBotTypingPubkeys.length > 0;
const directMessageIntro = React.useMemo(() => {
if (activeChannel?.channelType !== "dm") {
return null;
}
const participants = activeChannel.participantPubkeys.map(
(pubkey, index) => ({
fallbackName: activeChannel.participants[index] ?? null,
pubkey,
const directMessageIntro = React.useMemo(
() =>
buildDirectMessageIntro({
channel: activeChannel,
currentPubkey,
profiles,
}),
);
const otherParticipants = currentPubkey
? participants.filter(
(participant) =>
participant.pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
)
: participants;
const [participant] =
otherParticipants.length > 0 ? otherParticipants : participants;
if (!participant) {
return null;
}
const profile = profiles?.[participant.pubkey.toLowerCase()] ?? null;
const displayName = resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles,
pubkey: participant.pubkey,
});
return {
avatarUrl: profile?.avatarUrl ?? null,
displayName,
};
}, [activeChannel, currentPubkey, profiles]);
[activeChannel, currentPubkey, profiles],
);
const channelIntro = React.useMemo(() => {
if (!activeChannel || activeChannel.channelType === "dm") {
@@ -785,7 +755,10 @@ export const ChannelPane = React.memo(function ChannelPane({
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
? activeChannel.channelType === "dm" &&
directMessageIntro
? `Message ${directMessageIntro.displayName}`
: `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
@@ -139,6 +139,7 @@ export function ChannelScreen({
const {
activeChannelTitle,
activeDmAvatarUrl,
activeDmHeaderParticipants,
activeDmPresenceStatus,
activeChannelEphemeralDisplay,
} = useActiveChannelHeader(activeChannel, currentPubkey);
@@ -537,6 +538,7 @@ export function ChannelScreen({
activeChannelTitle={activeChannelTitle}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmAvatarUrl={activeDmAvatarUrl}
activeDmHeaderParticipants={activeDmHeaderParticipants}
activeDmPresenceStatus={activeDmPresenceStatus}
chromeWrapperRef={channelHeaderChromeRef}
currentPubkey={currentPubkey}
@@ -3,12 +3,15 @@ import type * as React from "react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type ChannelScreenHeaderProps = {
activeChannel: Channel | null;
@@ -16,6 +19,7 @@ type ChannelScreenHeaderProps = {
activeChannelTitle: string;
actionsVariant?: "inline" | "compact";
activeDmAvatarUrl: string | null;
activeDmHeaderParticipants: ActiveDmHeaderParticipant[];
activeDmPresenceStatus: PresenceStatus | null;
chromeWrapperRef?: React.Ref<HTMLDivElement>;
currentPubkey?: string;
@@ -34,6 +38,7 @@ export function ChannelScreenHeader({
activeChannelTitle,
actionsVariant = "inline",
activeDmAvatarUrl,
activeDmHeaderParticipants,
activeDmPresenceStatus,
chromeWrapperRef,
currentPubkey,
@@ -45,6 +50,9 @@ export function ChannelScreenHeader({
onManageChannel,
onToggleMembers,
}: ChannelScreenHeaderProps) {
const isGroupDm =
activeChannel?.channelType === "dm" &&
activeDmHeaderParticipants.length > 1;
const showJoinButton =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -90,20 +98,26 @@ export function ChannelScreenHeader({
description={getChannelDescription(activeChannel)}
leadingContent={
activeChannel?.channelType === "dm" ? (
<ProfileAvatar
avatarUrl={activeDmAvatarUrl}
className="h-6 w-6 rounded-full text-[10px]"
iconClassName="h-3.5 w-3.5"
label={activeChannelTitle}
testId="chat-header-dm-avatar"
/>
isGroupDm ? (
<DmHeaderParticipantStack
participants={activeDmHeaderParticipants}
/>
) : (
<ProfileAvatar
avatarUrl={activeDmAvatarUrl}
className="h-6 w-6 rounded-full text-[10px]"
iconClassName="h-3.5 w-3.5"
label={activeChannelTitle}
testId="chat-header-dm-avatar"
/>
)
) : undefined
}
statusBadge={
<ChannelHeaderStatusBadge
channelType={activeChannel?.channelType}
ephemeralDisplay={activeChannelEphemeralDisplay}
presenceStatus={activeDmPresenceStatus}
presenceStatus={isGroupDm ? null : activeDmPresenceStatus}
/>
}
title={activeChannelTitle}
@@ -111,3 +125,55 @@ export function ChannelScreenHeader({
/>
);
}
function DmHeaderParticipantStack({
participants,
}: {
participants: ActiveDmHeaderParticipant[];
}) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
return (
<div
aria-hidden="true"
className="mr-1 flex shrink-0 items-center"
data-testid="chat-header-dm-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-2" : ""}
data-testid="chat-header-dm-avatar-stack-participant"
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
mask: "radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 16px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-7 w-7 text-[10px]"
displayName={participant.displayName}
size="sm"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-2" : ""}
data-testid="chat-header-dm-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-[11px] leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}
@@ -1,28 +1,37 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Bot, UserRoundPlus, X } from "lucide-react";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useUpdateManagedAgentMutation } from "@/features/agents/hooks";
import { CreateAgentRespondToField } from "@/features/agents/ui/RespondToField";
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers";
import {
formatMemberName,
formatPubkey,
} from "@/features/channels/lib/memberUtils";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
useUserSearchQuery,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
import { usePresenceQuery } from "@/features/presence/hooks";
import { changeChannelMemberRole } from "@/shared/api/tauri";
import type {
AddChannelMembersResult,
Channel,
ChannelMember,
ManagedAgent,
RespondToMode,
UserSearchResult,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
@@ -31,18 +40,47 @@ import {
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
MODAL_SEARCH_INPUT_CLASS,
MODAL_SEARCH_SHELL_CLASS,
} from "@/shared/ui/modalSearchStyles";
import { MembersSidebarAgentControls } from "./MembersSidebarAgentControls";
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
import { MembersSidebarMemberCard } from "./MembersSidebarMemberCard";
import { useMembersSidebarActions } from "./useMembersSidebarActions";
const MEMBER_ADD_RESULT_LIMIT = 50;
function formatAddCandidateName(user: UserSearchResult) {
return (
user.displayName?.trim() ||
user.nip05Handle?.trim() ||
formatPubkey(user.pubkey)
);
}
function memberModalRoleRank(member: ChannelMember) {
if (member.role === "owner") return 0;
if (member.role === "admin") return 1;
return 2;
}
function compareMembersForModal(
currentPubkey: string | undefined,
left: ChannelMember,
right: ChannelMember,
) {
const rankDelta = memberModalRoleRank(left) - memberModalRoleRank(right);
if (rankDelta !== 0) {
return rankDelta;
}
if (currentPubkey && left.pubkey === currentPubkey) return -1;
if (currentPubkey && right.pubkey === currentPubkey) return 1;
return formatMemberName(left).localeCompare(formatMemberName(right));
}
type MembersSidebarProps = {
channel: Channel | null;
currentPubkey?: string;
@@ -60,6 +98,11 @@ export function MembersSidebar({
}: MembersSidebarProps) {
const channelId = channel?.id ?? null;
const queryClient = useQueryClient();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [searchQuery, setSearchQuery] = React.useState("");
const [inviteSubmissionErrors, setInviteSubmissionErrors] = React.useState<
AddChannelMembersResult["errors"]
>([]);
const membersQuery = useChannelMembersQuery(channelId, open);
const addMembersMutation = useAddChannelMembersMutation(channelId);
const changeRoleMutation = useMutation({
@@ -79,8 +122,24 @@ export function MembersSidebar({
: null;
const rawMembers = membersQuery.data ?? [];
const { people, bots, archived, isBot, isMyBot, managedAgentsQuery } =
useClassifiedMembers(rawMembers, currentPubkey);
const selfMember =
rawMembers.find((member) => member.pubkey === currentPubkey) ?? null;
const {
people,
bots,
archived,
isBot,
isMyBot,
managedAgentsQuery,
relayAgentsQuery,
} = useClassifiedMembers(rawMembers, currentPubkey);
const activeMembers = React.useMemo(
() =>
[...people, ...bots].sort((left, right) =>
compareMembersForModal(currentPubkey, left, right),
),
[bots, currentPubkey, people],
);
const allMemberPubkeys = React.useMemo(
() => rawMembers.map((member) => member.pubkey),
@@ -92,9 +151,180 @@ export function MembersSidebar({
const memberProfilesQuery = useUsersBatchQuery(allMemberPubkeys, {
enabled: open && rawMembers.length > 0,
});
const normalizedSearchQuery = searchQuery.trim().toLowerCase();
const deferredSearchQuery = React.useDeferredValue(searchQuery.trim());
const normalizedDeferredSearchQuery = deferredSearchQuery.toLowerCase();
const filteredActiveMembers = React.useMemo(() => {
if (!normalizedSearchQuery) {
return activeMembers;
}
const profiles = memberProfilesQuery.data?.profiles ?? {};
return activeMembers.filter((member) => {
const normalizedPubkey = normalizePubkey(member.pubkey);
const profile = profiles[normalizedPubkey] ?? null;
const memberIsBot = isBot(member);
const labels = [
formatMemberName(member, currentPubkey),
member.displayName ?? "",
profile?.displayName ?? "",
memberIsBot ? "agent" : "",
member.role,
normalizedPubkey,
];
return labels.some((label) =>
label.toLowerCase().includes(normalizedSearchQuery),
);
});
}, [
activeMembers,
currentPubkey,
isBot,
memberProfilesQuery.data?.profiles,
normalizedSearchQuery,
]);
const memberPubkeys = React.useMemo(
() => new Set(rawMembers.map((member) => normalizePubkey(member.pubkey))),
[rawMembers],
);
const canAddMembers =
(selfMember !== null || channel?.visibility === "open") &&
channel?.channelType !== "dm";
const userSearchQuery = useUserSearchQuery(deferredSearchQuery, {
allowEmpty: false,
enabled: open && canAddMembers && deferredSearchQuery.length > 0,
limit: MEMBER_ADD_RESULT_LIMIT,
});
const isArchivedDiscovery = useIsArchivedPredicate();
const addSearchResults = React.useMemo(() => {
if (!canAddMembers || normalizedDeferredSearchQuery.length === 0) {
return [];
}
const candidatesByPubkey = new Map<string, UserSearchResult>();
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 (
memberPubkeys.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({
candidates: [...candidatesByPubkey.values()],
getLabel: formatAddCandidateName,
limit: MEMBER_ADD_RESULT_LIMIT,
query: normalizedDeferredSearchQuery,
});
}, [
canAddMembers,
isArchivedDiscovery,
managedAgentsQuery.data,
memberPubkeys,
normalizedDeferredSearchQuery,
relayAgentsQuery.data,
userSearchQuery.data,
]);
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
relayAgentsQuery.isLoading;
const filteredArchivedMembers = React.useMemo(() => {
if (!normalizedSearchQuery) {
return archived;
}
const profiles = memberProfilesQuery.data?.profiles ?? {};
return archived.filter((member) => {
const normalizedPubkey = normalizePubkey(member.pubkey);
const profile = profiles[normalizedPubkey] ?? null;
const memberIsBot = isBot(member);
const labels = [
formatMemberName(member, currentPubkey),
member.displayName ?? "",
profile?.displayName ?? "",
memberIsBot ? "agent" : "",
member.role,
normalizedPubkey,
];
return labels.some((label) =>
label.toLowerCase().includes(normalizedSearchQuery),
);
});
}, [
archived,
currentPubkey,
isBot,
memberProfilesQuery.data?.profiles,
normalizedSearchQuery,
]);
const selfMember =
rawMembers.find((member) => member.pubkey === currentPubkey) ?? null;
const canManageMembers =
selfMember?.role === "owner" || selfMember?.role === "admin";
const isArchived =
@@ -180,10 +410,30 @@ export function MembersSidebar({
const [editRespondToAgent, setEditRespondToAgent] =
React.useState<ManagedAgent | null>(null);
React.useEffect(() => {
if (!open) {
setSearchQuery("");
setInviteSubmissionErrors([]);
return;
}
searchInputRef.current?.focus({ preventScroll: true });
}, [open]);
if (!channel) {
return null;
}
async function handleAddSearchResult(user: UserSearchResult) {
setInviteSubmissionErrors([]);
const result = await addMembersMutation.mutateAsync({
pubkeys: [user.pubkey],
role: user.isAgent ? "bot" : "member",
});
setInviteSubmissionErrors(result.errors);
}
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
return (
<MembersSidebarMemberCard
@@ -231,95 +481,151 @@ export function MembersSidebar({
return (
<>
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
aria-describedby={undefined}
className="max-w-xl gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
data-testid="members-sidebar"
side="right"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchInputRef.current?.focus({ preventScroll: true });
}}
showCloseButton={false}
>
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 text-left backdrop-blur-xl supports-[backdrop-filter]:bg-background/20">
<SheetTitle>Members</SheetTitle>
<SheetDescription>
People and bots in {channel.name}.
</SheetDescription>
</SheetHeader>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>Channel members</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<label
className={MODAL_SEARCH_SHELL_CLASS}
htmlFor="channel-management-search-users"
>
<UserRoundPlus className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="channel-management-search-users"
disabled={addMembersMutation.isPending || isArchived}
id="channel-management-search-users"
onChange={(event) => {
setSearchQuery(event.target.value);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || addSearchResults.length === 0) {
return;
}
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{(selfMember !== null || channel.visibility === "open") &&
channel.channelType !== "dm" ? (
<ChannelMemberInviteCard
canAssignElevatedRoles={canManageMembers}
existingMembers={rawMembers}
isPending={addMembersMutation.isPending}
onSubmit={(input) => addMembersMutation.mutateAsync(input)}
open={open}
requestErrorMessage={
addMembersMutation.error instanceof Error
? addMembersMutation.error.message
: null
event.preventDefault();
void handleAddSearchResult(addSearchResults[0]);
}}
placeholder={
canAddMembers
? "Add people and agents"
: "Search people and agents"
}
ref={searchInputRef}
spellCheck={false}
type="text"
value={searchQuery}
/>
) : null}
</label>
</DialogHeader>
<section className="space-y-2.5">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold tracking-tight">People</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{people.length}
</span>
</div>
<div className="space-y-2" data-testid="members-sidebar-people">
{people.length > 0 ? (
people.map((member) => renderMemberCard(member, false))
<div className="max-h-[calc(100vh-12rem)] overflow-y-auto pb-6">
<section>
<div
className="h-[min(50vh,24rem)] overflow-y-auto rounded-xl border border-border/70 bg-background/70"
data-testid="members-sidebar-people"
>
<SearchResultSectionTitle
action={
hasControllableManagedBots ? (
<MembersSidebarAgentControls
canBulkRemove={hasRemovableManagedBots}
canBulkRespawn={hasControllableManagedBots}
canBulkStop={hasStoppableManagedBots}
disabled={isActionPending || isArchived}
onRemoveAll={() => {
void handleRemoveAll();
}}
onRespawnAll={() => {
void handleRespawnAll();
}}
onStopAll={() => {
void handleStopAll();
}}
/>
) : null
}
>
{normalizedSearchQuery
? "Members"
: `Members · ${activeMembers.length}`}
</SearchResultSectionTitle>
{normalizedSearchQuery ? (
<div>
{filteredActiveMembers.map((member) =>
renderMemberCard(member, isBot(member)),
)}
{canAddMembers ? (
<>
{addSearchResults.length > 0 || isAddSearchLoading ? (
<SearchResultSectionTitle>
Not in this channel
</SearchResultSectionTitle>
) : null}
{addSearchResults.map((user) => (
<AddMemberSearchResultRow
disabled={
addMembersMutation.isPending || isArchived
}
key={user.pubkey}
onSelect={(selectedUser) => {
void handleAddSearchResult(selectedUser);
}}
user={user}
/>
))}
{isAddSearchLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
Searching...
</p>
) : null}
</>
) : null}
{filteredActiveMembers.length === 0 &&
addSearchResults.length === 0 &&
!isAddSearchLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
No matching people or agents.
</p>
) : null}
</div>
) : filteredActiveMembers.length > 0 ? (
<div>
{filteredActiveMembers.map((member) =>
renderMemberCard(member, isBot(member)),
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
<p className="px-4 py-3 text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No people found."}
</p>
)}
</div>
</section>
<section className="space-y-2.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold tracking-tight">Bots</h2>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{bots.length}
</span>
{hasControllableManagedBots ? (
<MembersSidebarAgentControls
canBulkRemove={hasRemovableManagedBots}
canBulkRespawn={hasControllableManagedBots}
canBulkStop={hasStoppableManagedBots}
disabled={isActionPending || isArchived}
onRemoveAll={() => {
void handleRemoveAll();
}}
onRespawnAll={() => {
void handleRespawnAll();
}}
onStopAll={() => {
void handleStopAll();
}}
/>
) : null}
</div>
<div className="space-y-2" data-testid="members-sidebar-bots">
{bots.length > 0 ? (
bots.map((member) => renderMemberCard(member, true))
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No bots found."}
: normalizedSearchQuery
? "No members match your search."
: "No members found."}
</p>
)}
</div>
</section>
{archived.length > 0 ? (
<section className="space-y-2.5">
<section className="mt-4">
<details
className="group/archived"
data-testid="members-sidebar-archived"
@@ -329,10 +635,10 @@ export function MembersSidebar({
Archived
</h2>
<span
className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
className="text-muted-foreground"
data-testid="members-sidebar-archived-count"
>
{archived.length}
({archived.length})
</span>
<span className="ml-auto text-xs text-muted-foreground transition-transform group-open/archived:rotate-90">
▸
@@ -342,9 +648,14 @@ export function MembersSidebar({
className="mt-2 space-y-2"
data-testid="members-sidebar-archived-list"
>
{archived.map((member) =>
{filteredArchivedMembers.map((member) =>
renderMemberCard(member, isBot(member)),
)}
{filteredArchivedMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">
No archived members match your search.
</p>
) : null}
</div>
</details>
</section>
@@ -352,15 +663,31 @@ export function MembersSidebar({
{changeRoleError ? (
<p
className="text-sm text-destructive"
className="mt-4 text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{changeRoleError}
</p>
) : null}
{addMembersMutation.error instanceof Error ? (
<p className="mt-4 text-sm text-destructive">
{addMembersMutation.error.message}
</p>
) : null}
{inviteSubmissionErrors.length > 0 ? (
<div className="mt-4 space-y-1 text-sm text-destructive">
{inviteSubmissionErrors.map((error) => (
<p key={`${error.pubkey}-${error.error}`}>
{formatPubkey(error.pubkey)}: {error.error}
</p>
))}
</div>
) : null}
</div>
</SheetContent>
</Sheet>
</DialogContent>
</Dialog>
<EditRespondToDialog
agent={editRespondToAgent}
currentPubkey={currentPubkey}
@@ -373,6 +700,83 @@ export function MembersSidebar({
);
}
function SearchResultSectionTitle({
action,
children,
}: {
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="sticky top-0 z-10 mr-3 flex min-h-9 items-center gap-2 bg-background/95 px-4 pb-1.5 pt-3 text-xs font-medium uppercase tracking-wide text-muted-foreground/75 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<span>{children}</span>
{action ? (
<span className="normal-case tracking-normal">{action}</span>
) : null}
</div>
);
}
function AddMemberSearchResultRow({
disabled,
onSelect,
user,
}: {
disabled: boolean;
onSelect: (user: UserSearchResult) => void;
user: UserSearchResult;
}) {
return (
<div
className="group/add-result relative isolate flex min-h-14 w-full items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-within:bg-muted/40"
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
disabled={disabled}
onClick={() => onSelect(user)}
type="button"
/>
<UserAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
displayName={formatAddCandidateName(user)}
size="sm"
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{user.isAgent ? (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-3 w-3" />
agent
</span>
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
</span>
)}
</div>
<Button
className="relative z-20 shrink-0"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelect(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
);
}
function EditRespondToDialog({
agent,
currentPubkey,
@@ -1,5 +1,6 @@
import {
Activity,
Bot,
Ellipsis,
Pencil,
Play,
@@ -31,7 +32,6 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Badge } from "@/shared/ui/badge";
type MembersSidebarMemberCardProps = {
canChangeRole: boolean;
@@ -55,10 +55,14 @@ type MembersSidebarMemberCardProps = {
function formatRoleLabel(member: ChannelMember, memberIsBot: boolean) {
if (memberIsBot) {
return "Bot";
return "agent";
}
return `${member.role[0]?.toUpperCase() ?? ""}${member.role.slice(1)}`;
if (member.role === "owner" || member.role === "admin") {
return member.role;
}
return null;
}
function formatRespondToLabel(agent: ManagedAgent) {
@@ -115,11 +119,11 @@ export function MembersSidebarMemberCard({
: canRemoveMember || canChangeRole;
const memberIdentity = (
<>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 items-center gap-3">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profileAvatarUrl ?? null}
className="h-9 w-9 text-[11px] shadow-none"
className="h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={memberAvatarLabel}
/>
@@ -132,58 +136,73 @@ export function MembersSidebarMemberCard({
</span>
) : null}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium leading-5">
{memberLabel}
</p>
<Badge className="shrink-0" variant="secondary">
{roleLabel}
</Badge>
{managedAgent ? (
<>
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</Badge>
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-respond-to-${member.pubkey}`}
variant="outline"
>
{formatRespondToLabel(managedAgent)}
</Badge>
</>
) : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground/50">
{truncatePubkey(member.pubkey)}
</p>
<div className="min-w-0 flex-1">
{memberIsBot ? (
<div className="relative min-w-0">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/member:opacity-0 group-focus-within/member:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{memberLabel}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-3 w-3" />
{roleLabel}
</span>
</div>
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/member:opacity-100 group-focus-within/member:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(member.pubkey)}
</span>
</span>
</div>
) : (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium tracking-tight">
{memberLabel}
</span>
{roleLabel ? (
<span className="inline-flex shrink-0 items-center text-xs text-muted-foreground">
{roleLabel}
</span>
) : null}
</div>
)}
{managedAgent ? (
<span
className="sr-only"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
>
{formatManagedAgentStatus(managedAgent)}
</span>
) : null}
{managedAgent ? (
<span
className="sr-only"
data-testid={`sidebar-managed-agent-respond-to-${member.pubkey}`}
>
{formatRespondToLabel(managedAgent)}
</span>
) : null}
</div>
</>
</div>
);
return (
<div
className="group flex items-center justify-between gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-muted/40"
className="group/member relative isolate flex min-h-14 items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-within:bg-muted/40"
data-testid={`sidebar-member-${member.pubkey}`}
>
{onOpenProfile ? (
<button
aria-label={`Open profile for ${memberLabel}`}
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-md"
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`sidebar-member-open-profile-${member.pubkey}`}
onClick={() => onOpenProfile(member.pubkey)}
type="button"
>
{memberIdentity}
</button>
/>
) : (
<div className="flex min-w-0 items-center gap-3">{memberIdentity}</div>
<span aria-hidden="true" className="absolute inset-0 z-0" />
)}
{memberIdentity}
{hasActions ? (
<MemberActionsMenu
canChangeRole={canChangeRole}
@@ -240,7 +259,7 @@ function MemberActionsMenu({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="invisible flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground group-hover:visible hover:bg-muted hover:text-foreground data-[state=open]:visible"
className="invisible relative z-20 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground group-hover/member:visible hover:bg-muted hover:text-foreground data-[state=open]:visible"
data-testid={`sidebar-member-menu-${member.pubkey}`}
type="button"
>
@@ -3,22 +3,44 @@ import * as React from "react";
import { useEphemeralChannelDisplay } from "@/features/channels/useEphemeralChannelDisplay";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
export type ActiveDmHeaderParticipant = {
pubkey: string;
displayName: string;
avatarUrl: string | null;
};
export function useActiveChannelHeader(
activeChannel: Channel | null,
currentPubkey?: string,
) {
const activeDmParticipantPubkeys = React.useMemo(() => {
const activeDmParticipants = React.useMemo(() => {
if (activeChannel?.channelType !== "dm") {
return [];
}
return activeChannel.participantPubkeys.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
);
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: null;
return activeChannel.participantPubkeys
.map((pubkey, index) => ({
fallbackName: activeChannel.participants[index] ?? null,
pubkey,
}))
.filter(
(participant) =>
normalizePubkey(participant.pubkey) !== normalizedCurrentPubkey,
);
}, [activeChannel, currentPubkey]);
const activeDmParticipantPubkeys = React.useMemo(
() => activeDmParticipants.map((participant) => participant.pubkey),
[activeDmParticipants],
);
const activeDmPresenceQuery = usePresenceQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
@@ -36,9 +58,30 @@ export function useActiveChannelHeader(
const activeDmAvatarUrl =
activeDmParticipantPubkeys.length > 0
? (activeDmProfilesQuery.data?.profiles?.[
activeDmParticipantPubkeys[0]?.toLowerCase()
normalizePubkey(activeDmParticipantPubkeys[0] ?? "")
]?.avatarUrl ?? null)
: null;
const activeDmHeaderParticipants = React.useMemo(
() =>
activeDmParticipants.map((participant) => {
const profile =
activeDmProfilesQuery.data?.profiles?.[
normalizePubkey(participant.pubkey)
] ?? null;
return {
pubkey: participant.pubkey,
displayName: resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles: activeDmProfilesQuery.data?.profiles,
pubkey: participant.pubkey,
}),
avatarUrl: profile?.avatarUrl ?? null,
};
}),
[activeDmParticipants, activeDmProfilesQuery.data?.profiles, currentPubkey],
);
return {
activeChannelTitle: activeChannel
@@ -49,6 +92,7 @@ export function useActiveChannelHeader(
)
: "Channels",
activeDmAvatarUrl,
activeDmHeaderParticipants,
activeDmPresenceStatus,
activeChannelEphemeralDisplay,
};
@@ -324,7 +324,7 @@ export function useRichTextEditor({
autocapitalize: "none",
autocorrect: "off",
class:
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 text-foreground md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-hidden prose-sm max-w-none",
"data-testid": "message-input",
spellcheck: "false",
},
@@ -47,6 +47,7 @@ export const MessageRow = React.memo(
onUnfollowThread,
profiles,
searchQuery,
showDepthGuides = true,
agentPubkeys,
videoReviewContext,
}: {
@@ -71,6 +72,7 @@ export const MessageRow = React.memo(
onUnfollowThread?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
searchQuery?: string;
showDepthGuides?: boolean;
videoReviewContext?: VideoReviewContext;
}) {
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
@@ -354,7 +356,7 @@ export const MessageRow = React.memo(
className="relative"
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
>
{depthGuideOffsets.length > 0 ? (
{showDepthGuides && depthGuideOffsets.length > 0 ? (
<div
aria-hidden
className="pointer-events-none absolute left-0"
@@ -13,20 +13,22 @@ const NESTED_REPLY_OFFSET_PX = 28;
function ParticipantAvatar({
participant,
index,
participantCount,
}: {
participant: TimelineThreadSummaryParticipant;
index: number;
participantCount: number;
}) {
return (
<div
className={index > 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 (
<div className="relative pb-1 pt-0.5">
{depthGuideOffsets.length > 0 ? (
{showDepthGuides && depthGuideOffsets.length > 0 ? (
<div
aria-hidden
className="pointer-events-none absolute left-0"
@@ -108,6 +112,7 @@ export function MessageThreadSummaryRow({
index={index}
key={participant.id}
participant={participant}
participantCount={summary.participants.length}
/>
))}
</div>
@@ -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"
>
<ProfileAvatar
avatarUrl={directMessageIntro.avatarUrl}
className="h-[60px] w-[60px] text-base"
iconClassName="h-6 w-6"
label={directMessageIntro.displayName}
testId="message-dm-intro-avatar"
<DirectMessageIntroAvatarStack
participants={directMessageIntro.participants}
/>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
{directMessageIntro.displayName}
@@ -438,3 +441,55 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</TooltipProvider>
);
});
function DirectMessageIntroAvatarStack({
participants,
}: {
participants: DirectMessageIntroParticipant[];
}) {
const { hiddenCount, visibleParticipants } =
getDmParticipantPreview(participants);
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
return (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={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%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
))}
{hiddenCount > 0 ? (
<div
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-more"
style={{ zIndex: stackItemCount }}
>
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
<span className="text-lg leading-none">+{hiddenCount}</span>
</span>
</div>
) : null}
</div>
);
}
@@ -211,12 +211,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
: undefined
}
profiles={profiles}
showDepthGuides={false}
videoReviewContext={videoReviewContextById.get(message.id)}
/>
<MessageThreadSummaryRow
depth={message.depth}
message={message}
onOpenThread={onReply}
showDepthGuides={false}
summary={summary}
/>
{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}
@@ -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,
);
});
@@ -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;
}
@@ -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;
}
+19 -21
View File
@@ -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({
</SidebarMenuButton>
{shouldShowAgentCount ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
className="right-2 rounded-full bg-sidebar-accent/70 px-1.5 text-[11px] leading-none text-sidebar-foreground/75 peer-data-[active=true]/menu-button:bg-sidebar-active-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-active-foreground"
data-testid="sidebar-agents-count"
>
{totalAgentCount}
<span className="leading-none">{totalAgentCount}</span>
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
@@ -686,21 +684,21 @@ export function AppSidebar({
</FeatureGate>
<SidebarSection
action={
<SidebarGroupAction
aria-expanded={isNewDmOpen}
aria-label="Start a direct message"
className={cn(
"top-1/2 -translate-y-1/2 text-sidebar-foreground/50 hover:bg-sidebar-border/35 hover:text-sidebar-foreground",
SECTION_ACTION_VISIBILITY_CLASS,
)}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
type="button"
>
<PenSquare className="transition-transform" />
</SidebarGroupAction>
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
<button
aria-expanded={isNewDmOpen}
aria-label="Compose new message"
className={SECTION_ICON_BUTTON_CLASS}
data-testid="new-dm-trigger"
onClick={() => {
setIsNewDmOpen(true);
}}
title="Compose new message"
type="button"
>
<MessageCirclePlus className="h-4 w-4" />
</button>
</div>
}
dmParticipantsByChannelId={dmParticipantsByChannelId}
isCollapsed={collapsedGroups.directMessages}
@@ -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<HTMLSpanElement>(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<HTMLElement>(
`[${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 (
<>
<span
aria-hidden="true"
className="relative inline-block whitespace-nowrap text-left align-middle leading-none will-change-[width,height]"
ref={rootRef}
style={{
transitionDuration: `${BUTTON_LABEL_MORPH_DURATION_MS}ms`,
transitionProperty: "width, height",
transitionTimingFunction: BUTTON_LABEL_MORPH_EASE,
}}
/>
<span className="sr-only">{text}</span>
</>
);
}
export function NewDirectMessageDialog({
@@ -56,49 +249,168 @@ export function NewDirectMessageDialog({
string | null
>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const selectedRecipientsRef = React.useRef<HTMLDivElement>(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<string, UserSearchResult>();
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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-xl" data-testid="new-dm-dialog">
<DialogHeader>
<DialogTitle>New direct message</DialogTitle>
<DialogDescription>
Pick 1 to 8 people. If the conversation already exists, Buzz will
reopen it.
</DialogDescription>
<DialogContent
aria-describedby={undefined}
className="max-w-xl gap-0 overflow-hidden border-0 px-6 pb-0 pt-6"
data-testid="new-dm-dialog"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchInputRef.current?.focus({ preventScroll: true });
}}
showCloseButton={false}
>
<DialogHeader className="space-y-0 pb-5">
<div className="flex items-center justify-between gap-4">
<DialogTitle>New direct message</DialogTitle>
<DialogClose className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus:outline-hidden focus:ring-1 focus:ring-ring">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<label className={MODAL_SEARCH_SHELL_CLASS} htmlFor="new-dm-search">
<Search className="h-4 w-4 shrink-0 text-muted-foreground/55 transition-colors duration-150 ease-out group-hover/search:text-muted-foreground group-focus-within/search:text-foreground" />
<input
autoCapitalize="none"
autoCorrect="off"
className={MODAL_SEARCH_INPUT_CLASS}
data-testid="new-dm-search"
disabled={isPending}
id="new-dm-search"
onChange={(event) => {
setSearchQuery(event.target.value);
setSubmitErrorMessage(null);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") {
return;
}
if (searchQuery.trim().length === 0) {
if (selectedUsers.length > 0) {
event.preventDefault();
void submitDirectMessage();
}
return;
}
const keyboardSelection = getKeyboardSearchSelection({
currentQuery: searchQuery,
rankedQuery: deferredSearchQuery,
results: searchResults,
});
if (!keyboardSelection) {
return;
}
event.preventDefault();
handleSelectUser(keyboardSelection);
}}
placeholder="Search people and agents"
ref={searchInputRef}
spellCheck={false}
type="text"
value={searchQuery}
/>
</label>
</DialogHeader>
<form
className="space-y-4"
className="flex flex-col"
onSubmit={(event) => {
event.preventDefault();
if (selectedUsers.length === 0) {
return;
}
setSubmitErrorMessage(null);
void onSubmit({
pubkeys: selectedUsers.map((user) => user.pubkey),
})
.then(() => {
onOpenChange(false);
})
.catch((error) => {
setSubmitErrorMessage(
error instanceof Error
? error.message
: "Failed to open direct message.",
);
});
void submitDirectMessage();
}}
>
<div className="overflow-hidden rounded-2xl border border-border/80 bg-muted/20">
<div className="flex items-center gap-2 px-3 py-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
className="h-auto border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0"
data-testid="new-dm-search"
disabled={isPending}
onChange={(event) => {
setSearchQuery(event.target.value);
setSubmitErrorMessage(null);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || searchResults.length === 0) {
return;
}
event.preventDefault();
handleSelectUser(searchResults[0]);
}}
placeholder="Search by name, NIP-05, or pubkey."
ref={searchInputRef}
value={searchQuery}
/>
</div>
{selectedUsers.length > 0 ? (
<div className="flex flex-wrap gap-2 border-t border-border/70 px-3 py-2">
{selectedUsers.map((user) => (
<div
className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/80 px-2.5 py-1 text-xs"
data-testid={`new-dm-selected-${user.pubkey}`}
key={user.pubkey}
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-5 w-5 text-[10px] shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<span className="font-medium">{formatUserName(user)}</span>
<div
className="overflow-hidden transition-[height,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
style={{
height: selectedRecipientsHeight,
opacity: selectedUsers.length > 0 ? 1 : 0,
}}
>
<div className="pb-4" ref={selectedRecipientsRef}>
{selectedUsers.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedUsers.map((user) => (
<button
aria-label={`Remove ${formatUserName(user)}`}
className="text-muted-foreground transition-colors hover:text-foreground"
className="group/selected-recipient inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/80 py-1 pl-1 pr-3 text-xs transition-colors duration-150 ease-out hover:bg-muted/50 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
data-testid={`new-dm-selected-${user.pubkey}`}
disabled={isPending}
key={user.pubkey}
onClick={() => {
setSelectedUsers((current) =>
current.filter(
@@ -198,89 +541,151 @@ export function NewDirectMessageDialog({
}}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : null}
<div className="border-t border-border/70 px-2 py-2">
{hasReachedRecipientLimit ? (
<p
className="px-2 py-1 text-sm text-muted-foreground"
data-testid="new-dm-limit"
>
Direct messages support up to 9 people including you.
</p>
) : deferredSearchQuery.length === 0 ? (
<p
className="px-2 py-1 text-sm text-muted-foreground"
data-testid="new-dm-empty"
>
Search for someone to start a conversation.
</p>
) : userSearchQuery.isLoading ? (
<p className="px-2 py-1 text-sm text-muted-foreground">
Searching…
</p>
) : searchResults.length > 0 ? (
<div className="space-y-1">
{searchResults.map((user) => (
<button
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition-colors hover:bg-accent hover:text-accent-foreground"
data-testid={`new-dm-result-${user.pubkey}`}
key={user.pubkey}
onClick={() => {
handleSelectUser(user);
}}
type="button"
>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-9 w-9 text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{formatUserName(user)}
</p>
<p className="truncate text-xs text-muted-foreground">
{formatUserSecondary(user)}
</p>
</div>
<span className="text-xs text-muted-foreground">Add</span>
<span className="relative h-8 w-8 shrink-0">
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="h-8 w-8 text-xs shadow-none transition-opacity duration-150 ease-out group-hover/selected-recipient:opacity-0 group-focus-visible/selected-recipient:opacity-0"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-primary text-primary-foreground opacity-0 shadow transition-colors duration-150 ease-out group-hover/selected-recipient:bg-primary/90 group-hover/selected-recipient:opacity-100 group-focus-visible/selected-recipient:bg-primary/90 group-focus-visible/selected-recipient:opacity-100">
<X aria-hidden="true" className="h-4 w-4" />
</span>
</span>
<span className="font-medium">
{formatUserName(user)}
</span>
{user.isAgent ? (
<Bot
aria-label="agent"
className="h-3.5 w-3.5 text-muted-foreground"
/>
) : null}
</button>
))}
</div>
) : null}
</div>
</div>
<div
className="overflow-hidden transition-[max-height,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
style={{
maxHeight: hasReachedRecipientLimit ? 0 : "min(50vh, 24rem)",
opacity: hasReachedRecipientLimit ? 0 : 1,
}}
>
<div className="h-[min(50vh,24rem)] overflow-y-auto rounded-xl border border-border/70 bg-background/70">
{searchResults.length > 0 ? (
<div>
{searchResults.map((user) => (
<div
className="group/dm-result relative flex min-h-14 w-full items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-within:bg-muted/40"
key={user.pubkey}
>
<button
aria-label={`Add ${formatUserName(user)}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
data-testid={`new-dm-result-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={() => {
handleSelectUser(user);
}}
type="button"
/>
<ProfileAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
iconClassName="h-4 w-4"
label={formatUserName(user)}
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
{user.isAgent ? (
<div className="relative min-w-0">
<div className="flex min-w-0 items-center gap-2 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-0 group-focus-within/dm-result:opacity-0">
<span className="truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot
aria-hidden="true"
className="h-3 w-3"
data-testid="new-dm-agent-icon"
/>
agent
</span>
</div>
<span className="absolute inset-0 flex items-center opacity-0 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100">
<span className="truncate font-mono text-sm text-muted-foreground">
{truncatePubkey(user.pubkey)}
</span>
</span>
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatUserName(user)}
</span>
)}
</div>
<Button
aria-label={`Add ${formatUserName(user)}`}
className="relative z-20 shrink-0 opacity-0 transition-opacity duration-150 ease-out group-hover/dm-result:opacity-100 group-focus-within/dm-result:opacity-100"
data-testid={`new-dm-add-${user.pubkey}`}
disabled={isPending || hasReachedRecipientLimit}
onClick={(event) => {
event.stopPropagation();
handleSelectUser(user);
}}
size="sm"
type="button"
>
Add
</Button>
</div>
))}
</div>
) : isDirectoryLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
{deferredSearchQuery.length === 0
? "Loading people and agents…"
: "Searching…"}
</p>
) : (
<p className="px-2 py-1 text-sm text-muted-foreground">
No matching users.
<p
className="px-4 py-3 text-sm text-muted-foreground"
data-testid="new-dm-empty"
>
{deferredSearchQuery.length === 0
? "No people or agents available to message."
: "No matching users."}
</p>
)}
</div>
</div>
{hasReachedRecipientLimit ? (
<p
className="mt-4 text-sm text-destructive"
data-testid="new-dm-limit"
>
DMs support up to nine people, including you.
</p>
) : null}
{userSearchQuery.error instanceof Error ? (
<p className="text-sm text-destructive">
<p className="mt-4 text-sm text-destructive">
{userSearchQuery.error.message}
</p>
) : null}
{submitErrorMessage ? (
<p className="text-sm text-destructive">{submitErrorMessage}</p>
<p className="mt-4 text-sm text-destructive">
{submitErrorMessage}
</p>
) : null}
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{selectedUsers.length === 0
? "No recipients selected yet."
: `${selectedUsers.length} recipient${
selectedUsers.length === 1 ? "" : "s"
} selected.`}
</p>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 py-4">
<div className="ml-auto flex items-center gap-2">
<Button
disabled={isPending}
onClick={() => onOpenChange(false)}
@@ -294,11 +699,15 @@ export function NewDirectMessageDialog({
disabled={isPending || selectedUsers.length === 0}
type="submit"
>
{isPending
? "Opening..."
: selectedUsers.length > 1
? "Start group DM"
: "Message"}
<MorphingButtonLabel
text={
isPending
? "Opening..."
: selectedUsers.length > 1
? "Start group DM"
: "Message"
}
/>
</Button>
</div>
</div>
@@ -59,13 +59,26 @@ function DmChannelIcon({
presenceStatus?: PresenceStatus;
}) {
const primaryParticipant = participants?.[0];
const secondaryParticipant = participants?.[1];
if (!primaryParticipant) {
return <CircleDot className="h-4 w-4" />;
}
if (isPair || !secondaryParticipant) {
if (!isPair && participants && participants.length > 1) {
return (
<span
aria-hidden="true"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sidebar-border/80 bg-sidebar-accent/80 text-[10px] font-semibold leading-none text-sidebar-foreground shadow-none"
data-testid={`channel-dm-count-${channelName}`}
>
<span className="translate-x-px leading-none">
{participants.length}
</span>
</span>
);
}
if (isPair || !participants || participants.length <= 1) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
<ProfileAvatar
@@ -87,27 +100,7 @@ function DmChannelIcon({
);
}
return (
<span className="relative flex h-5 w-7 shrink-0 items-center">
<ProfileAvatar
avatarUrl={primaryParticipant.avatarUrl}
className="absolute left-0 top-0 h-[18px] w-[18px] rounded-full border-2 border-sidebar bg-sidebar-accent/80 text-[8px] text-sidebar-foreground shadow-none"
iconClassName="h-2.5 w-2.5"
label={primaryParticipant.label}
/>
<ProfileAvatar
avatarUrl={secondaryParticipant.avatarUrl}
className="absolute bottom-0 right-0 h-[18px] w-[18px] rounded-full border-2 border-sidebar bg-sidebar-accent/80 text-[8px] text-sidebar-foreground shadow-none"
iconClassName="h-2.5 w-2.5"
label={secondaryParticipant.label}
/>
{participants && participants.length > 2 ? (
<span className="absolute -bottom-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-sidebar-primary px-1 text-[8px] font-semibold text-sidebar-primary-foreground">
{participants.length}
</span>
) : null}
</span>
);
return <CircleDot className="h-4 w-4" />;
}
function SidebarChannelIcon({
+1 -1
View File
@@ -524,7 +524,7 @@
.rich-text-composer .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: hsl(var(--muted-foreground));
color: hsl(var(--muted-foreground) / 0.65);
pointer-events: none;
height: 0;
}
@@ -0,0 +1,5 @@
export const MODAL_SEARCH_SHELL_CLASS =
"group/search mt-4 flex cursor-text items-center gap-3 rounded-xl border border-input bg-background px-3 py-2.5 transition-colors duration-150 ease-out hover:border-muted-foreground/40 hover:bg-muted/70 focus-within:border-muted-foreground/50 focus-within:bg-muted/70";
export const MODAL_SEARCH_INPUT_CLASS =
"block h-6 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 text-muted-foreground/55 shadow-none caret-foreground outline-none transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 group-hover/search:text-muted-foreground group-hover/search:placeholder:text-muted-foreground group-focus-within/search:text-foreground group-focus-within/search:placeholder:text-foreground";
+2 -2
View File
@@ -380,7 +380,7 @@ const Sidebar = React.forwardRef<
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:border-r group-data-[variant=sidebar]:border-sidebar-border group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=sidebar]:pr-px group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
@@ -577,7 +577,7 @@ const SidebarRail = React.forwardRef<
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"cursor-col-resize",
"after:absolute after:inset-y-0 after:left-1/2 after:z-10 after:w-px after:-translate-x-1/2 after:bg-sidebar-border after:opacity-0 after:transition-opacity after:content-[''] hover:after:opacity-100 focus-visible:after:opacity-100 group-data-[resizing=true]:after:opacity-100",
"after:absolute after:inset-y-0 after:left-1/2 after:z-10 after:w-px after:-translate-x-1/2 after:bg-border/50 after:transition-colors after:content-[''] hover:after:bg-sidebar-border focus-visible:after:bg-sidebar-border group-data-[resizing=true]:after:bg-sidebar-border",
"[[data-state=collapsed]_&]:cursor-pointer",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
+4 -1
View File
@@ -30,7 +30,10 @@ async function gotoApp(page: import("@playwright/test").Page) {
}
async function openPersonaCatalog(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "New" }).click();
await page
.getByTestId("agents-library-personas")
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByText("Choose from Catalog...").click();
}
+135 -39
View File
@@ -355,19 +355,121 @@ test("start a new direct message from the sidebar", async ({ page }) => {
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
await page.getByTestId("new-dm-search").fill("charlie");
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await expect(page.getByTestId("new-dm-search")).toHaveValue("");
await page
.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`)
.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).not.toBeVisible();
await page.getByTestId("new-dm-search").fill("charlie");
await expect(
page.getByTestId(`new-dm-result-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.getByTestId("new-dm-search")).toHaveValue("");
await expect(
page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`),
).toBeVisible();
await page.getByTestId("new-dm-submit").click();
await page.keyboard.press("Enter");
await expect(page.getByTestId("dm-list")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toHaveText("charlie");
});
test("shows capped participant stack in group direct message header", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("new-dm-trigger").click();
await expect(page.getByTestId("new-dm-dialog")).toBeVisible();
for (const identity of [
TEST_IDENTITIES.alice,
TEST_IDENTITIES.bob,
TEST_IDENTITIES.charlie,
TEST_IDENTITIES.outsider,
]) {
await page.getByTestId("new-dm-search").fill(identity.username);
await page.getByTestId(`new-dm-result-${identity.pubkey}`).click();
}
await page.getByTestId("new-dm-submit").click();
await expect(page.getByTestId("channel-dm-count-Group DM (5)")).toHaveText(
"4",
);
await expect(page.getByTestId("chat-title")).toContainText("alice");
await expect(page.getByTestId("chat-title")).toContainText("bob");
await expect(page.getByTestId("chat-title")).toContainText("charlie");
await expect(page.getByTestId("chat-title")).toContainText("+1 more");
await expect(page.getByTestId("chat-title")).not.toContainText("outsider");
const chatTitle = await page.getByTestId("chat-title").innerText();
await expect(
page.getByTestId("message-input").locator("[data-placeholder]").first(),
).toHaveAttribute("data-placeholder", `Message ${chatTitle}`);
const composerColors = await page
.getByTestId("message-input")
.evaluate((element) => {
const placeholderElement =
element.querySelector<HTMLElement>("[data-placeholder]");
if (!placeholderElement) {
return null;
}
return {
placeholderColor: window.getComputedStyle(
placeholderElement,
"::before",
).color,
textColor: window.getComputedStyle(element).color,
};
});
expect(composerColors).not.toBeNull();
expect(composerColors?.placeholderColor).not.toBe(composerColors?.textColor);
await expect(page.getByTestId("chat-header-dm-avatar")).toHaveCount(0);
await expect(page.getByTestId("chat-header-dm-avatar-stack")).toBeVisible();
await expect(page.getByTestId("chat-presence-badge")).toHaveCount(0);
await expect(
page.getByTestId("chat-header-dm-avatar-stack-participant"),
).toHaveCount(3);
await expect(page.getByTestId("chat-header-dm-avatar-stack-more")).toHaveText(
"+1",
);
const headerStackBox = await page
.getByTestId("chat-header-dm-avatar-stack")
.boundingBox();
const headerTitleBox = await page.getByTestId("chat-title").boundingBox();
expect(headerStackBox).not.toBeNull();
expect(headerTitleBox).not.toBeNull();
expect((headerStackBox?.x ?? 0) + (headerStackBox?.width ?? 0)).toBeLessThan(
headerTitleBox?.x ?? 0,
);
await expect(page.getByTestId("message-dm-intro")).toContainText("alice");
await expect(page.getByTestId("message-dm-intro")).toContainText("bob");
await expect(page.getByTestId("message-dm-intro")).toContainText("charlie");
await expect(page.getByTestId("message-dm-intro")).toContainText("+1 more");
await expect(page.getByTestId("message-dm-intro")).not.toContainText(
"outsider",
);
await expect(page.getByTestId("message-dm-intro-avatar-stack")).toBeVisible();
await expect(
page.getByTestId("message-dm-intro-avatar-stack-participant"),
).toHaveCount(3);
await expect(
page.getByTestId("message-dm-intro-avatar-stack-more"),
).toHaveText("+1");
});
test("create stream with name and description", async ({ page }) => {
const channelName = `my-new-stream-${Date.now()}`;
@@ -1061,6 +1163,14 @@ test("members sidebar can invite and remove members", async ({ page }) => {
0,
);
await expect(page.getByText(/Members · \d+/)).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("a");
await expect(page.getByText("Members", { exact: true })).toBeVisible();
await expect(page.getByText(/Members · \d+/)).toHaveCount(0);
await expect(
page.getByText("Not in this channel", { exact: true }),
).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("char");
await expect(
page.getByTestId(
@@ -1071,21 +1181,17 @@ test("members sidebar can invite and remove members", async ({ page }) => {
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await page.getByTestId("channel-management-add-role").selectOption("admin");
await page.getByTestId("channel-management-add-members").click();
await expect(
page.getByTestId(`selected-invitee-${TEST_IDENTITIES.charlie.pubkey}`),
).toHaveCount(0);
await expect(page.getByTestId("channel-management-add-members")).toHaveCount(
0,
);
await expect(page.getByTestId("channel-management-search-users")).toHaveValue(
"",
"char",
);
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
await expectMembersTriggerCount(page, initialMemberCount + 1);
await openMemberMenu(page, TEST_IDENTITIES.charlie.pubkey);
@@ -1099,31 +1205,19 @@ test("members sidebar can invite and remove members", async ({ page }) => {
await expectMembersTriggerCount(page, initialMemberCount);
});
test("members sidebar keeps direct pubkey entry behind a toggle", async ({
page,
}) => {
test("members modal does not show direct pubkey entry", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");
const initialMemberCount = await readMembersTriggerCount(page);
await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveCount(
0,
);
await page.getByTestId("channel-management-toggle-direct-pubkeys").click();
await expect(
page.getByTestId("channel-management-add-pubkeys"),
).toBeVisible();
await page
.getByTestId("channel-management-add-pubkeys")
.fill(TEST_IDENTITIES.outsider.pubkey);
await page.getByTestId("channel-management-add-members").click();
page.getByTestId("channel-management-toggle-direct-pubkeys"),
).toHaveCount(0);
await expect(
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.outsider.pubkey}`),
).toContainText("outsider");
await expectMembersTriggerCount(page, initialMemberCount + 1);
page.getByTestId("channel-management-search-users"),
).toHaveAttribute("placeholder", "Add people and agents");
});
test("open-channel members can add agents from the header", async ({
@@ -1172,19 +1266,21 @@ test("private-channel members can add members and bots without admin", async ({
await expect(
page.getByTestId("channel-management-search-users"),
).toBeVisible();
await page.getByTestId("channel-management-search-users").fill("char");
await page
.getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`)
.click();
await expect(
page.getByTestId("channel-management-add-members"),
).toBeVisible();
page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`),
).toContainText("charlie");
// The role dropdown hides the elevated "admin" option for non-admins — the
// relay rejects it anyway — while keeping the non-elevated roles a member
// may grant (member, guest, bot).
const roleSelect = page.getByTestId("channel-management-add-role");
await expect(roleSelect.locator("option")).toHaveText([
"member",
"guest",
"bot",
]);
// The modal no longer exposes member/guest/bot role choices or a staged
// submit button; selected people are added immediately as members and
// selected agents are added as bots.
await expect(page.getByTestId("channel-management-add-role")).toHaveCount(0);
await expect(page.getByTestId("channel-management-add-members")).toHaveCount(
0,
);
});
test("removing a channel-scoped agent preserves the managed agent record", async ({
@@ -37,7 +37,7 @@ test.describe("NIP-IA hide archived from discovery", () => {
// Folded Archived section is visible with count = 1.
await expect(page.getByTestId("members-sidebar-archived")).toBeVisible();
await expect(page.getByTestId("members-sidebar-archived-count")).toHaveText(
"1",
"(1)",
);
// Expanding it reveals Alice.
@@ -133,14 +133,12 @@ test.describe("NIP-IA hide archived from discovery", () => {
await page.getByTestId("channel-members-trigger").click();
await expect(page.getByTestId("members-sidebar")).toBeVisible();
// Self appears in active People list — NOT folded into Archived. The
// members sidebar renders the current user as "You" + a pubkey-prefix.
await expect(page.getByTestId("members-sidebar-people")).toContainText(
"You",
);
await expect(page.getByTestId("members-sidebar-people")).toContainText(
"deadbeef",
);
// Self appears in active People list — NOT folded into Archived.
const peopleList = page.getByTestId("members-sidebar-people");
await expect(peopleList).toContainText("You");
await expect(
peopleList.getByTestId(`sidebar-member-${SELF_PUBKEY}`),
).toBeVisible();
// No Archived section at all when self is the only archived pubkey in
// the channel (self-exemption makes archived.length === 0).
await expect(page.getByTestId("members-sidebar-archived")).toHaveCount(0);
+12 -5
View File
@@ -75,6 +75,13 @@ async function openManagedAgentActions(
await expect(trigger).toHaveAttribute("data-state", "open");
}
async function openNewAgentMenu(page: import("@playwright/test").Page) {
await page
.getByTestId("agents-library-personas")
.getByRole("button", { name: "New", exact: true })
.click();
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -140,7 +147,7 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
// Member is admitted -> the relay-mesh toggle becomes enabled (availability
@@ -184,7 +191,7 @@ test("Run-on-relay-mesh skips connect signaling for own serve target", async ({
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Own Mesh Agent");
@@ -224,7 +231,7 @@ test("Run-on-relay-mesh canonicalizes the mesh connect #p target", async ({
});
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Mesh Agent");
@@ -262,7 +269,7 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
await setMesh(page, { admitted: false, denyReason: "not a relay member" });
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
// The relay-mesh toggle stays disabled — a non-member cannot even opt into
@@ -281,7 +288,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await openNewAgentMenu(page);
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill("Saved relay mesh agent");
+11
View File
@@ -626,6 +626,17 @@ test("opens a single-level thread panel with inline expansion", async ({
await expect(
rootSummaryRow.getByTestId("message-thread-summary-participant"),
).toHaveCount(2);
await expect
.poll(() =>
rootSummaryRow
.getByTestId("message-thread-summary-participant")
.evaluateAll((participants) =>
participants
.map((participant) => getComputedStyle(participant).zIndex)
.join(","),
),
)
.toBe("1,2");
await expect
.poll(async () => {
+4 -1
View File
@@ -226,7 +226,10 @@ test("env vars editor renders in PersonaDialog new-persona form", async ({
// Open the Agents view, click New > Persona to open the persona dialog.
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page
.getByTestId("agents-library-personas")
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByRole("menuitem", { name: /^Persona$/ }).click();
// The env vars editor should be present.
+4 -1
View File
@@ -116,7 +116,10 @@ test("create agent supports parallelism and system prompt overrides", async ({
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "New" }).click();
await page
.getByTestId("agents-library-personas")
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByText("Custom Agent").click();
await page.getByTestId("agent-name-input").fill(agentName);