chore: convert NIP-21 references to njump link

This commit is contained in:
2026-10-12 11:02:15 +07:00
parent 291471075b
commit 7c42466b0a
4 changed files with 129 additions and 31 deletions
+113 -1
View File
@@ -8,6 +8,9 @@ use state::FileAttachment;
pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15); pub(crate) const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
const SCHEME_WITH_COLON: &str = "nostr:";
const NEVENT_PREFIX: &str = "nevent1";
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Message { pub struct Message {
@@ -148,7 +151,33 @@ impl Message {
return format!("[File] {}", file.display_name()).into(); 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::<Vec<_>>()
.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 /// Image URL of a `:shortcode:` reaction, if the shortcode is declared
@@ -219,6 +248,61 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
.collect() .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<usize>,
/// 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<NeventRef> {
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. /// Extracts all reply (ids) from the event tags.
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> { fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
let mut replies_to = vec![]; let mut replies_to = vec![];
@@ -283,4 +367,32 @@ mod tests {
assert_eq!(Message::reaction_image("😂", &emojis), None); assert_eq!(Message::reaction_image("😂", &emojis), None);
assert_eq!(Message::reaction_image(":unknown:", &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}"));
}
} }
+6 -29
View File
@@ -1,4 +1,4 @@
use chat::Mention; use chat::{Mention, nevent_refs};
use gpui::{App, Entity}; use gpui::{App, Entity};
use nostr_sdk::prelude::Url; use nostr_sdk::prelude::Url;
use person::PersonRegistry; use person::PersonRegistry;
@@ -23,6 +23,11 @@ pub fn rendered_text(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
replacements.extend(emoji_replacements(content, emojis)); 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); replacements.sort_by_key(|replacement| replacement.range.start);
RenderedText::new(content, &replacements) RenderedText::new(content, &replacements)
@@ -53,31 +58,3 @@ fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineRepl
replacements replacements
} }
#[cfg(test)]
mod tests {
use super::*;
fn emoji(shortcode: &str, url: &str) -> (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);
}
}
+2 -1
View File
@@ -992,7 +992,8 @@ impl GroupPanel {
let message = group.message(target)?; let message = group.message(target)?;
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, 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(( Some((
author.name(), author.name(),
+8
View File
@@ -113,6 +113,8 @@ impl RenderedText {
.on_link_click(|url, _, _, cx| { .on_link_click(|url, _, _, cx| {
if is_web_url(url) { if is_web_url(url) {
cx.open_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() .into_any_element()
@@ -136,3 +138,9 @@ fn is_web_url(url: &str) -> bool {
let url = url.to_ascii_lowercase(); let url = url.to_ascii_lowercase();
url.starts_with("http://") || url.starts_with("https://") 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)
}