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);
|
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}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user