Skip to content

fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction#905

Merged
wpfleger96 merged 7 commits into
mainfrom
paul/fix-multiword-mentions
Jun 8, 2026
Merged

fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction#905
wpfleger96 merged 7 commits into
mainfrom
paul/fix-multiword-mentions

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes @mention resolution for multi-word display names (e.g. "Will Pfleger") in the CLI send pipeline, removes false-positive mention rendering in the desktop app, and adds NIP-27 nostr:npub1... pubkey mention extraction with markdown-aware escaping.

Multi-word mention fix

The CLI extract_at_names() tokenized on whitespace, so @Will Pfleger only extracted "will" — which never matched the full display name. The desktop renderer then fell back to a generic @\S+ regex, highlighting partial text like "@will" as if it were a valid mention even without a p-tag.

  • Add extract_at_mentions_with_known() to sprout-sdk/mentions.rs — two-pass extractor that tries known member names longest-first (case-insensitive, word-boundary-checked), then falls back to single-word tokenization for unknown names
  • Update resolve_content_mentions() in the CLI to extract display names from fetched profiles and pass them into the new two-pass extractor
  • Replace the @\S+ fallback in buildPrefixPattern() with a never-matching regex — only p-tagged members get mention styling
  • Guard against UTF-8 boundary panics when known name byte-length lands mid-character in multi-byte content (use get() instead of direct slice)

NIP-27 nostr:npub mention extraction

Adds support for mentioning users via nostr:npub1... URIs in message content, per NIP-27. The pubkey is extracted and added as a p tag alongside any @name mentions.

  • Add strip_code_regions() — removes fenced code blocks and inline code spans before scanning, so nostr:npub1... inside backticks or code blocks is treated as literal text (not a mention)
  • Add extract_nostr_uris() — regex matches nostr:npub1<58 bech32 chars>, decodes via the existing nostr crate, deduplicates
  • Integrate into the mention pipeline via merge_mentions() — union of @name and nostr:npub pubkeys, deduplicated
  • Stored content keeps nostr:npub1... verbatim for UI rendering as mention pills

Tests

26 new tests covering multi-word names, longest-first priority, deduplication, punctuation boundaries, Unicode safety, NIP-27 extraction, code-block escaping, invalid bech32 handling, and cross-method deduplication.

@will

will commented Jun 8, 2026 via email

Copy link
Copy Markdown

@wpfleger96 wpfleger96 marked this pull request as ready for review June 8, 2026 17:12
@wpfleger96 wpfleger96 requested a review from a team as a code owner June 8, 2026 17:12
The CLI mention resolver stopped at the first space when scanning for
@NAMEs, so '@will Pfleger' only extracted 'will' — which never matched
the profile display_name 'Will Pfleger'. Result: no p-tag, no notification.

**Root cause (sprout-sdk/mentions.rs)**
extract_at_names() tokenizes on alphanumeric+._- chars only, so it can
never produce a multi-word token. match_names_to_profiles() then does a
full-string equality check against the profile display_name, so a
single-word extract like 'will' never matches 'Will Pfleger'.

**Fix 1 — new two-pass extractor (sprout-sdk/mentions.rs)**
Add extract_at_mentions_with_known(content, known_names):
- Sort known_names longest-first (so 'Will Pfleger' beats 'Will')
- At each @ token, try each known name case-insensitively with a
  word-boundary check (whitespace / punctuation / EOS)
- Fall back to the existing single-word tokenizer for unmatched @ tokens
  so @alice still works even when profiles are loading
- Deduplicate, preserve first-seen order

**Fix 2 — wire it into the CLI (sprout-cli/messages.rs)**
resolve_content_mentions() now extracts display_names from the fetched
profiles first, passes them into extract_at_mentions_with_known, then
maps matched names back to pubkeys via match_names_to_profiles. The
early-exit guard is also tightened to skip I/O when content has no '@'.

**Fix 3 — remove false-positive mention rendering (desktop)**
buildPrefixPattern() previously fell back to prefix+\S+ when no known
names were available, causing '@will' to render as a blue mention even
though no user named 'Will' exists. Changed the empty-names branch to
return a never-matching regex (/(?!)/gi) so only text backed by an
actual p-tagged member gets mention styling.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
The two-pass extractor sliced after_at by byte offset (known.len()) which
can land mid-character when content contains multi-byte UTF-8 (CJK, emoji).
Replace panicking &after_at[..n] with after_at.get(..n) which returns None
on invalid boundaries, gracefully skipping the candidate.

Also fix clippy: sort_by → sort_by_key(Reverse) for the longest-first sort.

Add three Unicode-specific tests to prevent regression.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
remarkMentions.ts and remarkChannelLinks.ts still referenced the old
'falls back to generic pattern' behavior that was removed in the
mentionPattern.ts change.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
buildPrefixPattern() now accepts an optional { fallbackToGeneric } flag.
remarkChannelLinks passes it so #channel links still render while channel
names load asynchronously. remarkMentions/buildMentionPattern do not, so
@word patterns without a matching p-tag are never highlighted as mentions.

