Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions desktop/src/features/channels/lib/dmParticipantDisplay.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
109 changes: 12 additions & 97 deletions desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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[]
>([]);
Expand Down Expand Up @@ -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({
Expand All @@ -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);
});
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand All @@ -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
Expand Down
55 changes: 14 additions & 41 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export function ChannelScreen({
const {
activeChannelTitle,
activeDmAvatarUrl,
activeDmHeaderParticipants,
activeDmPresenceStatus,
activeChannelEphemeralDisplay,
} = useActiveChannelHeader(activeChannel, currentPubkey);
Expand Down Expand Up @@ -537,6 +538,7 @@ export function ChannelScreen({
activeChannelTitle={activeChannelTitle}
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
activeDmAvatarUrl={activeDmAvatarUrl}
activeDmHeaderParticipants={activeDmHeaderParticipants}
activeDmPresenceStatus={activeDmPresenceStatus}
chromeWrapperRef={channelHeaderChromeRef}
currentPubkey={currentPubkey}
Expand Down
Loading