mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish direct message and members modals (#1054)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user