Skip to content
Draft
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default defineConfig({
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/ambiguous-agent-owner-suffix.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
Expand Down
75 changes: 54 additions & 21 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import {
useUserSearchFetchMoreOnScroll,
useUsersBatchQuery,
} from "@/features/profile/hooks";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
formatOwnerLabel,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
Expand Down Expand Up @@ -374,6 +379,13 @@ export function MembersSidebar({
userSearchResults,
rawMembers,
]);
const addSearchNameCounts = React.useMemo(
() =>
countVisibleDisplayNames(
addSearchResults.map((user) => formatAddCandidateName(user)),
),
[addSearchResults],
);
const isAddSearchLoading =
userSearchQuery.isLoading ||
managedAgentsQuery.isLoading ||
Expand Down Expand Up @@ -708,23 +720,39 @@ export function MembersSidebar({
Not in this channel
</SearchResultSectionTitle>
) : null}
{addSearchResults.map((user) => (
<AddMemberSearchResultRow
disabled={
addingMemberPubkeys.has(user.pubkey) || isArchived
}
key={user.pubkey}
onSelect={(selectedUser) => {
void handleAddSearchResult(selectedUser);
}}
ownerLabel={formatOwnerLabel(
user.ownerPubkey,
identityQuery.data?.pubkey,
addSearchOwnerProfilesQuery.data?.profiles,
)}
user={user}
/>
))}
{addSearchResults.map((user) => {
const ownerLabel = formatOwnerLabel(
user.ownerPubkey,
identityQuery.data?.pubkey,
addSearchOwnerProfilesQuery.data?.profiles,
);
const displayName = formatAddCandidateName(user);

return (
<AddMemberSearchResultRow
disabled={
addingMemberPubkeys.has(user.pubkey) ||
isArchived
}
displayName={formatDisambiguatedAgentName({
displayName,
hasNameCollision:
hasVisibleDisplayNameCollision(
displayName,
addSearchNameCounts,
),
isAgent: user.isAgent,
ownerLabel,
})}
key={user.pubkey}
onSelect={(selectedUser) => {
void handleAddSearchResult(selectedUser);
}}
ownerLabel={ownerLabel}
user={user}
/>
);
})}
{isAddSearchLoading ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
Searching...
Expand Down Expand Up @@ -851,11 +879,13 @@ function SearchResultSectionTitle({

function AddMemberSearchResultRow({
disabled,
displayName,
onSelect,
ownerLabel,
user,
}: {
disabled: boolean;
displayName: string;
onSelect: (user: UserSearchResult) => void;
ownerLabel?: string | null;
user: UserSearchResult;
Expand All @@ -869,7 +899,7 @@ function AddMemberSearchResultRow({
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
aria-label={`Select ${displayName}`}
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)}
Expand All @@ -885,8 +915,11 @@ function AddMemberSearchResultRow({
{user.isAgent ? (
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
<span
className="truncate text-sm font-medium tracking-tight"
data-testid="channel-user-search-result-name"
>
{displayName}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-4 w-4" />
Expand Down
33 changes: 23 additions & 10 deletions desktop/src/features/messages/ui/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { truncatePubkey } from "@/shared/lib/pubkey";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";

export type MentionSuggestion = {
pubkey?: string;
Expand Down Expand Up @@ -66,12 +71,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({

// Name collisions are the impersonation vector: a vanity-ground key can
// wear any display name. When two suggestions share a name, surface each
// one's npub (truncated; full key in the hover tooltip) to tell them apart.
const nameCounts = new Map<string, number>();
for (const suggestion of suggestions) {
const name = suggestion.displayName.toLowerCase();
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
}
// one's owner in the title (when known) and npub (truncated; full key in the
// hover tooltip) to tell them apart.
const nameCounts = countVisibleDisplayNames(
suggestions.map((suggestion) => suggestion.displayName),
);

return (
<div
Expand Down Expand Up @@ -101,8 +105,16 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
(suggestion.teamId ? `team-${suggestion.teamId}` : null) ??
suggestion.displayName;
const agentLabel = "agent";
const hasNameCollision =
(nameCounts.get(suggestion.displayName.toLowerCase()) ?? 0) > 1;
const hasNameCollision = hasVisibleDisplayNameCollision(
suggestion.displayName,
nameCounts,
);
const displayName = formatDisambiguatedAgentName({
displayName: suggestion.displayName,
hasNameCollision,
isAgent: suggestion.isAgent,
ownerLabel: suggestion.ownerLabel,
});
const collisionNpub =
hasNameCollision && suggestion.pubkey
? safeNpub(suggestion.pubkey)
Expand Down Expand Up @@ -140,9 +152,10 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className="min-w-0 break-words font-medium leading-snug"
title={suggestion.displayName}
data-testid="mention-suggestion-name"
title={displayName}
>
{suggestion.displayName}
{displayName}
</span>
{suggestion.kind === "team" ||
suggestion.isAgent ||
Expand Down
17 changes: 14 additions & 3 deletions desktop/src/features/messages/ui/NewMessageResultRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Bot } from "lucide-react";

import { formatOwnerLabel } from "@/features/profile/lib/identity";
import {
formatDisambiguatedAgentName,
formatOwnerLabel,
} from "@/features/profile/lib/identity";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { UserSearchResult } from "@/shared/api/types";
Expand Down Expand Up @@ -67,6 +70,7 @@ function HoverRecipientIdentity({
export function NewMessageResultRow({
currentPubkey,
disabled,
hasNameCollision = false,
isAlreadySelected = false,
isKeyboardHighlighted = false,
onSelect,
Expand All @@ -75,6 +79,7 @@ export function NewMessageResultRow({
}: {
currentPubkey?: string;
disabled: boolean;
hasNameCollision?: boolean;
isAlreadySelected?: boolean;
isKeyboardHighlighted?: boolean;
onSelect: (user: UserSearchResult) => void;
Expand All @@ -87,14 +92,20 @@ export function NewMessageResultRow({
currentPubkey,
ownerProfiles,
);
const displayName = formatDisambiguatedAgentName({
displayName: name,
hasNameCollision,
isAgent: user.isAgent,
ownerLabel,
});

return (
<div
className={cn("relative", RESULT_ROW_INSET_DIVIDER_CLASS)}
data-keyboard-highlighted={isKeyboardHighlighted ? "true" : undefined}
>
<button
aria-label={`${isAlreadySelected ? "Already added" : "Add"} ${name}`}
aria-label={`${isAlreadySelected ? "Already added" : "Add"} ${displayName}`}
aria-selected={isAlreadySelected || isKeyboardHighlighted}
className={cn(
"group/dm-result flex min-h-14 w-full cursor-pointer items-center gap-3 px-4 py-3.5 text-left transition-colors duration-150 ease-out hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
Expand All @@ -120,7 +131,7 @@ export function NewMessageResultRow({
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1">
<HoverRecipientIdentity
displayName={name}
displayName={displayName}
pubkey={user.pubkey}
/>
</div>
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/messages/ui/NewMessageScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from "@/features/channels/hooks";
import type { Channel } from "@/shared/api/types";
import { useSendMessageMutation } from "@/features/messages/hooks";
import {
countVisibleDisplayNames,
hasVisibleDisplayNameCollision,
} from "@/features/profile/lib/identity";
import { getKeyboardSearchSelection } from "@/features/profile/lib/userCandidateSearch";
import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip";
import { useIdentityQuery } from "@/shared/api/hooks";
Expand Down Expand Up @@ -72,6 +76,13 @@ export function NewMessageScreen() {
const isSearchTransitionPending = searchQuery.trim() !== deferredSearchQuery;
const visibleSearchResults =
isSearchTransitionPending || isDirectoryLoading ? [] : searchResults;
const visibleSearchNameCounts = React.useMemo(
() =>
countVisibleDisplayNames(
visibleSearchResults.map((user) => formatRecipientName(user)),
),
[visibleSearchResults],
);
const showRecipientPicker = isRecipientPickerOpen && !isPending;
const highlightedRecipientIndex = React.useMemo(() => {
if (!showRecipientPicker || visibleSearchResults.length === 0) {
Expand Down Expand Up @@ -518,6 +529,10 @@ export function NewMessageScreen() {
isKeyboardHighlighted={
highlightedRecipient?.pubkey === user.pubkey
}
hasNameCollision={hasVisibleDisplayNameCollision(
formatRecipientName(user),
visibleSearchNameCounts,
)}
key={user.pubkey}
onSelect={handleResultSelect}
ownerProfiles={ownerProfiles}
Expand Down
56 changes: 55 additions & 1 deletion desktop/src/features/profile/lib/identity.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";

import { profileLookupsEqual } from "./identity.ts";
import {
countVisibleDisplayNames,
formatDisambiguatedAgentName,
hasVisibleDisplayNameCollision,
profileLookupsEqual,
} from "./identity.ts";

test("formatDisambiguatedAgentName appends an owner for colliding agents", () => {
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: true,
ownerLabel: "sergior",
}),
"Bumble (sergior)",
);
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: true,
ownerLabel: "you",
}),
"Bumble (you)",
);
});

test("formatDisambiguatedAgentName leaves humans and unique agents unchanged", () => {
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: true,
isAgent: false,
ownerLabel: null,
}),
"Bumble",
);
assert.equal(
formatDisambiguatedAgentName({
displayName: "Bumble",
hasNameCollision: false,
isAgent: true,
ownerLabel: "sergior",
}),
"Bumble",
);
});

test("visible display-name collisions are case-insensitive", () => {
const counts = countVisibleDisplayNames(["Bumble", " bUmBlE ", "Fizz"]);

assert.equal(hasVisibleDisplayNameCollision("bumble", counts), true);
assert.equal(hasVisibleDisplayNameCollision("Fizz", counts), false);
});

const summary = (over = {}) => ({
displayName: "Ada",
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/profile/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,48 @@ export function formatOwnerLabel(
truncatePubkey(ownerPubkey)
);
}

/**
* Appends an agent's owner to a display name only when that name collides with
* another visible result. The returned label is presentation-only; callers
* keep the original display name for selection and mention insertion.
*/
export function formatDisambiguatedAgentName(input: {
displayName: string;
hasNameCollision: boolean;
isAgent?: boolean;
ownerLabel?: string | null;
}) {
const { displayName, hasNameCollision, isAgent, ownerLabel } = input;
const normalizedOwnerLabel = ownerLabel?.trim();

if (!hasNameCollision || !isAgent || !normalizedOwnerLabel) {
return displayName;
}

return `${displayName} (${normalizedOwnerLabel})`;
}

/** Case-insensitive display-name counts for a currently visible result set. */
export function countVisibleDisplayNames(
displayNames: readonly (string | null | undefined)[],
) {
const counts = new Map<string, number>();

for (const displayName of displayNames) {
const normalizedName = displayName?.trim().toLowerCase();
if (!normalizedName) {
continue;
}
counts.set(normalizedName, (counts.get(normalizedName) ?? 0) + 1);
}

return counts;
}

export function hasVisibleDisplayNameCollision(
displayName: string,
counts: ReadonlyMap<string, number>,
) {
return (counts.get(displayName.trim().toLowerCase()) ?? 0) > 1;
}
Loading