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

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-08 19:05:37 -04:00
committed by GitHub
parent 15f610dcd5
commit bfafdd46b2
8 changed files with 651 additions and 43 deletions

View File

@@ -5,16 +5,25 @@ export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const NEVER_MATCH = /(?!)/gi;
/**
* Build a regex that matches a given prefix followed by known multi-word names
* (longest-first to avoid partial matches). When known names are provided,
* only those names are matched — no generic fallback. When no names are
* available, falls back to prefix + \S+ for backwards compatibility (e.g.
* old messages without proper p-tags, or while profiles are loading).
* only those names are matched — no generic fallback.
*
* When no names are available:
* - If `options.fallbackToGeneric` is true, falls back to `prefix + \S+` so
* that patterns like `#channel` still render while channel names are loading
* asynchronously (used by remarkChannelLinks).
* - Otherwise returns a never-matching regex, preventing arbitrary `@word`
* patterns from being highlighted as valid mentions when no p-tags are
* present (used by remarkMentions / buildMentionPattern).
*/
export function buildPrefixPattern(
prefix: string,
knownNames: string[],
options?: { fallbackToGeneric?: boolean },
): RegExp {
const sorted = [...new Set(knownNames)]
.filter((name) => name.trim().length > 0)
@@ -23,7 +32,10 @@ export function buildPrefixPattern(
const escapedPrefix = escapeRegExp(prefix);
if (sorted.length === 0) {
return new RegExp(`${escapedPrefix}\\S+`, "gi");
if (options?.fallbackToGeneric) {
return new RegExp(`${escapedPrefix}\\S+`, "gi");
}
return NEVER_MATCH;
}
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
@@ -32,8 +44,10 @@ export function buildPrefixPattern(
}
/**
* Build a regex that matches @mentions, trying known multi-word names first
* (longest-first to avoid partial matches), then falling back to @\S+.
* Build a regex that matches @mentions for known multi-word names
* (longest-first to avoid partial matches). When no known names are provided,
* returns a never-matching regex — @word patterns are not highlighted unless
* they correspond to an actual p-tagged member.
*/
export function buildMentionPattern(mentionNames: string[]): RegExp {
return buildPrefixPattern("@", mentionNames);

View File

@@ -2,9 +2,9 @@
* Remark plugin that detects #channel-name patterns in text nodes and wraps them
* in custom HAST `channel-link` elements for styled rendering via react-markdown.
*
* When `channelNames` is provided, multi-word channel names (e.g. "my channel")
* are matched first (longest-first to avoid partial matches), then the plugin
* falls back to the generic `#\S+` pattern for unknown channels.
* Known channel names are matched longest-first to avoid partial matches. When
* no known names are provided, falls back to `#\S+` so that channel links still
* render while the channel list is loading asynchronously.
*/
import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin";
@@ -17,7 +17,9 @@ type RemarkChannelLinksOptions = {
export default function remarkChannelLinks(
options?: RemarkChannelLinksOptions,
) {
const channelPattern = buildPrefixPattern("#", options?.channelNames ?? []);
const channelPattern = buildPrefixPattern("#", options?.channelNames ?? [], {
fallbackToGeneric: true,
});
return createRemarkPrefixPlugin(channelPattern, (matchText) => {
const channelName = matchText.slice(1);

View File

@@ -2,9 +2,9 @@
* Remark plugin that detects @mention patterns in text nodes and wraps them
* in custom HAST `mention` elements for styled rendering via react-markdown.
*
* When `mentionNames` is provided, multi-word display names (e.g. "John Doe")
* are matched first (longest-first to avoid partial matches), then the plugin
* falls back to the generic `@\S+` pattern for unknown mentions.
* Only p-tagged member names are highlighted — multi-word display names
* (e.g. "John Doe") are matched longest-first to avoid partial matches.
* When no known names are provided, nothing is highlighted.
*/
import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin";