iris_chat_core/
mentions.rs1use nostr::{PublicKey, ToBech32};
6
7#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
8pub struct MessageMention {
9 pub start: u32,
11 pub length: u32,
12 pub owner_pubkey_hex: String,
13 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#[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 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 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}