chore: convert NIP-21 references to njump link
This commit is contained in:
+113
-1
@@ -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::<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
|
||||
@@ -219,6 +248,61 @@ fn extract_mentions(content: &str) -> Vec<Mention> {
|
||||
.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.
|
||||
fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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<InlineRepl
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user