From 7c42466b0a4a07bc183acc00ed9057ba4410cfd9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 12 Oct 2026 11:02:15 +0700 Subject: [PATCH] chore: convert NIP-21 references to njump link --- crates/chat/src/message.rs | 114 ++++++++++++++++++++++++++++++++++++- crates/chat_ui/src/text.rs | 35 ++---------- crates/nip29_ui/src/lib.rs | 3 +- crates/ui/src/markdown.rs | 8 +++ 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index a80d2513..8b30bcdf 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -8,6 +8,9 @@ use state::FileAttachment; pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15); +const SCHEME_WITH_COLON: &str = "nostr:"; +const NEVENT_PREFIX: &str = "nevent1"; + /// Rendered message. #[derive(Debug, Clone)] pub struct Message { @@ -148,7 +151,33 @@ impl Message { return format!("[File] {}", file.display_name()).into(); } - self.content.clone().into() + // Media-only messages carry their URLs outside `.content` + if self.content.is_empty() && !self.media.is_empty() { + return self + .media + .iter() + .map(|url| url.to_string()) + .collect::>() + .join(" ") + .into(); + } + + let references = nevent_refs(&self.content); + if references.is_empty() { + return self.content.clone().into(); + } + + let mut preview = String::with_capacity(self.content.len()); + let mut last = 0; + + for reference in references { + preview.push_str(&self.content[last..reference.range.start]); + preview.push_str(&reference.label); + last = reference.range.end; + } + preview.push_str(&self.content[last..]); + + preview.into() } /// Image URL of a `:shortcode:` reaction, if the shortcode is declared @@ -219,6 +248,61 @@ fn extract_mentions(content: &str) -> Vec { .collect() } +/// A `nevent` reference found in a content string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NeventRef { + /// Range of the reference, including the optional `nostr:` scheme + pub range: Range, + /// Compact display label, like `https://njump.me/nevent1...d9sp` + pub label: String, +} + +/// Extracts valid `nevent1...` and `nostr:nevent1...` references from content. +pub fn nevent_refs(content: &str) -> Vec { + let mut references = Vec::new(); + + for (match_start, _) in content.match_indices(NEVENT_PREFIX) { + // Only lowercase references are matched, matching NIP-19 encodings in practice + let start = if content[..match_start].ends_with(SCHEME_WITH_COLON) { + match_start - SCHEME_WITH_COLON.len() + } else { + match_start + }; + + // Skip matches glued to word characters, like the `nevent1` in `xnevent1...` + if content[..start].ends_with(char::is_alphanumeric) { + continue; + } + + let end = match_start + + content[match_start..] + .bytes() + .take_while(u8::is_ascii_alphanumeric) + .count(); + + // The bech32 token excludes the optional `nostr:` scheme + let bech32 = &content[match_start..end]; + if Nip19Event::from_bech32(bech32).is_err() { + continue; + } + + references.push(NeventRef { + range: start..end, + label: nevent_label(bech32), + }); + } + + references +} + +/// Compact display of a nevent link, like `https://njump.me/nevent1...d9sp`. +fn nevent_label(bech32: &str) -> String { + match bech32.len() { + 0..=4 => format!("https://njump.me/{bech32}"), + _ => format!("https://njump.me/nevent1...{}", &bech32[bech32.len() - 4..]), + } +} + /// Extracts all reply (ids) from the event tags. fn extract_reply_ids(inner: &Tags) -> Vec { let mut replies_to = vec![]; @@ -283,4 +367,32 @@ mod tests { assert_eq!(Message::reaction_image("😂", &emojis), None); assert_eq!(Message::reaction_image(":unknown:", &emojis), None); } + + fn valid_nevent() -> String { + Nip19Event::new(EventId::from_byte_array([7; 32])) + .to_bech32() + .expect("valid nevent") + } + + #[test] + fn image_only_messages_preview_their_url() { + let url = "https://example.com/picture.png"; + let message = Message::from(&rumor(Kind::PrivateDirectMessage, url, Tags::new())); + + assert!(message.content.is_empty()); + assert_eq!(message.preview().to_string(), url); + } + + #[test] + fn preview_compacts_nevent_references() { + let nevent = valid_nevent(); + let message = Message::from(&rumor( + Kind::PrivateDirectMessage, + &format!("see nostr:{nevent}"), + Tags::new(), + )); + + let label = format!("https://njump.me/nevent1...{}", &nevent[nevent.len() - 4..]); + assert_eq!(message.preview().to_string(), format!("see {label}")); + } } diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 771de157..00ccf58a 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,4 +1,4 @@ -use chat::Mention; +use chat::{Mention, nevent_refs}; use gpui::{App, Entity}; use nostr_sdk::prelude::Url; use person::PersonRegistry; @@ -23,6 +23,11 @@ pub fn rendered_text( .collect::>(); replacements.extend(emoji_replacements(content, emojis)); + replacements.extend( + nevent_refs(content) + .into_iter() + .map(|reference| InlineReplacement::new(reference.range, reference.label)), + ); replacements.sort_by_key(|replacement| replacement.range.start); RenderedText::new(content, &replacements) @@ -53,31 +58,3 @@ fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec (String, Url) { - (String::from(shortcode), Url::parse(url).expect("valid url")) - } - - #[test] - fn declared_shortcodes_become_image_replacements() { - let emojis = vec![emoji("soapbox", "https://example.com/soapbox.png")]; - - let replacements = emoji_replacements("hi :soapbox: and :other:", &emojis); - - assert_eq!(replacements.len(), 1); - assert_eq!(replacements[0].range, 3..12); - } - - #[test] - fn matches_glued_to_word_characters_are_skipped() { - let emojis = vec![emoji("30", "https://example.com/30.png")]; - - assert!(emoji_replacements("at 12:30:45 exactly", &emojis).is_empty()); - assert!(emoji_replacements("x:30: and :30:y", &emojis).is_empty()); - assert_eq!(emoji_replacements("(:30:)", &emojis).len(), 1); - } -} diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 4fa26544..e6eaddf1 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -992,7 +992,8 @@ impl GroupPanel { let message = group.message(target)?; let persons = PersonRegistry::global(cx); let author = persons.read(cx).get(&message.author, cx); - let line = message.content.lines().next().unwrap_or_default().trim(); + let preview = message.preview(); + let line = preview.lines().next().unwrap_or_default().trim(); Some(( author.name(), diff --git a/crates/ui/src/markdown.rs b/crates/ui/src/markdown.rs index e60f06de..7de5f93d 100644 --- a/crates/ui/src/markdown.rs +++ b/crates/ui/src/markdown.rs @@ -113,6 +113,8 @@ impl RenderedText { .on_link_click(|url, _, _, cx| { if is_web_url(url) { cx.open_url(url); + } else if let Some(bech32) = nevent_bech32(url) { + cx.open_url(&format!("https://njump.me/{bech32}")); } }) .into_any_element() @@ -136,3 +138,9 @@ fn is_web_url(url: &str) -> bool { let url = url.to_ascii_lowercase(); url.starts_with("http://") || url.starts_with("https://") } + +/// Extracts the bech32 from a NIP-19 event reference (`nevent1...` or `nostr:nevent1...`). +fn nevent_bech32(url: &str) -> Option<&str> { + let bech32 = url.strip_prefix("nostr:").unwrap_or(url); + bech32.starts_with("nevent1").then_some(bech32) +}