diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 79a580211..a95d52506 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -10,6 +10,7 @@ import { allowsOutsideMentions, archivedMention, mentionAdmission, + pastedMentionRecipient, } from "./mention-admission"; import { rememberMention } from "./mention-history"; import { @@ -1392,25 +1393,20 @@ function Composer({ }} onFormatsChange={setActiveFormats} onEditLink={setLinkEdit} - // Pasted identity links notify the people the picker would offer, - // under their current names; edits never add recipients. In a - // stream or forum, a cached profile outside the channel counts as - // a picked directory row, so Send asks before adding them. The - // pasted label is never the name: it could disguise the invitee. - acceptRecipient={(pubkey) => { - if (editing.target) return null; - const name = session.profiles.snapshot().get(pubkey)?.name; - return ( - mentionCandidates( - session, - channelId, - agentChoices, - mentionRoster, - name ? [{ pubkey, name }] : [], - ).find((c) => c.recipient.pubkey === pubkey)?.recipient ?? - null - ); - }} + // Pasted identity links notify admitted recipients under their + // current names; edits never add recipients. Send still asks + // before adding someone outside the channel. + acceptRecipient={(pubkey) => + editing.target + ? null + : pastedMentionRecipient( + session, + channelId, + pubkey, + agentChoices, + mentionRoster, + ) + } data-single-emoji={largeEmojiDraft || undefined} maxLength={16000} aria-label={label} diff --git a/src/features/messages/mention-admission.test.ts b/src/features/messages/mention-admission.test.ts index e739a92ac..54660745a 100644 --- a/src/features/messages/mention-admission.test.ts +++ b/src/features/messages/mention-admission.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; import type { RelaySession } from "../relay/session"; -import { mentionAdmission } from "./mention-admission"; +import { mentionAdmission, pastedMentionRecipient } from "./mention-admission"; const member = "a".repeat(64); const outside = "b".repeat(64); @@ -84,3 +84,28 @@ it("refuses invalid keys, archived identities and closed destinations", () => { )(member), ).toBe(false); }); + +it("names a pasted recipient from current state, and leaves an unknown outsider display-only", () => { + const base = session({ channelType: "stream" }); + const profiles = new Map([[outside, { name: "Jane" }]]); + const withProfiles = { + ...base, + profiles: { snapshot: () => profiles }, + } as unknown as RelaySession; + expect(pastedMentionRecipient(withProfiles, "channel", outside)).toEqual({ + pubkey: outside, + name: "Jane", + }); + expect(pastedMentionRecipient(withProfiles, "channel", member)).toEqual({ + pubkey: member, + name: member.slice(0, 12), + }); + expect(pastedMentionRecipient(withProfiles, "channel", library)).toEqual({ + pubkey: library, + name: "Honey", + }); + expect(pastedMentionRecipient(withProfiles, "channel", "f".repeat(64))).toBe( + null, + ); + expect(pastedMentionRecipient(withProfiles, "channel", archived)).toBe(null); +}); diff --git a/src/features/messages/mention-admission.ts b/src/features/messages/mention-admission.ts index 929f4006f..d3b5e50c8 100644 --- a/src/features/messages/mention-admission.ts +++ b/src/features/messages/mention-admission.ts @@ -48,3 +48,35 @@ export function mentionAdmission( !archivedMention(session, pubkey) && (outside || allowed.has(pubkey)); } + +/** + * The recipient a pasted identity link adds, under its current name, or null. + * The pasted label is never the name: it could disguise the invitee. A key + * with no known name is accepted only if it is a member; otherwise the link + * stays display-only, as the chooser would not offer it. + */ +export function pastedMentionRecipient( + session: RelaySession, + channelId: string, + pubkey: string, + inviteAgents = false, + roster?: readonly MentionRecipient[], +): MentionRecipient | null { + if (!mentionAdmission(session, channelId, inviteAgents, roster)(pubkey)) + return null; + const listed = roster?.find((person) => person.pubkey === pubkey); + const name = + session.profiles.snapshot().get(pubkey)?.name || + listed?.name || + session.agentChoices + .snapshot() + .identities.find((agent) => agent.pubkey === pubkey)?.name; + if (name) return { pubkey, name }; + const member = roster + ? !!listed + : !!session.channels + .list() + .channels.find((c) => c.id === channelId) + ?.members?.includes(pubkey); + return member ? { pubkey, name: pubkey.slice(0, 12) } : null; +}