Fixes CI regression where the smoke test timed out waiting for the
'Open channel' button on a #general link.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
The char-indexed scan allocated a new String at every '@' and collected
the full content to Vec<char> upfront. Replace with str::match_indices
+ str::get() slicing — zero intermediate allocations, half the lines.
Merge the two-phase profile parse in the CLI into a single pass that
builds a name→pubkey HashMap, removing the MentionProfile indirection
and the duplicated display_name/name fallback logic. Trim verbose doc
comments to contract-only summaries.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
@wpfleger96 wpfleger96 force-pushed the paul/fix-multiword-mentions branch from 0a1be8d to 8e99d6f Compare June 8, 2026 21:23
@wpfleger96 wpfleger96 changed the title fix: resolve multi-word display names in @mention pipeline fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction Jun 8, 2026
…re escaping

Add strip_code_regions() to remove fenced code blocks and inline code
spans before scanning, and extract_nostr_uris() to decode nostr:npub1...
URIs into hex pubkeys. Uses the nostr crate's PublicKey::from_bech32()
for decoding — no new dependencies needed.

Callers run extract_nostr_uris on strip_code_regions(content) and merge
the resulting pubkeys into the existing p-tag set via merge_mentions,
which handles deduplication against @name-resolved pubkeys.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
@wpfleger96 wpfleger96 force-pushed the paul/fix-multiword-mentions branch from 8e99d6f to 0d35331 Compare June 8, 2026 22:00
@wpfleger96

Copy link
Copy Markdown
Collaborator Author

Mention rendering verification

Valid mention (p-tagged): @alice with a p-tag renders as a styled mention chip.

01-mention-with-ptag

False positive eliminated: @SomeUser without a p-tag renders as plain text (previously highlighted by the @\S+ fallback).

02-mention-no-ptag

#channel link preserved: #random still renders as a channel link via fallbackToGeneric.

03-channel-link

wpfleger96 added a commit that referenced this pull request Jun 8, 2026
extract_nostr_uris computes a fixed 58-byte suffix window after the
nostr:npub1 prefix. The existing length guard let bech32_end land inside
a multi-byte UTF-8 character when non-ASCII followed the prefix, panicking
on the &content[..bech32_end] slice. A valid bech32 suffix is pure ASCII,
so a non-boundary index is always a non-match; skip it explicitly.

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
@wpfleger96 wpfleger96 merged commit bfafdd4 into main Jun 8, 2026
27 of 28 checks passed
@wpfleger96 wpfleger96 deleted the paul/fix-multiword-mentions branch June 8, 2026 23:05
tlongwell-block pushed a commit that referenced this pull request Jun 9, 2026
* origin/main: (32 commits)
  docs: add NIP-ER event reminders (#875)
  feat(acp): pass slash commands through to ACP connectors (#919)
  fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction (#905)
  fix(desktop): re-enable mcp_command reconciliation and harden spawn site (#909)
  Fix desktop DM and sidebar UI polish (#908)
  Animate reaction counts (#904)
  Mobile custom emoji + settings redesign (#906)
  Renew TTL when unarchiving ephemeral channels (#902)
  chore(release): release version 0.3.13 (#903)
  Collapse channel header actions (#901)
  sprout-agent: make Databricks defaults env-only (#868)
  Restyle settings sections (#894)
  Add emoji reaction particles (#890)
  Move settings into the app shell (#893)
  Tune chat text sizing (#891)
  Style channel header navigation (#889)
  fix: rename missed known_acp_provider_exact → known_acp_runtime_exact (#900)
  chore(deps): update radix-ui-primitives monorepo (#898)
  chore(deps): update actions/checkout digest to df4cb1c (#897)
  refactor: rename ACP "provider" to "runtime" across the codebase (#783)
  ...

# Conflicts:
#	desktop/src/features/agents/ui/CreateAgentDialog.tsx
tlongwell-block pushed a commit that referenced this pull request Jun 9, 2026
* origin/main: (32 commits)
  docs: add NIP-ER event reminders (#875)
  feat(acp): pass slash commands through to ACP connectors (#919)
  fix(sdk): resolve multi-word display names and add NIP-27 nostr:npub mention extraction (#905)
  fix(desktop): re-enable mcp_command reconciliation and harden spawn site (#909)
  Fix desktop DM and sidebar UI polish (#908)
  Animate reaction counts (#904)
  Mobile custom emoji + settings redesign (#906)
  Renew TTL when unarchiving ephemeral channels (#902)
  chore(release): release version 0.3.13 (#903)
  Collapse channel header actions (#901)
  sprout-agent: make Databricks defaults env-only (#868)
  Restyle settings sections (#894)
  Add emoji reaction particles (#890)
  Move settings into the app shell (#893)
  Tune chat text sizing (#891)
  Style channel header navigation (#889)
  fix: rename missed known_acp_provider_exact → known_acp_runtime_exact (#900)
  chore(deps): update radix-ui-primitives monorepo (#898)
  chore(deps): update actions/checkout digest to df4cb1c (#897)
  refactor: rename ACP "provider" to "runtime" across the codebase (#783)
  ...

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>

# Conflicts:
#	desktop/src/features/agents/ui/CreateAgentDialog.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants