From b07b436cb17775b95e8550274ccc87f9fb3185ce Mon Sep 17 00:00:00 2001 From: anonwurcod Date: Sun, 2 Aug 2026 15:52:14 -0400 Subject: [PATCH] fix(desktop): encode resolved mentions as NIP-27 Nostr URIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store resolved @mentions as stable nostr:npub1… references on send and edit so renames cannot break historical mentions, and materialize inbound nostr:npub1…/nprofile1… prose into mention chips with current profile names even when no p-tag is present. Closes #2319 Signed-off-by: anonwurcod --- .../src/features/messages/lib/hasMention.ts | 342 ++++++++++++++++++ .../features/messages/ui/MessageComposer.tsx | 30 +- .../src/features/messages/ui/MessageRow.tsx | 83 ++++- .../messages/ui/useMentionSendFlow.ts | 36 +- 4 files changed, 482 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index 5e5bafd8ad..8c1f17f6b2 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -1,3 +1,5 @@ +import { decode } from "nostr-tools/nip19"; + /** * Escape special regex characters in a string. */ @@ -153,3 +155,343 @@ export function getMentionOffset(text: string, name: string): number | null { export function hasMention(text: string, name: string): boolean { return getMentionOffset(text, name) !== null; } + +// --------------------------------------------------------------------------- +// NIP-27 outbound encoding and inbound materialization +// --------------------------------------------------------------------------- + +type Range = { start: number; end: number }; + +/** + * Build an array of character ranges that must not be transformed by + * @-mention substitution: inline/fenced/indented code spans, Markdown link + * destinations (the URL inside `](...)`), existing `nostr:` URIs (so + * already-encoded references are not re-encoded), email-like tokens whose `@` + * is not a mention trigger, and backslash-escaped `\@` literals. + */ +function buildProtectedRanges(text: string, protectNostr = true): Range[] { + const ranges: Range[] = []; + const masked = maskMarkdownCode(text); + + // Code spans and blocks — maskMarkdownCode replaces non-space, non-newline + // chars inside code with spaces. Detect contiguous such runs. + let codeRangeStart = -1; + for (let i = 0; i <= text.length; i++) { + const isCodeChar = + i < text.length && + masked[i] === " " && + text[i] !== " " && + text[i] !== "\n" && + text[i] !== "\r"; + if (isCodeChar && codeRangeStart === -1) { + codeRangeStart = i; + } else if (!isCodeChar && codeRangeStart !== -1) { + ranges.push({ start: codeRangeStart, end: i }); + codeRangeStart = -1; + } + } + + let m: RegExpExecArray | null; + + // Markdown link destinations: [text](URL) — protect from `(` to closing `)` + // so that an `@name` appearing in a URL is not substituted. + const linkDestRe = /\]\(([^)\n]*)\)/g; + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while ((m = linkDestRe.exec(text)) !== null) { + // Protect the `(URL)` portion (index of `]` + 1 through the `)`) + ranges.push({ start: m.index + 1, end: m.index + m[0].length }); + } + + // Existing nostr: URIs — protect them wholesale so already-encoded + // references are not re-encoded and non-profile entities are left unchanged. + const nostrRe = /nostr:[a-zA-Z0-9]+/g; + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while (protectNostr && (m = nostrRe.exec(text)) !== null) { + ranges.push({ start: m.index, end: m.index + m[0].length }); + } + + // Email-like tokens: local@domain.tld — the `@` is not a mention trigger. + const emailRe = /[^\s@()*_|\\[\]]+@[^\s@()*_|\\[\]]+\.[^\s@()*_|\\[\]\s]+/g; + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while ((m = emailRe.exec(text)) !== null) { + ranges.push({ start: m.index, end: m.index + m[0].length }); + } + + // Backslash-escaped @ signs: \@ is an escaped literal, not a mention trigger. + const escapedAtRe = /\\@/g; + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while ((m = escapedAtRe.exec(text)) !== null) { + ranges.push({ start: m.index, end: m.index + 2 }); + } + + return ranges; +} + +/** True if the half-open interval [start, end) overlaps any range in `set`. */ +function overlapsRanges(start: number, end: number, set: Range[]): boolean { + for (const r of set) { + if (start < r.end && end > r.start) return true; + } + return false; +} + +/** + * Returns true when `ch` is a character that may validly precede an @mention + * in Buzz prose: start-of-string, whitespace, `(`, or a Markdown/spoiler + * delimiter (`*`, `_`, `|`). + */ +function isValidMentionPrecursor(ch: string | undefined): boolean { + if (ch === undefined) return true; + return ( + ch === " " || + ch === "\t" || + ch === "\n" || + ch === "\r" || + ch === "(" || + ch === "*" || + ch === "_" || + ch === "|" + ); +} + +/** + * Returns true when `ch` is a character that may validly follow an @mention + * in Buzz prose: end-of-string, whitespace, or common punctuation/delimiters. + */ +function isValidMentionSuccessor(ch: string | undefined): boolean { + if (ch === undefined) return true; + return ( + ch === " " || + ch === "\t" || + ch === "\n" || + ch === "\r" || + ch === "," || + ch === ";" || + ch === "." || + ch === "!" || + ch === "?" || + ch === ":" || + ch === ")" || + ch === "]" || + ch === "}" || + ch === "*" || + ch === "_" || + ch === "|" + ); +} + +/** + * Replace every resolved @mention in `text` with its canonical NIP-27 + * `nostr:npub1…` reference. `mentionMap` maps display names (exactly as the + * user typed them after autocomplete resolution) to their replacement strings + * (e.g. `"Alice" → "nostr:npub1abc…"`). + * + * Protected zones — code spans, fenced and indented blocks, Markdown link + * destinations, existing `nostr:` URIs, email-like tokens, and + * backslash-escaped `@` signs — are never modified. Longer display names + * take priority over shorter ones so a match for "Alice Smith" prevents the + * prefix "Alice" from matching separately. The operation is idempotent: + * already-encoded references land in the protected set and are not re-encoded. + */ +export function substituteResolvedMentions( + text: string, + mentionMap: ReadonlyMap, +): string { + if (mentionMap.size === 0) return text; + + const protectedRanges = buildProtectedRanges(text); + + // Longer names match before shorter ones to avoid partial substitution + // of a shorter name that is a prefix of a longer resolved name. + const sortedEntries = [...mentionMap.entries()].sort( + ([a], [b]) => b.length - a.length, + ); + + type Edit = { start: number; end: number; replacement: string }; + const edits: Edit[] = []; + // Ranges already claimed by a longer-name match; prevents re-substitution + // of their interior by a shorter name. + const claimedRanges: Range[] = []; + + for (const [name, replacement] of sortedEntries) { + const trimmedName = name.trim(); + if (!trimmedName) continue; + const nameLower = trimmedName.toLowerCase(); + const nameLen = trimmedName.length; + + let pos = 0; + while (pos < text.length) { + const atIdx = text.indexOf("@", pos); + if (atIdx === -1) break; + + // Skip @ signs that fall inside a protected range. + if (overlapsRanges(atIdx, atIdx + 1, protectedRanges)) { + pos = atIdx + 1; + continue; + } + + // Word-boundary: the character before @ must be a valid precursor. + const precursor = atIdx > 0 ? text[atIdx - 1] : undefined; + if (!isValidMentionPrecursor(precursor)) { + pos = atIdx + 1; + continue; + } + + // Case-insensitive name match immediately after the @. + if ( + text.slice(atIdx + 1, atIdx + 1 + nameLen).toLowerCase() !== nameLower + ) { + pos = atIdx + 1; + continue; + } + + const occEnd = atIdx + 1 + nameLen; + + // Word-boundary: the character after the name must be a valid successor. + const successor = occEnd < text.length ? text[occEnd] : undefined; + if (!isValidMentionSuccessor(successor)) { + pos = atIdx + 1; + continue; + } + + // Reject occurrences that span into a protected or already-claimed range. + if ( + overlapsRanges(atIdx, occEnd, protectedRanges) || + overlapsRanges(atIdx, occEnd, claimedRanges) + ) { + pos = occEnd; + continue; + } + + edits.push({ start: atIdx, end: occEnd, replacement }); + claimedRanges.push({ start: atIdx, end: occEnd }); + pos = occEnd; + } + } + + if (edits.length === 0) return text; + + // Apply right-to-left so earlier offsets remain valid after each splice. + edits.sort((a, b) => b.start - a.start); + let result = text; + for (const edit of edits) { + result = + result.slice(0, edit.start) + edit.replacement + result.slice(edit.end); + } + return result; +} + +/** + * Decode a `nostr:npub1…` or `nostr:nprofile1…` URI to a lowercase hex + * public key. Returns `null` for non-profile NIP-21 entities (nevent, note, + * naddr, …), malformed bech32 strings, or URIs that do not start with + * `nostr:`. + */ +export function decodeNostrProfilePubkey(uri: string): string | null { + if (!uri.startsWith("nostr:")) return null; + const encoded = uri.slice(6); + if (!encoded.startsWith("npub1") && !encoded.startsWith("nprofile1")) { + // Non-profile NIP-21 entity — leave unchanged. + return null; + } + try { + const decoded = decode(encoded); + if (decoded.type === "npub") { + return typeof decoded.data === "string" + ? decoded.data.toLowerCase() + : null; + } + if (decoded.type === "nprofile") { + const data = decoded.data as { pubkey: string }; + return typeof data.pubkey === "string" ? data.pubkey.toLowerCase() : null; + } + return null; + } catch { + return null; + } +} + +/** + * Replace `nostr:npub1…` / `nostr:nprofile1…` profile references in prose + * with `@` mention chips, using the current identity known to + * the caller. Code spans, fenced/indented blocks, and Markdown link + * destinations are left unchanged. Non-profile NIP-21 entities and malformed + * references remain as plain text. + * + * Returns: + * - `body`: the transformed text, with each valid profile URI replaced by + * `@` so the Markdown renderer treats it as a mention chip. + * - `nameToHexPubkey`: a map from every substituted display name to the + * corresponding lowercase hex public key, ready to be merged into the + * renderer's `mentionPubkeysByName` so chips resolve to profile popovers + * and open the correct decoded public key on click. + */ +export function materializeInboundProfiles( + text: string, + getDisplayName: (hexPubkey: string) => string | null, +): { body: string; nameToHexPubkey: Map } { + const nameToHexPubkey = new Map(); + + // Match nostr:npub1... and nostr:nprofile1... tokens anywhere in prose. + const nostrProfileRe = /nostr:(?:npub1|nprofile1)[a-zA-Z0-9]+/g; + const protectedRanges = buildProtectedRanges(text, false); + + type Edit = { start: number; end: number; replacement: string }; + const edits: Edit[] = []; + let m: RegExpExecArray | null; + + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while ((m = nostrProfileRe.exec(text)) !== null) { + const start = m.index; + const end = m.index + m[0].length; + + // Skip references that appear inside code spans or link destinations. + if (overlapsRanges(start, end, protectedRanges)) continue; + + const pubkey = decodeNostrProfilePubkey(m[0]); + if (!pubkey) continue; + + const displayName = getDisplayName(pubkey); + if (!displayName) continue; + + // Last display name wins when the same pubkey appears multiple times; + // all occurrences use the same replacement string so this is safe. + nameToHexPubkey.set(displayName, pubkey); + edits.push({ start, end, replacement: `@${displayName}` }); + } + + if (edits.length === 0) return { body: text, nameToHexPubkey }; + + // Apply right-to-left to preserve earlier character offsets. + edits.sort((a, b) => b.start - a.start); + let body = text; + for (const edit of edits) { + body = body.slice(0, edit.start) + edit.replacement + body.slice(edit.end); + } + return { body, nameToHexPubkey }; +} + +/** + * Extract the set of lowercase hex public keys that are referenced by + * `nostr:npub1…` or `nostr:nprofile1…` tokens in prose text. Tokens that + * appear inside code spans, fenced/indented code blocks, or Markdown link + * destinations are excluded. Duplicates are removed. This is the companion + * to `materializeInboundProfiles`: call it first to know which profiles to + * hydrate, then call `materializeInboundProfiles` once the profile data is + * available. + */ +export function extractNostrProfilePubkeys(text: string): string[] { + const nostrProfileRe = /nostr:(?:npub1|nprofile1)[a-zA-Z0-9]+/g; + const protectedRanges = buildProtectedRanges(text, false); + const pubkeys: string[] = []; + let m: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: scan loop + while ((m = nostrProfileRe.exec(text)) !== null) { + const start = m.index; + const end = m.index + m[0].length; + if (overlapsRanges(start, end, protectedRanges)) continue; + const pubkey = decodeNostrProfilePubkey(m[0]); + if (pubkey && !pubkeys.includes(pubkey)) pubkeys.push(pubkey); + } + return pubkeys; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..74c4725876 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -23,8 +23,10 @@ import { import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { substituteResolvedMentions } from "@/features/messages/lib/hasMention"; import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { useIdentityQuery } from "@/shared/api/hooks"; import { hasMentionClipboardHtml, @@ -188,6 +190,7 @@ function MessageComposerImpl({ const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); const editTargetRef = React.useRef(editTarget); const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys); + const getMentionDisplayNameRef = React.useRef(mentions.getMentionDisplayName); const ownerPubkeyRef = React.useRef(ownerPubkey); disabledRef.current = disabled; isSendingRef.current = isSending; @@ -197,6 +200,7 @@ function MessageComposerImpl({ onEditLastOwnMessageRef.current = onEditLastOwnMessage; editTargetRef.current = editTarget; extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; + getMentionDisplayNameRef.current = mentions.getMentionDisplayName; ownerPubkeyRef.current = ownerPubkey; const isAutocompleteOpenRef = React.useRef(false); @@ -517,12 +521,29 @@ function MessageComposerImpl({ // attachments) flows through to onEditSave as empty content, which // deletes the message instead of publishing it (see handleEditSave). + // Build the NIP-27 substitution map for resolved @mentions before + // encoding the outgoing body. Scan `trimmed` (pre-substitution) so + // mention pubkey extraction still works against @-name patterns, then + // apply the substitution to the content that is sent. + const editMentionPubkeys = extractMentionPubkeysRef.current(trimmed); + const editNpubMap = new Map(); + for (const pubkey of editMentionPubkeys) { + const name = getMentionDisplayNameRef.current(pubkey); + if (!name) continue; + const npub = safeNpub(pubkey); + if (npub) editNpubMap.set(name, `nostr:${npub}`); + } + const substitutedEditBody = substituteResolvedMentions( + trimmed, + editNpubMap, + ); + // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` // because edit semantics use `[]` as the explicit "wipe all // attachments" signal — the receiver overlay drops imeta when the // edit carries an empty (but defined) set. const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, + substitutedEditBody, currentPendingImeta, spoileredAttachmentUrls, ); @@ -540,11 +561,12 @@ function MessageComposerImpl({ // Notify only mentions this edit *newly adds* (see // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. + // unchanged emits no `p` tags and re-wakes nobody. Use pubkeys extracted + // from the pre-substitution `trimmed` so @-name scanning still works + // after the final body has been NIP-27 encoded. const addedMentionPubkeys = diffAddedMentionPubkeys( extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), + editMentionPubkeys, ownerPubkeyRef.current ?? "", ); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 286526b658..1d415f8cfb 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -38,6 +38,11 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; +import { + extractNostrProfilePubkeys, + materializeInboundProfiles, +} from "@/features/messages/lib/hasMention"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; @@ -185,10 +190,80 @@ export const MessageRow = React.memo( }, [channelId, openReminder], ); - const { mentionNames, mentionPubkeysByName } = React.useMemo( - () => resolveMentionProps(message.tags, profiles), - [profiles, message.tags], + // Extract nostr:npub1.../nostr:nprofile1... pubkeys from the message body + // so we can trigger profile hydration even when no `p` tag supplies them. + const inboundProfilePubkeys = React.useMemo( + () => extractNostrProfilePubkeys(message.body), + [message.body], ); + + // Batch-fetch profiles for body-embedded profile references that are not + // covered by the existing tag-based profile lookup passed via `profiles`. + const inboundProfilesQuery = useUsersBatchQuery(inboundProfilePubkeys, { + enabled: inboundProfilePubkeys.length > 0, + }); + + // Merge the tag-based profile lookup with any freshly fetched profiles for + // body-embedded references. This remains stable when neither source changes. + const effectiveProfiles = React.useMemo(() => { + const extra = inboundProfilesQuery.data?.profiles; + if (!extra) return profiles; + return { ...profiles, ...extra }; + }, [profiles, inboundProfilesQuery.data?.profiles]); + + // Resolve tag-based mention names, then overlay decoded nostr:npub.../ + // nostr:nprofile... references from the body using current profile identity. + const { mentionNames, mentionPubkeysByName, displayBody } = + React.useMemo(() => { + const tagProps = resolveMentionProps(message.tags, effectiveProfiles); + + const { body: materialized, nameToHexPubkey } = + materializeInboundProfiles(message.body, (pubkey) => { + const profile = effectiveProfiles?.[pubkey]; + if (!profile) return null; + return ( + profile.displayName?.trim() || + profile.name?.trim() || + profile.nip05Handle?.split("@")[0]?.trim() || + null + ); + }); + + if (nameToHexPubkey.size === 0) { + return { ...tagProps, displayBody: message.body }; + } + + // Build a lowercase-keyed name→pubkey map for the chip renderer. + const extraByName = Object.fromEntries( + [...nameToHexPubkey.entries()].map(([name, pubkey]) => [ + name.toLowerCase(), + pubkey, + ]), + ); + // Merge: existing tag names take precedence; decoded profile names fill in + // references that had no corresponding p/mention tag. + const existingNamesLower = new Set( + (tagProps.mentionNames ?? []).map((n) => n.toLowerCase()), + ); + const extraNames = [...nameToHexPubkey.keys()].filter( + (n) => !existingNamesLower.has(n.toLowerCase()), + ); + const mergedNames = + (tagProps.mentionNames ?? []).length > 0 || extraNames.length > 0 + ? [...(tagProps.mentionNames ?? []), ...extraNames] + : undefined; + const mergedPubkeysByName = + tagProps.mentionPubkeysByName !== undefined || + Object.keys(extraByName).length > 0 + ? { ...tagProps.mentionPubkeysByName, ...extraByName } + : undefined; + + return { + mentionNames: mergedNames, + mentionPubkeysByName: mergedPubkeysByName, + displayBody: materialized, + }; + }, [effectiveProfiles, message.tags, message.body]); // "Is this pubkey an agent" = the community-scoped baseline every surface // shares (managed ∪ relay) plus the pubkey's own profile `isAgent` flag from this surface's lookup. Both are per-pubkey // O(1) checks — no per-row rescan of `profiles` (that duplicated parent @@ -371,7 +446,7 @@ export const MessageRow = React.memo( message, isKnownAgentPubkey, )} - content={message.body} + content={displayBody} customEmoji={customEmoji} imetaByUrl={imetaByUrl} agentMentionPubkeysByName={agentMentionPubkeysByName} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..225863ceff 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,6 +1,8 @@ import * as React from "react"; import { toast } from "sonner"; +import { substituteResolvedMentions } from "@/features/messages/lib/hasMention"; +import { safeNpub } from "@/shared/lib/nostrUtils"; import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, @@ -110,6 +112,28 @@ type UseMentionSendFlowOptions = { resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; +/** + * Build a map from resolved display name → `nostr:npub1…` replacement string + * for every @mention that autocomplete resolved to a public key in `text`. + * Used to perform the NIP-27 outbound substitution before the message is sent. + */ +function buildMentionNpubMap( + text: string, + extractMentionPubkeys: (t: string) => string[], + getMentionDisplayName: (pubkey: string) => string | null, +): Map { + const pubkeys = extractMentionPubkeys(text); + const map = new Map(); + for (const pubkey of pubkeys) { + const name = getMentionDisplayName(pubkey); + if (!name) continue; + const npub = safeNpub(pubkey); + if (!npub) continue; + map.set(name, `nostr:${npub}`); + } + return map; +} + function mergeOutgoingTagsWithReferenceMentions( outgoingTags: string[][] | undefined, pubkeys: Iterable, @@ -703,8 +727,17 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( + // Substitute resolved @mentions with canonical nostr:npub1… references + // before building the outgoing message body. Routing tags are derived + // from the original @-mention scan so p-tag notification semantics are + // unaffected by the NIP-27 encoding. + const npubMap = buildMentionNpubMap( trimmed, + mentions.extractMentionPubkeys, + mentions.getMentionDisplayName, + ); + const { content: finalContent, mediaTags } = buildOutgoingMessage( + substituteResolvedMentions(trimmed, npubMap), pendingImeta, spoileredAttachmentUrls, ); @@ -773,6 +806,7 @@ export function useMentionSendFlow({ getNonMemberMentionPubkeys, getDmThreadAgentMentionError, mentions.extractMentionPubkeys, + mentions.getMentionDisplayName, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, onPrepareSendChannel,