Skip to main content

iris_chat_core/
mentions.rs

1//! Identity-bearing mentions remain ordinary encrypted message content. The
2//! Markdown link is readable by older clients and survives history sync, drafts,
3//! edits and attachments without a new event kind or public recipient tags.
4
5use nostr::{PublicKey, ToBech32};
6
7#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
8pub struct MessageMention {
9    /// UTF-16 offsets into the encoded body, shared by all native text editors.
10    pub start: u32,
11    pub length: u32,
12    pub owner_pubkey_hex: String,
13    /// Includes the visible @ prefix. This label is not identity verification.
14    pub label: String,
15}
16
17#[uniffi::export]
18pub fn format_message_mention(owner_pubkey_hex: String, name: String) -> String {
19    let Ok(owner) = PublicKey::from_hex(&owner_pubkey_hex) else {
20        return String::new();
21    };
22    let npub = owner
23        .to_bech32()
24        .expect("public key encoding is infallible");
25    let name: String = name
26        .chars()
27        .filter(|c| !c.is_control() && !matches!(c, '[' | ']' | '\\'))
28        .take(80)
29        .collect();
30    let name = name.trim().trim_start_matches('@');
31    let label = if name.is_empty() { &npub[..12] } else { name };
32    format!("[@{label}](nostr:{npub})")
33}
34
35#[uniffi::export]
36pub fn message_mentions(text: String) -> Vec<MessageMention> {
37    mentions(&text)
38        .into_iter()
39        .map(|(start, end, owner, label)| MessageMention {
40            start: text[..start].encode_utf16().count() as u32,
41            length: text[start..end].encode_utf16().count() as u32,
42            owner_pubkey_hex: owner.to_hex(),
43            label: label.to_owned(),
44        })
45        .collect()
46}
47
48/// Readable previews and quoted/forwarded content must not expose wire syntax.
49#[uniffi::export]
50pub fn display_message_mentions(text: String) -> String {
51    let mut output = String::with_capacity(text.len());
52    let mut cursor = 0;
53    for (start, end, _, label) in mentions(&text) {
54        output.push_str(&text[cursor..start]);
55        output.push_str(label);
56        cursor = end;
57    }
58    output.push_str(&text[cursor..]);
59    output
60}
61
62pub(crate) fn mentions_owner(text: &str, owner: &str) -> bool {
63    // Reply quotations and forwards describe someone else's words. They do not
64    // turn an earlier mention into a fresh notification.
65    let body = crate::core::strip_reply_quote(text);
66    if body.starts_with("Forwarded:\n") {
67        return false;
68    }
69    mentions(body)
70        .iter()
71        .any(|(_, _, key, _)| key.to_hex() == owner)
72}
73
74fn mentions(text: &str) -> Vec<(usize, usize, PublicKey, &str)> {
75    let mut output = Vec::new();
76    let mut cursor = 0;
77    while let Some(offset) = text[cursor..].find("[@") {
78        let start = cursor + offset;
79        cursor = start + 2;
80        let label_window_end = (cursor + 328).min(text.len());
81        let Some(label_window) = text.as_bytes().get(cursor..label_window_end) else {
82            break;
83        };
84        let Some(label_end) = label_window
85            .windows(8)
86            .position(|w| w == b"](nostr:")
87            .map(|i| cursor + i)
88        else {
89            continue;
90        };
91        let label = &text[start + 1..label_end];
92        if label.len() > 321
93            || label.len() < 2
94            || label
95                .chars()
96                .any(|c| c.is_control() || matches!(c, '[' | ']' | '\\'))
97        {
98            continue;
99        }
100        let key_start = label_end + "](nostr:".len();
101        let end = key_start + 63;
102        if text.as_bytes().get(end) != Some(&b')') {
103            continue;
104        }
105        let Some(key) = text.get(key_start..end) else {
106            continue;
107        };
108        // One canonical, checksummed identifier; no relay hints or arbitrary URL.
109        if key.len() != 63 || !key.starts_with("npub1") {
110            continue;
111        }
112        let Ok(owner) = PublicKey::parse(key) else {
113            continue;
114        };
115        output.push((start, end + 1, owner, label));
116        cursor = end + 1;
117    }
118    output
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    const ALICE: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
125    const BOB: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
126
127    #[test]
128    fn identities_survive_equal_names_and_unicode_offsets() {
129        let a = format_message_mention(ALICE.into(), "Alex 💜".into());
130        let b = format_message_mention(BOB.into(), "Alex 💜".into());
131        let text = format!("👋 {a}, {b}!");
132        let found = message_mentions(text.clone());
133        assert_eq!(found.len(), 2);
134        assert_eq!(found[0].start, 3);
135        assert_eq!(found[0].length as usize, a.encode_utf16().count());
136        assert_eq!(found[0].owner_pubkey_hex, ALICE);
137        assert_eq!(found[1].owner_pubkey_hex, BOB);
138        assert_eq!(
139            display_message_mentions(text.clone()),
140            "👋 @Alex 💜, @Alex 💜!"
141        );
142        assert!(mentions_owner(&text, ALICE));
143    }
144
145    #[test]
146    fn ordinary_names_links_and_invalid_identifiers_do_not_notify() {
147        for text in [
148            "Hello @Alex",
149            "[Alex](nostr:npub1bad)",
150            "[@Alex](https://example.com)",
151            "[@Alex](nostr:npub1bad)",
152        ] {
153            assert!(!mentions_owner(text, ALICE));
154            assert_eq!(display_message_mentions(text.into()), text);
155        }
156        assert!(format_message_mention("invalid".into(), "Alex".into()).is_empty());
157    }
158
159    #[test]
160    fn quoted_and_forwarded_mentions_do_not_notify_again() {
161        let mention = format_message_mention(ALICE.into(), "Alice".into());
162        assert!(!mentions_owner(
163            &format!("↩ Bob: {mention}\n\nThanks"),
164            ALICE
165        ));
166        assert!(!mentions_owner(&format!("Forwarded:\n\n{mention}"), ALICE));
167        assert!(mentions_owner(
168            &format!("↩ Bob: Earlier\n\nHello {mention}"),
169            ALICE
170        ));
171    }
172
173    #[test]
174    fn malformed_prefixes_are_bounded_and_do_not_hide_a_valid_identity() {
175        let valid = format_message_mention(ALICE.into(), "Alice".into());
176        let text = format!("{}{}", "[@".repeat(16_000), valid);
177        assert_eq!(message_mentions(text).len(), 1);
178    }
179
180    #[test]
181    fn labels_cannot_break_out_of_their_identity_token() {
182        let text = format_message_mention(ALICE.into(), "Alice]\n\\[bad".into());
183        assert_eq!(message_mentions(text.clone())[0].label, "@Alicebad");
184        assert!(mentions_owner(&text, ALICE));
185    }
186}