diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f2..04ce9256a30 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,7 +10,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; @@ -72,7 +72,7 @@ export function useTrayMenu({ activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, agentName: agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + `Agent ${truncateNpub(pubkey)}`, channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 7db7c32bfa5..89588b57567 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/channels/lib/memberUtils.ts b/desktop/src/features/channels/lib/memberUtils.ts index d921457e000..10ad4b7ff0a 100644 --- a/desktop/src/features/channels/lib/memberUtils.ts +++ b/desktop/src/features/channels/lib/memberUtils.ts @@ -1,5 +1,5 @@ import type { ChannelMember } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export const roleOrder: Record = { owner: 0, @@ -17,7 +17,7 @@ export function formatMemberName( return "You"; } - return member.displayName ?? truncatePubkey(member.pubkey); + return member.displayName ?? truncateNpub(member.pubkey); } export function compareMembersByRole( diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index 7edeb842c8b..9e7dda9c7fe 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -3,7 +3,7 @@ import { Bot } from "lucide-react"; import type { UserSearchResult } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const MEMBER_ROW_INSET_DIVIDER_CLASS = @@ -13,7 +13,7 @@ export function formatAddCandidateName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -67,7 +67,7 @@ export function AddMemberSearchResultRow({ /> - {truncatePubkey(user.pubkey)} + {truncateNpub(user.pubkey)} {ownerLabel ? ( diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 90fb5bfaa9b..335e379ce8e 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -2,7 +2,7 @@ import { Search, UserPlus, X } from "lucide-react"; import * as React from "react"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -19,7 +19,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,11 +270,11 @@ export function ChannelMemberInviteCard({

- {truncatePubkey(directInvitee.pubkey)} + {truncateNpub(directInvitee.pubkey)}

by public key @@ -370,7 +370,7 @@ export function ChannelMemberInviteCard({
{submissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 1b0b7687c1b..3c9afbfaeca 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -49,7 +49,7 @@ import { } from "@/shared/ui/dialog"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { MODAL_SEARCH_INPUT_CLASS, MODAL_SEARCH_SHELL_CLASS, @@ -641,7 +641,7 @@ export function MembersSidebar({ managedAgentRuntime={managedAgentRuntime} member={member} memberIsBot={memberIsBot} - memberAvatarLabel={member.displayName ?? truncatePubkey(member.pubkey)} + memberAvatarLabel={member.displayName ?? truncateNpub(member.pubkey)} memberLabel={formatMemberName(member, currentPubkey)} moderationState={moderationStateByPubkey.get( normalizePubkey(member.pubkey), @@ -886,7 +886,7 @@ export function MembersSidebar({
{inviteSubmissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 9f187388645..6cc3da1d9c2 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -27,7 +27,7 @@ import { MANAGED_AGENT_PAIR_ACTION_LABELS, type ManagedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import type { ChannelMember, ManagedAgent, @@ -195,7 +195,7 @@ export function MembersSidebarMemberCard({
- {truncatePubkey(member.pubkey)} + {truncateNpub(member.pubkey)} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index e12c1bc7768..d5cd1824e0d 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -8,7 +8,7 @@ import type { RelayAgent, } from "@/shared/api/types"; import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { channelBotMemberPubkeySet, channelMemberPubkeySet, @@ -100,7 +100,7 @@ export function buildChannelAgentSessionCandidates({ byPubkey.set(key, { pubkey: member.pubkey, - name: member.displayName ?? truncatePubkey(member.pubkey), + name: member.displayName ?? truncateNpub(member.pubkey), status: "deployed", agentSource: "member-bot", canInterruptTurn: false, diff --git a/desktop/src/features/community-members/ui/AddMemberDialog.tsx b/desktop/src/features/community-members/ui/AddMemberDialog.tsx index 5464111752f..c2a26b5df3b 100644 --- a/desktop/src/features/community-members/ui/AddMemberDialog.tsx +++ b/desktop/src/features/community-members/ui/AddMemberDialog.tsx @@ -13,7 +13,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import type { RelayMemberRole, UserSearchResult } from "@/shared/api/types"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -50,7 +50,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx index fdc75f3d183..7693212711b 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx @@ -3,7 +3,7 @@ import { MoreHorizontal, Plus, Shield, ShieldCheck, User } from "lucide-react"; import { toast } from "sonner"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useChangeRelayMemberRoleMutation, @@ -101,7 +101,7 @@ function MemberRow({
- {displayName || truncatePubkey(member.pubkey)} + {displayName || truncateNpub(member.pubkey)} {isSelf ? ( diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 29338ff5672..dad1f7ff6bc 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -19,7 +19,11 @@ import type { RelayMemberRole, UserProfileSummary, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { + normalizePubkey, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -65,7 +69,7 @@ function HoverMemberIdentity({ displayName: string; pubkey: string; }) { - const npub = npubFromPubkey(pubkey) ?? pubkey; + const npub = npubFromPubkey(pubkey) ?? UNAVAILABLE_KEY_LABEL; return ( - {truncatePubkey(npub)} + {truncateNpub(npub)} ); diff --git a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx index 64735a5d567..087ef952fff 100644 --- a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx +++ b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx @@ -1,6 +1,6 @@ import { toast } from "sonner"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useRemoveRelayMemberMutation } from "@/features/community-members/hooks"; import type { RelayMember } from "@/shared/api/types"; @@ -25,7 +25,7 @@ export function ConfirmRemoveDialog({ onOpenChange: (open: boolean) => void; }) { const removeMutation = useRemoveRelayMemberMutation(); - const label = displayName || (member ? truncatePubkey(member.pubkey) : ""); + const label = displayName || (member ? truncateNpub(member.pubkey) : ""); function handleOpenChange(next: boolean) { if (!next) { diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs index 7b34b901619..52d7a0a114d 100644 --- a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs @@ -250,6 +250,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts.ts"; import { joinAlertStorageKey } from "@/features/community-members/lib/joinAlerts.ts"; +import { truncateNpub } from "@/shared/lib/pubkey.ts"; import { relayClient } from "@/shared/api/relayClient.ts"; import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; import { useCommunities } from "@/features/communities/useCommunities.tsx"; @@ -1715,7 +1716,7 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a frame that predates the demotion must not re-open disclosure", ); assert.ok( - !notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + !notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the demoted viewer must never learn the new member's identity", ); assert.equal( @@ -1982,11 +1983,11 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a community switch clears the latch: the feature recovers without a reload", ); assert.ok( - notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the join suppressed by the latch is re-announced, not lost", ); assert.ok( - notifications.some((entry) => entry.body?.includes(CAROL.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(CAROL))), "and the new join lands too", ); diff --git a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs index 65e2912ce70..e827635d145 100644 --- a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs +++ b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs @@ -86,6 +86,9 @@ async function setup(options = {}) { "@/shared/lib/pubkey": { normalizePubkey: (s) => s.toLowerCase(), truncatePubkey: (s) => s, + // Compact-identity seam stubbed alongside its sibling: this suite + // renders display names, never key-form labels. + truncateNpub: (s) => s, }, "@/features/channels/hooks": { useAddChannelMembersMutation: () => ({ diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts index 09a7392a829..3289a5e8068 100644 --- a/desktop/src/features/forum/ui/useForumMentionPreparation.ts +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -4,7 +4,7 @@ import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/chan import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { ChannelType } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type PendingInvite = { channelId: string; @@ -176,7 +176,7 @@ export function useForumMentionPreparation( isInvitePending: isInviting, names: (pending?.nonMemberPubkeys ?? []).map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ), onDismiss: dismiss, onInvite: () => void invite(), diff --git a/desktop/src/features/home/ui/RecentNotesSection.tsx b/desktop/src/features/home/ui/RecentNotesSection.tsx index e0837b219df..3bff5c015a7 100644 --- a/desktop/src/features/home/ui/RecentNotesSection.tsx +++ b/desktop/src/features/home/ui/RecentNotesSection.tsx @@ -4,7 +4,7 @@ import type { UserNote } from "@/shared/api/socialTypes"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type RecentNotesSectionProps = { notes: UserNote[]; @@ -52,8 +52,7 @@ export function RecentNotesSection({
{notes.slice(0, 5).map((note) => { const profile = profiles[note.pubkey.toLowerCase()]; - const displayName = - profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const isAgent = agentPubkeys.has(note.pubkey); return ( diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7c..4c01ca68a15 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -33,7 +33,7 @@ import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; import { HuddleParticipantsControl } from "./ParticipantList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; // Mirrors HuddleState in src-tauri/src/huddle/mod.rs. type HuddleState = { @@ -98,7 +98,7 @@ function clampReactionName(name: string): string { } function fallbackNameForPubkey(pubkey?: string | null): string { - return pubkey ? `Participant ${truncatePubkey(pubkey)}` : "Someone"; + return pubkey ? `Participant ${truncateNpub(pubkey)}` : "Someone"; } function parseHuddleReactionEvent(event: RelayEvent) { diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index 04b6a7453bf..b7d8d5888e9 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -11,7 +11,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { VoiceRegistryEntry } from "@/features/settings/ui/voiceSettingsLogic"; import { invokeTauri } from "@/shared/api/tauri"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -49,6 +49,13 @@ const MAX_VISIBLE_PARTICIPANTS = 9; type ParticipantIdentity = { avatarUrl: string | null; displayName: string; + /** + * Label the avatar derives initials from: the authored name when one + * exists, otherwise the unprefixed compact key. `displayName` is the + * visible "Participant npub1…" fallback, whose word initials would + * collapse every unnamed participant onto "PN"/"AN". + */ + initialsLabel: string; isActive: boolean; isAgent: boolean; pubkey: string; @@ -110,10 +117,10 @@ function buildParticipantIdentities({ const profile = profiles[normalizedPubkey]; const isAgent = agentSet.has(normalizedPubkey); const agent = agentNames.get(normalizedPubkey); + const authoredName = profile?.displayName?.trim() || agent?.name?.trim(); + const keyLabel = truncateNpub(pubkey); const displayName = - profile?.displayName?.trim() || - agent?.name?.trim() || - `${isAgent ? "Agent" : "Participant"} ${truncatePubkey(pubkey)}`; + authoredName || `${isAgent ? "Agent" : "Participant"} ${keyLabel}`; const speakerLevel = normalizedSpeakerLevels.get(normalizedPubkey) ?? (activeSpeakerSet.has(normalizedPubkey) ? 0.55 : 0); @@ -121,6 +128,7 @@ function buildParticipantIdentities({ return { avatarUrl: profile?.avatarUrl ?? agent?.avatarUrl ?? null, displayName, + initialsLabel: authoredName || keyLabel, isActive: activeSpeakerSet.has(normalizedPubkey) || speakerLevel > 0.04, isAgent, pubkey, @@ -453,6 +461,7 @@ function ParticipantAvatar({ diff --git a/desktop/src/features/huddle/lib/huddleChannelName.ts b/desktop/src/features/huddle/lib/huddleChannelName.ts index c9f6dff2835..bd01c0d3e8a 100644 --- a/desktop/src/features/huddle/lib/huddleChannelName.ts +++ b/desktop/src/features/huddle/lib/huddleChannelName.ts @@ -1,5 +1,5 @@ import type { Channel, ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type BuildHuddleChannelNameInput = { channel: Channel; @@ -33,7 +33,7 @@ function channelParticipantLabel( return firstName(fallbackName); } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } export function buildHuddleChannelName({ diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index a24da67f700..b35e8140481 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -45,7 +45,7 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; const HEX_RE = /^[0-9a-f]+$/i; @@ -387,7 +387,7 @@ export function formatTimelineMessages( ? "You" : profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(actorPubkey); + truncateNpub(actorPubkey); existing.users.push({ pubkey: actorPubkey, displayName, diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 2ee46b01415..9c714015b24 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -5,7 +5,7 @@ import type { ChannelRole, UserSearchResult, } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export function formatSearchUserDisplayName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || null; @@ -54,7 +54,7 @@ export type MentionCandidate = { export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent") + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent") ); } diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 39f16c96791..39db21be0c3 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -444,20 +444,38 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => { test("whole compact mention labels are restored only for their declared exact key", () => { const key = `150b20bd${"a".repeat(52)}15dc`; const label = `Scout (${key}) 2`; - const compact = "Scout (150b20bd…15dc) 2"; - assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + // The npub compact a chip renders today, and the hex compact a chip copied + // before keys displayed as npub still carries: a whole chip in either + // form re-binds; nothing else does. + const npubCompact = "Scout (npub1z59…zwkg) 2"; + const legacyCompact = "Scout (150b20bd…15dc) 2"; + + for (const compact of [npubCompact, legacyCompact]) { + assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + assert.equal( + matchChipTextToLabel(`@${compact}`, label, "@", key), + "truncated", + ); + } + assert.equal(matchChipTextToLabel(npubCompact, label, "@"), "fragment"); + assert.equal( + matchChipTextToLabel(npubCompact, label, "@", "b".repeat(64)), + "fragment", + ); + assert.equal(matchChipTextToLabel(npubCompact, label, "#", key), "fragment"); + // A different key's compact — npub or legacy hex — is text this record + // never declared: tampered text never gains the identity's binding. assert.equal( - matchChipTextToLabel(`@${compact}`, label, "@", key), - "truncated", + matchChipTextToLabel("Scout (npub1m6k…zuz0) 2", label, "@", key), + "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment"); assert.equal( - matchChipTextToLabel(compact, label, "@", "b".repeat(64)), + matchChipTextToLabel("Scout (deadbeef…beef) 2", label, "@", key), "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment"); + // A dropped collision suffix is a partial chip, not a tolerated form. assert.equal( - matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key), + matchChipTextToLabel("Scout (npub1z59…zwkg)", label, "@", key), "fragment", ); assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment"); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts index 8886bf8c29d..8b093610a49 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.ts +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -1,4 +1,7 @@ -import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "@/shared/lib/mentionDisplay"; import { truncateInlineChipLabel } from "@/shared/ui/mentionChip"; import { getMentionOffsets } from "./hasMention"; @@ -96,6 +99,17 @@ export function matchChipTextToLabel( if (compact !== label && matches(canonicalMentionLabel(compact))) { return "truncated"; } + // A chip copied before keys displayed as npub carries the hex-truncated + // key in its text. Accept that prior *display* form — derived from the + // same label and pubkey this record declares, never from the pasted text + // itself — so an old whole-chip copy still re-binds instead of losing its + // identity. The two forms are disjoint (a hex truncation cannot contain + // the `n` an npub starts with), so this cannot reclassify any new chip. + const legacy = + sigil === "@" ? formatLegacyMentionDisplayLabel(label, pubkey) : label; + if (legacy !== label && matches(canonicalMentionLabel(legacy))) { + return "truncated"; + } return "fragment"; } diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 3df5bba0b0a..deef2d3ebff 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -1,4 +1,4 @@ -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; @@ -118,7 +118,7 @@ export function rankMentionCandidates( : ""; const label = candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent"); + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent"); const groupRank = getMentionCandidateGroupRank( candidate, activePersonaIds, diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index 3e0563ab96c..a42a3a9aecb 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -272,10 +272,22 @@ test("compact mention paste expands only complete bound labels", () => { const html = (text) => `${text}`; + // Today's chip text renders the key as npub… + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg) 2")).text, + `@${label}`, + ); + // …and a whole chip copied before that switch still carries the hex + // compact, which must keep expanding to the exact declared identity. assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text, `@${label}`, ); + // A dropped collision suffix — in either form — is a partial chip. + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg)")).text, + "Scout (npub1z59…zwkg)", + ); assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text, "Scout (150b20bd…15dc)", diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 8981d363803..630ebbc6fea 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -607,6 +607,13 @@ test("buildThreadPanelDataFromIndex matches direct panel data", () => { test("buildMainTimelineEntries renders a relay-only thread summary", () => { const root = message({ id: "root", createdAt: 1 }); + // Realistic 64-hex relay participant keys: a cold/relay-only summary has no + // client messages to derive labels from, so an unnamed participant must + // fall back to the compact npub (`truncateNpub`, the same form the + // client-assembled path derives via `resolveUserLabel`) — never the raw + // hex identity. `participant.author` is what `MessageThreadSummaryRow` + // binds to `UserAvatar`'s visible/accessible `displayName` label. + const bob = "deadbeef".repeat(8); // → npub1m6k…zuz0 const summaries = new Map([ [ "root", @@ -614,7 +621,7 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { replyCount: 2, descendantCount: 4, lastReplyAt: 9, - participantPubkeys: ["alice", "bob"], + participantPubkeys: ["alice", bob], }, ], ]); @@ -631,10 +638,10 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { threadHeadId: "root", replyCount: 4, lastReplyAt: 9, - // Relay returns participants most-recent-first (["alice", "bob"]); the + // Relay returns participants most-recent-first (["alice", bob]); the // facepile renders them oldest-first so the last replier lands rightmost. participants: [ - { id: "bob", author: "bob", avatarUrl: null }, + { id: bob, author: "npub1m6k…zuz0", avatarUrl: null }, { id: "alice", author: "Alice", avatarUrl: "alice.png" }, ], }); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 3bc5ee39125..b85b57d1ac9 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -2,6 +2,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isBroadcastReply } from "@/features/messages/lib/threading"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; type ThreadPanelData = { @@ -408,7 +409,13 @@ function buildRelayThreadSummary( .reverse() .map((pubkey) => ({ id: pubkey, - author: profiles?.[pubkey.toLowerCase()]?.displayName ?? pubkey, + // Unnamed participants fall back to the compact npub — the same label + // the client-assembled path derives via `resolveUserLabel` — so a + // cold/relay-only facepile never surfaces raw hex. This `author` is + // what `MessageThreadSummaryRow` binds to `UserAvatar`'s + // `displayName` (the visible/accessible avatar label). + author: + profiles?.[pubkey.toLowerCase()]?.displayName ?? truncateNpub(pubkey), avatarUrl: profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null, ...(profiles?.[pubkey.toLowerCase()]?.isAgent === true ? { isAgent: true } diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs index 4f8c05842aa..ceada037c0d 100644 --- a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs +++ b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs @@ -190,15 +190,20 @@ test("copy inlines a blockified chip but preserves its block ancestor", () => { test("copy expands compact key text but preserves the exact label and identity", () => { const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; - const flavors = copyRenderedBody( - `` + - 'Scout (7c7c7c7c…7c7c) 2', - ); - assert.ok(flavors); - assert.ok(flavors.html.includes(`@${label}`)); - assert.ok( - flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), - ); - assert.ok(!flavors.html.includes("…")); + // Chips render the npub compact today; a chip copied before that switch + // still carries the hex compact. A whole chip in either form expands to + // the declared identity. + for (const compact of ["npub1037…08vj", "7c7c7c7c…7c7c"]) { + const flavors = copyRenderedBody( + `` + + `Scout (${compact}) 2`, + ); + assert.ok(flavors); + assert.ok(flavors.html.includes(`@${label}`)); + assert.ok( + flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), + ); + assert.ok(!flavors.html.includes("…")); + } }); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a380ac83a09..65ae331d594 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/shared/ui/switch"; import { Toggle } from "@/shared/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { safeNpub } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { @@ -387,7 +387,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ data-testid="mention-collision-npub" title={collisionNpub} > - {truncatePubkey(collisionNpub)} + {truncateNpub(collisionNpub)} ) : null} diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx index e3ab0c6076a..644a8dc80d3 100644 --- a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx +++ b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx @@ -5,7 +5,7 @@ import { getVisibleAgentAddressPubkeys } from "../lib/getVisibleAgentAddressPubk import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { InlineChip } from "@/shared/ui/InlineChip"; /** Resolve all literal competitors before hiding tag-backed address chips. */ @@ -54,7 +54,7 @@ export function MessageAgentAddressPrefix({ const label = profile?.displayName?.trim() || profile?.name?.trim() || - truncatePubkey(pubkey); + truncateNpub(pubkey); return ( {/* biome-ignore lint/a11y/useValidAriaRole: UserProfilePopover uses role for agent classification, not as an ARIA attribute. */} diff --git a/desktop/src/features/messages/ui/NewMessageResultRow.tsx b/desktop/src/features/messages/ui/NewMessageResultRow.tsx index 9625fb7882e..ea07a0607a1 100644 --- a/desktop/src/features/messages/ui/NewMessageResultRow.tsx +++ b/desktop/src/features/messages/ui/NewMessageResultRow.tsx @@ -6,7 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatRecipientName } from "./useNewMessageRecipients"; @@ -28,7 +28,7 @@ function HoverRecipientIdentity({ displayName: string; pubkey: string; }) { - const identityLabel = truncatePubkey(pubkey); + const identityLabel = truncateNpub(pubkey); return ( {typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; - const label = labels[index] ?? truncatePubkey(pubkey); + const label = labels[index] ?? truncateNpub(pubkey); return (
key.toLowerCase(), truncatePubkey: (key) => key, + // Compact-identity seam stubbed alongside its sibling: these suites + // render display names, never key-form labels. + truncateNpub: (key) => key, }, "@/shared/lib/customEmojiTags": { buildCustomEmojiTags: () => [] }, "./useMentionSendFlow.helpers": helpers, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 9847193101b..299c0144ab4 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -28,7 +28,7 @@ import { useDetachedAgentStart } from "./useDetachedAgentStart"; import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { dedupeQueuedAgentWakes, @@ -941,7 +941,7 @@ export function useMentionSendFlow({ if (!pendingNonMemberSend) return []; return pendingNonMemberSend.nonMemberPubkeys.map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); const invitation = useNonMemberInvite({ diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts index ffc45c6c9e2..c1b821da62b 100644 --- a/desktop/src/features/messages/ui/useNewMessageRecipients.ts +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -20,7 +20,7 @@ import { import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; /** Maximum recipients (excluding the current user) a DM can address. */ export const NEW_MESSAGE_RECIPIENT_LIMIT = 8; @@ -37,7 +37,7 @@ export function formatRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 4a0865437ed..e4594547eed 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { resolveUserLabel, type UserProfileLookup, @@ -204,7 +204,7 @@ export function useFeedDesktopNotifications( : undefined; // Only use real display names, not truncated pubkey fallbacks. const senderName = - resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey) + resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) ? resolvedLabel : undefined; void deliverFeedNotification(item, senderName); diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index da0259a66fc..089195213b3 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts"; +import { + formatOwnerLabel, + profileLookupsEqual, + resolveUserLabel, +} from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +// npubEncode(OWNER_PUBKEY), pinned so a fallback regression cannot pass by +// re-deriving the expectation from the code under test. +const OWNER_NPUB_COMPACT = "npub1hwa…04hu"; + const summary = (over = {}) => ({ displayName: "Ada", avatarUrl: "https://x/a.png", @@ -15,13 +23,28 @@ const summary = (over = {}) => ({ ...over, }); -test("formatOwnerLabel resolves a known owner's display name", () => { +test("formatOwnerLabel prefers the owner’s authored labels over the compact npub", () => { + // The label ladder: display name first, then a NIP-05 handle, then the + // key’s compact npub — never raw hex. assert.equal( formatOwnerLabel(OWNER_PUBKEY, null, { [OWNER_PUBKEY]: summary({ displayName: "baxen" }), }), "baxen", ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), { + [OWNER_PUBKEY]: summary({ + nip05Handle: "baxen@relay", + displayName: null, + }), + }), + "baxen@relay", + ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), {}), + OWNER_NPUB_COMPACT, + ); }); test("formatOwnerLabel calls the viewer-owned agent's owner you", () => { @@ -32,6 +55,31 @@ test("formatOwnerLabel returns null when verified ownership is absent", () => { assert.equal(formatOwnerLabel(null, OWNER_PUBKEY, {}), null); }); +test("resolveUserLabel falls back to the key’s compact npub, never raw hex", () => { + // No profile, no fallback name: the last resort is the npub compact. + assert.equal( + resolveUserLabel({ pubkey: OWNER_PUBKEY, profiles: {} }), + OWNER_NPUB_COMPACT, + ); + // A provided fallback name still wins over the key form. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: {}, + fallbackName: "legacy relay agent", + }), + "legacy relay agent", + ); + // A resolved display name wins over everything. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: { [OWNER_PUBKEY]: summary({ displayName: "baxen" }) }, + }), + "baxen", + ); +}); + test("profileLookupsEqual: same reference is equal", () => { const a = { p1: summary() }; assert.equal(profileLookupsEqual(a, a), true); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd38..a0d32faaaa4 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,9 +1,9 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type UserProfileLookup = Record; -export { truncatePubkey }; +export { truncateNpub }; /** * Deep-equal two profile lookups by value. Used to stabilise the merged @@ -128,7 +128,7 @@ export function resolveUserLabel(input: { return safeFallback; } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } /** @@ -188,6 +188,6 @@ export function formatOwnerLabel( return ( owner?.displayName?.trim() || owner?.nip05Handle?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index debbc42803a..d8d22eaa76a 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -23,6 +23,17 @@ type ProfileAvatarProps = { avatarUrl: string | null; avatarDataUrl?: string | null; label: string; + /** + * Label used to derive fallback initials; defaults to `label`. + * + * `label` stays the full visible/alt identity, but some callers build it + * as a generated role-prefixed key fallback ("Agent npub1abcd…wxyz"), + * which `getInitials` reads as ordinary words — collapsing every unnamed + * identity onto the same "AN"/"PN" initials. Identity-aware callers pass + * the unprefixed compact key here so key-fallback avatars keep distinct + * key-tail initials; authored display names keep their name initials. + */ + initialsLabel?: string; className?: string; iconClassName?: string; imageClassName?: string; @@ -46,6 +57,7 @@ export function ProfileAvatar({ avatarUrl, avatarDataUrl, label, + initialsLabel, className, iconClassName, imageClassName, @@ -54,7 +66,7 @@ export function ProfileAvatar({ testId, untrusted = false, }: ProfileAvatarProps) { - const initials = getInitials(label); + const initials = getInitials(initialsLabel ?? label); const presentation = useAvatarPresentation(avatarUrl); const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl; diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index d060deb5323..bad18369fe3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -77,7 +77,7 @@ import { resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, - truncatePubkey, + truncateNpub, type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; @@ -675,7 +675,7 @@ export function UserProfilePanel({ return ( ownerProfile?.nip05Handle?.trim() || ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } @@ -687,7 +687,7 @@ export function UserProfilePanel({ return ( currentProfile?.nip05Handle?.trim() || currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); }, [ currentProfileQuery.data, diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index be1a57c112e..e7d5a4d4ef8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -8,9 +8,9 @@ import type { RelayAgent, UpdateManagedAgentInput, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; -export { truncatePubkey }; +export { truncateNpub }; export type ProfileChannelLink = { id: string; @@ -237,7 +237,7 @@ export function resolveProfileDisplayName({ return ( profile?.displayName ?? persona?.displayName ?? - (pubkey ? truncatePubkey(pubkey) : "Agent") + (pubkey ? truncateNpub(pubkey) : "Agent") ); } @@ -252,7 +252,7 @@ export function resolveOwnerHandle( return ( profile?.nip05Handle?.trim() || profile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 3fab7b8102e..c36b59e3af6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -30,7 +30,7 @@ import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWith import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; import { @@ -108,7 +108,7 @@ function HoverPubkeyName({ - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); @@ -297,7 +297,7 @@ function UserProfilePopoverBody({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); + const displayName = profile?.displayName ?? truncateNpub(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows diff --git a/desktop/src/features/profile/ui/useProfileInteractionActions.ts b/desktop/src/features/profile/ui/useProfileInteractionActions.ts index f5ee47a7702..1993188f319 100644 --- a/desktop/src/features/profile/ui/useProfileInteractionActions.ts +++ b/desktop/src/features/profile/ui/useProfileInteractionActions.ts @@ -21,7 +21,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type ProfileInteractionAction = "huddle" | "message" | "wave"; @@ -207,7 +207,7 @@ export function useProfileInteractionActions({ const senderName = selfProfileQuery.data?.displayName?.trim() || identity.displayName.trim() || - truncatePubkey(identity.pubkey); + truncateNpub(identity.pubkey); const content = buildWaveMessageContent(senderName); const queryKey = channelMessagesKey(dm.id); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 54073bac5dd..71855770f27 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -13,7 +13,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -37,7 +37,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -45,7 +45,7 @@ function assigneeSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -383,7 +383,7 @@ export function IssueAssigneesRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index d960d3f09fc..bdb81011b3a 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -16,7 +16,7 @@ import { selectionItemFromCommit } from "@/features/projects/lib/projectSelectio import { commitShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoCommit } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { resolveUserLabel, @@ -129,7 +129,7 @@ export function ContributorsPanel({ isAgent, label: profile ? resolveUserLabel({ profiles, pubkey }) - : truncatePubkey(pubkey), + : truncateNpub(pubkey), profileLinked: true, pubkey, reviewCount: signedCounts.reviews, diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index 281aefe71be..fc64ffc6324 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -7,7 +7,7 @@ import type { } from "@/features/projects/projectPullRequests.mjs"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectRichContent } from "./ProjectRichContent"; function commentAuthor( @@ -18,7 +18,7 @@ function commentAuthor( return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index b130bf552c5..9fe09f50cd4 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -33,7 +33,7 @@ import { canReviewProjectPullRequest } from "@/features/projects/pullRequestRevi import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectFeedRow, ProjectFeedRowCluster, @@ -70,7 +70,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index fa5f205fabb..8b824f917d9 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -19,7 +19,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -44,7 +44,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -52,7 +52,7 @@ function reviewerSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,7 +270,7 @@ export function PullRequestReviewersRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 30e8601912a..310820363e2 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -6,7 +6,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type AgentActivityCardProps = { group: AgentNoteGroup; @@ -51,7 +51,7 @@ export function AgentActivityCard({ agentStatus, }: AgentActivityCardProps) { const [expanded, setExpanded] = React.useState(false); - const displayName = profile?.displayName ?? truncatePubkey(group.pubkey); + const displayName = profile?.displayName ?? truncateNpub(group.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const isSingleNote = group.notes.length === 1; diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 96f25a6ede4..7ce04ce3dba 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -18,7 +18,7 @@ import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { Markdown } from "@/shared/ui/markdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type NoteCardActions = { reply?: ( @@ -67,7 +67,7 @@ function ReplyParentContext({ const parentDisplayName = parentNote ? (cachedProfile?.displayName ?? fetchedProfile?.displayName ?? - truncatePubkey(parentNote.pubkey)) + truncateNpub(parentNote.pubkey)) : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; @@ -149,7 +149,7 @@ export function NoteCard({ members = [], actions, }: NoteCardProps) { - const displayName = profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); const actionButtonClass = diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 2b595ed2ccb..01cd20d6225 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -30,7 +30,7 @@ import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type PulseTab = | "search" @@ -225,7 +225,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { : null; const currentDisplayName = currentProfile?.displayName ?? - (currentPubkey ? truncatePubkey(currentPubkey) : "You"); + (currentPubkey ? truncateNpub(currentPubkey) : "You"); const pulseMentionMembers = React.useMemo(() => { const members: ChannelMember[] = []; diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 68e879a3033..5a032c780a4 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -20,7 +20,7 @@ import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchTex import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { @@ -134,7 +134,7 @@ function getUserDisplayName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 58dbe87ab79..9bb5962585a 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -34,7 +34,7 @@ import { type SeverityTier, } from "@/features/settings/lib/moderationQueue"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -195,14 +195,15 @@ const SEVERITY_BADGE: Record = { }; function targetLabel(group: ModerationQueueGroup): string { - const short = truncatePubkey(group.target); switch (group.targetKind) { case "event": - return `Message ${short}`; + // An event id is not a pubkey identity: keep the generic hex form. + return `Message ${truncatePubkey(group.target)}`; case "pubkey": - return `Member ${short}`; + return `Member ${truncateNpub(group.target)}`; case "blob": - return `Attachment ${short}`; + // Blob ids are not pubkey identities either. + return `Attachment ${truncatePubkey(group.target)}`; } } @@ -213,7 +214,7 @@ function ReporterLine({ report: ModerationReport; displayName?: string | null; }) { - const who = displayName?.trim() || truncatePubkey(report.reporterPubkey); + const who = displayName?.trim() || truncateNpub(report.reporterPubkey); return (
@@ -476,9 +477,11 @@ function AuditRow({ action: ModerationAction; actorName?: string | null; }) { - const who = actorName?.trim() || truncatePubkey(action.actorPubkey); + const who = actorName?.trim() || truncateNpub(action.actorPubkey); + // Actor and member targets are pubkey identities; a targeted event keeps + // the generic hex truncation for its event id. const targetShort = action.targetPubkey - ? truncatePubkey(action.targetPubkey) + ? truncateNpub(action.targetPubkey) : action.targetEventId ? truncatePubkey(action.targetEventId) : null; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 34fdba0bdfe..1eea8e21789 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -13,7 +13,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; import { @@ -150,12 +150,15 @@ function ThreadPreviewRow({ function WorkingAgentRow({ avatarUrl, elapsed, + initialsLabel, name, onOpen, pubkey, }: { avatarUrl: string | null; elapsed: string; + /** Unprefixed key (or authored name) the avatar derives initials from. */ + initialsLabel: string; name: string; onOpen: () => void; pubkey: string; @@ -171,6 +174,7 @@ function WorkingAgentRow({ avatarUrl={avatarUrl} className="h-9 w-9 shrink-0" displayName={name} + initialsLabel={initialsLabel} shape="squircle" size="md" /> @@ -192,7 +196,12 @@ function WorkingAgentRow({ ); } -function WorkingAgentRows({ +/** + * Working-agent rows for the channel activity popover. Exported for consumer + * tests: it owns the generated `Agent npub1…` fallback label that flows into + * `UserAvatar` initials. + */ +export function WorkingAgentRows({ activeWorking, channelId, onOpen, @@ -212,14 +221,15 @@ function WorkingAgentRows({ return activeWorking.agentPubkeys.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; - const name = - profile?.displayName?.trim() || - alignedAgentNames?.[index] || - `Agent ${truncatePubkey(pubkey)}`; + const authoredName = + profile?.displayName?.trim() || alignedAgentNames?.[index]; + const keyLabel = truncateNpub(pubkey); + const name = authoredName || `Agent ${keyLabel}`; return ( onOpen(pubkey, channelId)} diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx index 9d6bdddc005..6550845295d 100644 --- a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -10,7 +10,7 @@ import { } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Input } from "@/shared/ui/input"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { @@ -348,7 +348,7 @@ function AuthorOption({ {label} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} { }), "“Build finished” to team-on-call", ); + // A key destination renders its compact npub (npubEncode of the pinned + // hex key); a checksum-broken npub renders the neutral label, never the + // raw text as if it were a role. + const hexKey = "deadbeef".repeat(8); + const brokenNpub = + "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuzy"; + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: hexKey, + }), + "“Build finished” to npub1m6k…zuz0", + ); + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: brokenNpub, + }), + "“Build finished” to Unavailable", + ); assert.equal( workflowStepDescription({ id: "approval", @@ -68,6 +92,15 @@ test("describes configured workflow steps on the canvas", () => { }), "“Ship the release?” from release-managers", ); + assert.equal( + workflowStepDescription({ + id: "approval", + action: "request_approval", + message: "Ship the release?", + from: hexKey, + }), + "“Ship the release?” from npub1m6k…zuz0", + ); assert.equal( workflowStepDescription({ id: "reaction", diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts index 136ce026111..40697fe352f 100644 --- a/desktop/src/features/workflows/ui/workflowStepDescription.ts +++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatDurationSecondsVerbose, parseDurationSeconds, @@ -20,11 +20,17 @@ function quoted(value: string | undefined): string | null { return normalized ? `“${normalized}”` : null; } +// DM/approver references are pubkeys — 64-char hex in any case, or a +// canonical lowercase npub — or freeform role/template text; only a key +// identity renders as a key (an npub-shaped string that fails the shared +// helper's checksum renders the neutral Unavailable, never raw text). +const KEY_REFERENCE = /^(?:[0-9a-f]{64}|npub1[0-9a-z]+)$/i; + function destination(value: string | undefined): string | null { const normalized = value?.trim(); if (!normalized) return null; - return /^[0-9a-f]{64}$/i.test(normalized) - ? truncatePubkey(normalized) + return KEY_REFERENCE.test(normalized) + ? truncateNpub(normalized) : compact(normalized); } diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs index 1f2d03b2382..9c96abd3539 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs @@ -170,6 +170,39 @@ test("describes selected trigger conditions on the workflow canvas", () => { ), "👾 reaction added by Carl to “hey yourself”", ); + + // Without a resolved label, an author renders its compact npub while the + // referenced message keeps the generic hex truncation — an event id, not + // a pubkey identity. + const unresolvedAuthor = "deadbeef".repeat(8); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author == "${unresolvedAuthor}"`, + }), + "Message posted by npub1m6k…zuz0", + ); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author != "${unresolvedAuthor}"`, + }), + "Message posted by anyone except npub1m6k…zuz0", + ); + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_message_id == "${"b".repeat(64)}"`, + }), + "Reaction added to bbbbbbbb…bbbb", + ); + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_emoji == "👾" && trigger_author == "${unresolvedAuthor}" && trigger_message_id == "${"b".repeat(64)}"`, + }), + "👾 reaction added by npub1m6k…zuz0 to bbbbbbbb…bbbb", + ); }); test("compacts only an included emoji already rendered as the node icon", () => { diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts index 51bba6c0102..a4927444974 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { parseConditionExpressions } from "./workflowConditionExpression"; import { TRIGGER_LABELS } from "./workflowFormTypes"; import type { ParsedConditionExpression } from "./workflowConditionExpression"; @@ -19,7 +19,9 @@ function authorReference( authorLoading?: boolean, ): string { if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL; - return authorLabel ?? truncatePubkey(condition.value); + // The trigger author is a pubkey identity; the unresolved fallback renders + // the npub compact, never raw hex. + return authorLabel ?? truncateNpub(condition.value); } function quotedValue(value: string): string { @@ -35,6 +37,8 @@ function messageReference( messageLoading?: boolean, ): string { if (messageLoading) return TRIGGER_MESSAGE_LOADING_LABEL; + // The referenced message is an event id, not a pubkey identity: it keeps + // the generic hex truncation. return messageLabel ? quotedValue(messageLabel) : truncatePubkey(condition.value); diff --git a/desktop/src/shared/lib/initials.test.mjs b/desktop/src/shared/lib/initials.test.mjs index 19e9f8dbcc8..634489b9245 100644 --- a/desktop/src/shared/lib/initials.test.mjs +++ b/desktop/src/shared/lib/initials.test.mjs @@ -19,4 +19,47 @@ describe("getInitials", () => { it("returns empty for a symbol-only name", () => { assert.equal(getInitials("()"), ""); }); + + it("derives key-label initials from the compact key’s visible tail, not the npub prefix", () => { + // Compact npub labels every start with npub1: the tail is the only + // fragment that distinguishes one key-identified identity from another. + assert.equal(getInitials("npub1z59…zwkg"), "ZW"); + assert.equal(getInitials("npub1m6k…zuz0"), "ZU"); + }); + + it("derives full-npub initials from the same tail fragment", () => { + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkg", + ), + "ZW", + ); + }); + + it("leaves authored names that merely resemble npubs on the name path", () => { + // Not a key-shaped label: wrong lengths, separators, or alphabet must + // keep the ordinary name derivation so an authored name is never + // re-derived as a key just for resembling one. + for (const [label, expected] of [ + ["Npub1 Person", "NP"], + ["npub1cool handle", "NH"], + // Compact-label shape with a missing data character. + ["npub1ab…wxy", "NW"], + // Compact-label shape over letters outside the bech32 alphabet. + ["npub1bio…biob", "NB"], + ]) { + assert.equal(getInitials(label), expected); + } + }); + + it("requires a checksum-valid npub before deriving key-tail initials", () => { + // Same length and alphabet as a real npub, but the checksum does not + // decode: an authored lookalike must stay on the name path. + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkq", + ), + "N", + ); + }); }); diff --git a/desktop/src/shared/lib/initials.ts b/desktop/src/shared/lib/initials.ts index 23dbf2ff93b..da5833086c0 100644 --- a/desktop/src/shared/lib/initials.ts +++ b/desktop/src/shared/lib/initials.ts @@ -1,5 +1,50 @@ +import { decode } from "nostr-tools/nip19"; + +/** + * Key-form labels carry no name to abbreviate: their head is the constant + * `npub1` prefix, so name-derived initials would collapse every key-identified + * identity onto the same leading letter. A key's distinguishing fragment is + * its tail — the part a compact key actually shows — so derive initials from + * there and keep key-fallback avatars visually distinct. + * + * Detection is deliberately narrow so authored names never lose their name + * initials for merely resembling a key: a full label counts as a key only + * when it decodes as a checksum-valid npub of an identity-length key, and a + * compact label must match the exact `npub` + 4 + `…` + 4 truncation shape + * over the bech32 alphabet (the form `truncateNpub` emits). A display name + * that is a checksum-valid npub is indistinguishable from a real key by + * shape alone, and taking key-tail initials there is the safe side of that + * boundary. + */ +const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const COMPACT_NPUB_KEY_LABEL = new RegExp( + `^npub1[${BECH32_ALPHABET}]{3}…[${BECH32_ALPHABET}]{4}$`, +); +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + +function isFullNpubLabel(label: string): boolean { + try { + const decoded = decode(label); + return decoded.type === "npub" && HEX_64_REGEX.test(decoded.data); + } catch { + return false; + } +} + +function keyTailInitials(name: string): string | null { + const trimmed = name.trim(); + if (!COMPACT_NPUB_KEY_LABEL.test(trimmed) && !isFullNpubLabel(trimmed)) { + return null; + } + return trimmed.slice(-4, -2).toUpperCase(); +} + /** Derive up to two uppercase initials from a display name. */ export function getInitials(name: string): string { + const keyInitials = keyTailInitials(name); + if (keyInitials !== null) { + return keyInitials; + } return name .replace(/[^\p{L}\p{N}\s]/gu, " ") .trim() diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs index 20e0f17eb0b..0f44d5131a2 100644 --- a/desktop/src/shared/lib/mentionDisplay.test.mjs +++ b/desktop/src/shared/lib/mentionDisplay.test.mjs @@ -1,21 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatMentionDisplayLabel } from "./mentionDisplay.ts"; -import { truncatePubkey } from "./pubkey.ts"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "./mentionDisplay.ts"; +import { truncateNpub, truncatePubkey } from "./pubkey.ts"; const KEY = `150b20bd${"a".repeat(52)}15dc`; +// npubEncode(KEY), pinned so a formatter regression cannot pass by +// re-deriving the expectation from the code under test. +const KEY_NPUB_COMPACT = "npub1z59…zwkg"; test("compact mention display uses the member-list formatter and keeps collision suffixes", () => { for (const suffix of ["", " 2", " 10"]) { assert.equal( formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), - `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + `Bad Janet (${truncateNpub(KEY)})${suffix}`, ); } - assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); + assert.equal(formatMentionDisplayLabel(KEY, KEY), KEY_NPUB_COMPACT); assert.equal( formatMentionDisplayLabel(KEY.toUpperCase(), KEY), - truncatePubkey(KEY.toUpperCase()), + KEY_NPUB_COMPACT, ); }); @@ -32,10 +38,23 @@ test("display leaves unbound, mismatched, malformed and ordinary labels literal" assert.equal(formatMentionDisplayLabel(label, key), label); }); +test("legacy display keeps the retired hex compaction byte-exact for clipboard validation", () => { + for (const suffix of ["", " 2", " 10"]) { + assert.equal( + formatLegacyMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), + `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + ); + } + assert.equal(formatLegacyMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); +}); + test("matching compact keys do not become identity keys", () => { const other = KEY.replace("aaaa", "bbbb"); assert.notEqual(KEY, other); - assert.equal( + // The two keys collide under the old hex first8…last4 truncation yet + // compact to distinguishable npubs: npub display keeps them apart. + assert.equal(truncatePubkey(KEY), truncatePubkey(other)); + assert.notEqual( formatMentionDisplayLabel(`Scout (${KEY})`, KEY), formatMentionDisplayLabel(`Scout (${other})`, other), ); diff --git a/desktop/src/shared/lib/mentionDisplay.ts b/desktop/src/shared/lib/mentionDisplay.ts index 10d15643ad4..ec8a199efad 100644 --- a/desktop/src/shared/lib/mentionDisplay.ts +++ b/desktop/src/shared/lib/mentionDisplay.ts @@ -1,17 +1,40 @@ -import { truncatePubkey } from "./pubkey"; +import { truncateNpub, truncatePubkey } from "./pubkey"; -/** Compact only a bound mention's key; its literal label remains authoritative. */ -export function formatMentionDisplayLabel( +type KeyCompaction = (key: string) => string; + +function compactMentionDisplayLabel( label: string, pubkey: string | undefined, + compactKey: KeyCompaction, ): string { if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label; if (label.toLowerCase() === pubkey.toLowerCase()) { - return truncatePubkey(label); + return compactKey(label); } const qualified = label.match( /^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i, ); if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label; - return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`; + return `${qualified[1]} (${compactKey(qualified[2])})${qualified[3]}`; +} + +/** Compact only a bound mention's key; its literal label remains authoritative. */ +export function formatMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncateNpub); +} + +/** + * The pre-npub key compaction a chip rendered before keys displayed as npub. + * Retired from rendering; kept byte-exact so clipboard validation can still + * recognize whole chips copied by an older Buzz, re-binding them to the exact + * identity their record declares instead of degrading them to plain text. + */ +export function formatLegacyMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncatePubkey); } diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 1b47bc4c92c..0398ea2ccd9 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -35,6 +35,17 @@ function fallbackColorClass(displayName: string) { type UserAvatarProps = { avatarUrl: string | null; displayName: string; + /** + * Label used to derive fallback initials; defaults to `displayName`. + * + * Callers whose `displayName` is a generated role-prefixed key fallback + * ("Agent npub1abcd…wxyz") pass the unprefixed compact key here: + * word-initials would collapse every unnamed identity onto "AN"/"PN", + * while the compact key keeps distinct key-tail initials. Authored + * display names keep their name initials. The fallback color keeps + * hashing `displayName`, which still contains the key. + */ + initialsLabel?: string; size?: UserAvatarSize; accent?: boolean; shape?: "circle" | "squircle"; @@ -47,6 +58,7 @@ type UserAvatarProps = { export function UserAvatar({ avatarUrl, displayName, + initialsLabel, size = "md", accent = false, shape, @@ -55,7 +67,7 @@ export function UserAvatar({ imageDraggable, testId, }: UserAvatarProps) { - const initials = getInitials(displayName); + const initials = getInitials(initialsLabel ?? displayName); // Animated avatars show their static poster frame until hovered, then play // the animation. const animated = parseAnimatedAvatarUrl(avatarUrl); diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 7a051743344..8977c1bc4c6 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -4,7 +4,7 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import { summarizeMessageLinkContent } from "@/features/messages/lib/messageLinkMetadata"; import { getEventById } from "@/shared/api/tauri"; import { getUserProfile } from "@/shared/api/tauriProfiles"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { isDefinitiveEventNotFound } from "@/shared/lib/eventLookupError"; const MESSAGE_METADATA_RETRY_DELAY_MS = 750; @@ -70,7 +70,7 @@ function fetchMetadata( author: profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(event.pubkey), + truncateNpub(event.pubkey), createdAt: event.created_at, snippet: summarizeMessageLinkContent(event.content), }; diff --git a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs index bf96caeee00..5f9fe6b1126 100644 --- a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs +++ b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { truncatePubkey } from "../lib/pubkey.ts"; +import { truncateNpub } from "../lib/pubkey.ts"; import { createMarkdownComponents } from "./markdown.tsx"; import { renderCachedMarkdown } from "./markdown/nodeCache.ts"; import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts"; @@ -33,7 +33,7 @@ for (const agent of [false, true]) { ); assert.equal( html.replace(/<[^>]+>/g, ""), - `Ask Scout (${truncatePubkey(KEY)}) 2`, + `Ask Scout (${truncateNpub(KEY)}) 2`, ); assert.ok(html.includes(`data-mention-label="${label}"`)); assert.ok(html.includes(`data-mention-pubkey="${KEY}"`)); diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index 0e6caac7762..59d46804615 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -344,6 +344,12 @@ test.describe("channel activity hover preview", () => { await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Charlie"); + // The authored name also supplies the row avatar's initials. + await expect( + popover + .getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`) + .getByText("C", { exact: true }), + ).toBeVisible(); await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Working"); @@ -372,6 +378,56 @@ test.describe("channel activity hover preview", () => { await expect(threadPanel).toContainText("direct thread link"); }); + test("unnamed working agents keep distinct key-tail initials, not AN", async ({ + page, + }) => { + // npubEncode of the fixture keys: "a"×64 → npub1424…rcaj (tail RC), + // "b"×64 → npub1hwa…04hu (tail 04). Neither has a seeded profile, so + // the rows show the generated "Agent npub1…" fallback label. + const unnamedAgents = [ + { initials: "RC", label: "Agent npub1424…rcaj", pubkey: "a".repeat(64) }, + { initials: "04", label: "Agent npub1hwa…04hu", pubkey: "b".repeat(64) }, + ]; + + await page.goto("/"); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown }) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + ); + for (const { pubkey } of unnamedAgents) { + await page.evaluate( + ({ agentPubkey, channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey, + channelId, + turnId: "unnamed-agent-initials", + }); + }, + { agentPubkey: pubkey, channelId: CHANNEL_GENERAL }, + ); + } + + const popover = await openActivityPopover(page); + for (const { initials, label, pubkey } of unnamedAgents) { + const row = popover.getByTestId(`channel-activity-agent-${pubkey}`); + await expect(row).toContainText(label); + // The avatar abbreviates its key's visible tail (UserAvatar's fallback + // settles after its 200ms delay; expect retries past it), never the + // "Agent" word initials of the prefixed label. + await expect(row.getByText(initials, { exact: true })).toBeVisible(); + await expect(row.getByText("AN", { exact: true })).toHaveCount(0); + } + }); + test("removes the dot and preview after the final activity is read", async ({ page, }) => { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f9fd6dca58b..371cffdfb0b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4176,8 +4176,26 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { await expect(memberRows.first()).toBeVisible(); expect(await memberRows.count()).toBeLessThan(50); + // Generated members have no display name, so the roster sorts them by + // their npub fallback label: these sequential pubkeys share a `npub1qqq…` + // prefix and order by the checksum tail, not their numeric value. Resolve + // a generated member from the rows the initial window actually rendered + // instead of assuming `pubkeys[0]` sorts into that window. + const generatedPubkeySet = new Set(pubkeys); + const renderedPubkeys = await memberRows.evaluateAll((rows) => + rows.map( + (row) => + (row as HTMLElement).dataset.testid?.slice("sidebar-member-".length) ?? + "", + ), + ); + const firstRenderedGeneratedPubkey = renderedPubkeys.find((pubkey) => + generatedPubkeySet.has(pubkey), + ); + expect(firstRenderedGeneratedPubkey).toBeTruthy(); + const firstGeneratedRow = memberList.getByTestId( - `sidebar-member-${pubkeys[0]}`, + `sidebar-member-${firstRenderedGeneratedPubkey}`, ); await expect .poll(() => @@ -4214,8 +4232,24 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); - await expect( - memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`), + // Fully scrolling must render the roster's true tail, and the tail + // endpoint must be known independently of whatever the virtual window + // happens to render. The sidebar's own roster accounting — the + // "Members · N" header — must read exactly the fixture-known total + // ("random" seeds alice, the mock identity, and bob; this test adds the + // 500 generated pubkeys on top), so fixture or classification drift + // fails loudly here instead of silently weakening the tail check. + const rosterCount = 3 + pubkeys.length; + await expect(memberList.getByText(/^Members · \d+$/)).toHaveText( + `Members · ${rosterCount}`, + ); + // VirtualizedList stamps each rendered row with its item index, so the + // final item's row is a fixed target that no window sample can pick in + // its place: a virtualizer clamped mid-roster never renders it. + await expect( + memberList.locator( + `[data-index="${rosterCount - 1}"] > [data-testid^="sidebar-member-"]`, + ), ).toBeVisible(); }); diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index 7c56648654a..32b4bcde56a 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -4,6 +4,7 @@ import { KIND_HUDDLE_ENDED, KIND_HUDDLE_STARTED, } from "../../src/shared/constants/kinds"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -11,6 +12,15 @@ const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111"; const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const HUDDLE_THREAD_ROOT_ID = "mock-general-welcome"; +/** + * Initials a key-fallback avatar shows: the two characters just before the + * compact npub label's visible tail — the fragment `getInitials` reads from + * a key label, whose unit suite pins the derivation itself. + */ +function keyTailInitials(pubkey: string) { + return truncateNpub(pubkey).slice(-4, -2).toUpperCase(); +} + async function waitForMockLiveSubscription( page: import("@playwright/test").Page, channelName: string, @@ -1075,10 +1085,19 @@ test("returns to the parent channel when leaving a huddle channel in view", asyn test("keeps the huddle avatar strip compact and exposes the full roster", async ({ page, }) => { - const members = Array.from({ length: 11 }, (_, index) => ({ + // One named member plus ten key-only members (hex 01..0a + "a"×62), with + // the last as the huddle bot. The generated keys' npub tails are what + // key-fallback avatars abbreviate: 01… → npub1qx4…x2tc → X2, + // 02… → npub1q24…levs → LE, 0a… → npub1p24…sqjt → SQ. + const namedMember = "77".repeat(32); + const unnamedMembers = Array.from({ length: 10 }, (_, index) => ({ pubkey: `${(index + 1).toString(16).padStart(2, "0")}${"a".repeat(62)}`, - role: index === 10 ? ("bot" as const) : ("member" as const), + role: index === 9 ? ("bot" as const) : ("member" as const), })); + const members = [ + { pubkey: namedMember, role: "member" as const }, + ...unnamedMembers, + ]; await installMockBridge(page, { huddle: { @@ -1086,6 +1105,7 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async ephemeralChannelId: HUDDLE_CHANNEL_ID, members, }, + searchProfiles: [{ pubkey: namedMember, displayName: "Ada Lovelace" }], }); await page.goto("/"); @@ -1100,13 +1120,44 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async await expect(participantTrigger).toContainText("+2"); await expect(page.getByTestId("profile-huddle-control")).toHaveCount(0); + // Strip avatars stay distinct: the named member keeps name initials + // (AL), and a key-only participant shows its npub's tail (X2), never the + // word initials of the "Participant npub1…" label (PN). + const stripAvatars = participantStrip.getByTestId( + "huddle-participant-avatar", + ); + await expect(stripAvatars.first()).toHaveText("AL"); + await expect(stripAvatars.nth(1)).toHaveText( + keyTailInitials(unnamedMembers[0].pubkey), + ); + await participantTrigger.click(); + const roster = page + .getByRole("dialog") + .filter({ has: page.getByRole("heading", { name: "Participants" }) }); await expect( page.getByRole("heading", { name: "Participants" }), ).toBeVisible(); + const rosterRows = roster.getByRole("listitem"); + // Visible labels keep their generated forms alongside the avatar initials. + await expect(rosterRows.filter({ hasText: "Ada Lovelace" })).toHaveCount(1); + await expect( + rosterRows.filter({ + hasText: `Participant ${truncateNpub(unnamedMembers[0].pubkey)}`, + }), + ).toHaveCount(1); + const agentRow = rosterRows.filter({ + hasText: `Agent ${truncateNpub(unnamedMembers[9].pubkey)}`, + }); + await expect(agentRow).toHaveCount(1); await expect( page.getByRole("button", { name: /Remove Agent .* from huddle/ }), ).toHaveCount(1); + // The roster's agent avatar abbreviates its key tail (SQ), never the + // "Agent" word initials of the prefixed label (AN). + await expect(agentRow.getByTestId("huddle-participant-avatar")).toHaveText( + keyTailInitials(unnamedMembers[9].pubkey), + ); }); test("removes an agent from its menu without showing an extra participant control", async ({ diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 0e29ed49327..0b81596780f 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { truncatePubkey } from "../../src/shared/lib/pubkey"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -566,7 +566,7 @@ for (const { kind, scale } of [ .last(); await assertFits(markdown, "sent"); const qualifiedChip = row.locator("[data-mention]").last(); - await expect(qualifiedChip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(qualifiedChip).toHaveText(`Scout (${truncateNpub(SECOND)})`); await expect(qualifiedChip).toHaveAttribute( "data-mention-label", `Scout (${SECOND})`, @@ -873,7 +873,7 @@ for (const mismatchedKey of [false, true]) { .getByTestId("message-row") .filter({ hasText: "qualified clipboard roundtrip" }) .locator(`[data-mention-pubkey="${SECOND}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(SECOND)})`); const flavors = await chip.evaluate((element) => { const range = document.createRange(); range.selectNode(element); @@ -956,7 +956,7 @@ for (const partial of [false, true]) { .filter({ hasText: "compact collision" }); for (const key of keys) { const chip = row.locator(`[data-mention-pubkey="${key}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(key)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(key)})`); await expect(chip).toHaveAttribute("title", `Scout (${key})`); const flavors = await chip.evaluate((element, partial) => { const range = document.createRange(); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e9248509fd1..ee3731d16d7 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { @@ -4535,9 +4535,8 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - // The panel's public key row renders through the shared widget, - // which displays the canonical npub form — assert the npub prefix. - await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); + await expect(panel).toContainText(truncateNpub(MOCK_VIEWER_PUBKEY)); + await expect(panel).not.toContainText("deadbeefdeadbeef"); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ @@ -4760,7 +4759,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ profilePopover.getByTestId( `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`, ), - ).toHaveText("managed by 11111111…1111"); + ).toHaveText(`managed by ${truncateNpub(CASEY_PROFILE_PUBKEY)}`); }); test("human profile popover does not show an owner", async ({ page }) => { diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 81ff86c3299..8dbdbad005f 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,12 +1,13 @@ import { expect, test } from "@playwright/test"; import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openNewMessagePage, TEST_IDENTITIES, } from "../helpers/bridge"; -import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/pubkey-display"; @@ -121,7 +122,7 @@ test("new-DM agent name swaps to its public key on name hover", async ({ ) .toBe(true); await expect(settledAgentNpub).not.toHaveCSS("opacity", "0"); - await expect(settledAgentNpub).toHaveText("cafef00d…f00d"); + await expect(settledAgentNpub).toHaveText(truncateNpub(AGENT_PUBKEY)); await expect( settledAgentName.getByText("Pinky", { exact: true }), ).not.toHaveCSS("opacity", "1"); @@ -159,7 +160,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await page.mouse.move(1_100, 500); await expect(charlieNpub).toHaveCSS("opacity", "0"); @@ -262,7 +263,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await expect(charlieName.getByText("charlie", { exact: true })).toHaveCSS( "opacity", diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..bf3aca2e2d2 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -63,7 +64,9 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Workflow notification"); } async function createEnabled( @@ -152,7 +155,7 @@ test("inserts template variables with keyboard control and restores the caret", await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - const textarea = dialog.getByLabel("Message text"); + const textarea = dialog.locator('textarea[id^="wf-step-"][id$="-text"]'); const listbox = page.getByRole("listbox"); await textarea.fill("Hello {{trig"); await expect(listbox).toBeVisible(); @@ -286,6 +289,7 @@ test("round-trips and reopens structured message-text conditions", async ({ await dialog.getByRole("tab", { name: "Form" }).click(); await openTriggerInspector(dialog); + await waitForAnimations(page); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); @@ -640,11 +644,11 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await authorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await authorSearch.press("Enter"); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled(); await dialog.getByRole("tab", { name: "YAML" }).click(); @@ -663,7 +667,7 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await correctedAuthorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await correctedAuthorSearch.press("Enter"); await dialog @@ -697,7 +701,7 @@ test("round-trips manual author and reaction message IDs through save and reopen await openTriggerInspector(reopened); await reopened.getByText("Author", { exact: true }).locator("..").click(); await expect( - reopened.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + reopened.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await reopened.getByText("Message", { exact: true }).locator("..").click(); await expect( @@ -738,7 +742,7 @@ test("toggles selected author and message filters while preserving sibling condi .locator(".."); await authorField.getByText("Author", { exact: true }).locator("..").click(); const authorOption = dialog.getByRole("option", { - name: new RegExp(author.slice(0, 8)), + name: truncateNpub(author), }); await expect(authorOption).toHaveAttribute("aria-selected", "true"); await expect( @@ -751,7 +755,7 @@ test("toggles selected author and message filters while preserving sibling condi }); await authorSearch.fill(replacementAuthor); const replacementAuthorOption = dialog.getByRole("option", { - name: new RegExp(replacementAuthor.slice(0, 8)), + name: truncateNpub(replacementAuthor), }); await expect(replacementAuthorOption).toBeVisible(); await authorSearch.press("Enter"); diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png b/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png index 54cc63d5b2a..f0826431ce6 100644 Binary files a/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png and b/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png differ