post.rs (45059B)
1 use egui::{ 2 text::{CCursor, CCursorRange, LayoutJob}, 3 text_edit::TextEditOutput, 4 TextBuffer, TextEdit, TextFormat, 5 }; 6 use enostr::{FullKeypair, Pubkey}; 7 use nostrdb::{Note, NoteBuilder, NoteReply}; 8 use std::{ 9 any::TypeId, 10 collections::{BTreeMap, HashMap, HashSet}, 11 hash::{DefaultHasher, Hash, Hasher}, 12 ops::Range, 13 }; 14 use tracing::error; 15 16 use crate::media_upload::Nip94Event; 17 18 pub struct NewPost { 19 pub content: String, 20 pub account: FullKeypair, 21 pub media: Vec<Nip94Event>, 22 pub mentions: Vec<Pubkey>, 23 } 24 25 fn client_variant() -> &'static str { 26 #[cfg(target_os = "android")] 27 { 28 "Damus Android" 29 } 30 31 #[cfg(not(target_os = "android"))] 32 { 33 "Damus Notedeck" 34 } 35 } 36 37 fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { 38 builder 39 .start_tag() 40 .tag_str("client") 41 .tag_str(client_variant()) 42 } 43 44 impl NewPost { 45 pub fn new( 46 content: String, 47 account: enostr::FullKeypair, 48 media: Vec<Nip94Event>, 49 mentions: Vec<Pubkey>, 50 ) -> Self { 51 NewPost { 52 content, 53 account, 54 media, 55 mentions, 56 } 57 } 58 59 /// creates a NoteBuilder with all the shared data between note, reply & quote reply 60 fn builder_with_shared_tags<'a>(&self, mut content: String) -> NoteBuilder<'a> { 61 append_urls(&mut content, &self.media); 62 63 let mut builder = NoteBuilder::new().kind(1).content(&content); 64 builder = add_client_tag(builder); 65 66 for hashtag in Self::extract_hashtags(&self.content) { 67 builder = builder.start_tag().tag_str("t").tag_str(&hashtag); 68 } 69 70 if !self.media.is_empty() { 71 builder = add_imeta_tags(builder, &self.media); 72 } 73 74 if !self.mentions.is_empty() { 75 builder = add_mention_tags(builder, &self.mentions); 76 } 77 78 builder 79 } 80 81 pub fn to_note(&self, seckey: &[u8; 32]) -> Note<'_> { 82 let builder = self.builder_with_shared_tags(self.content.clone()); 83 84 builder.sign(seckey).build().expect("note should be ok") 85 } 86 87 pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note<'_> { 88 let mut builder = self.builder_with_shared_tags(self.content.clone()); 89 90 let nip10 = NoteReply::new(replying_to.tags()); 91 92 builder = if let Some(root) = nip10.root() { 93 builder 94 .start_tag() 95 .tag_str("e") 96 .tag_str(&hex::encode(root.id)) 97 .tag_str("") 98 .tag_str("root") 99 .start_tag() 100 .tag_str("e") 101 .tag_str(&hex::encode(replying_to.id())) 102 .tag_str("") 103 .tag_str("reply") 104 .sign(seckey) 105 } else { 106 // we're replying to a post that isn't in a thread, 107 // just add a single reply-to-root tag 108 builder 109 .start_tag() 110 .tag_str("e") 111 .tag_str(&hex::encode(replying_to.id())) 112 .tag_str("") 113 .tag_str("root") 114 .sign(seckey) 115 }; 116 117 let mut seen_p: HashSet<&[u8; 32]> = HashSet::new(); 118 119 builder = builder 120 .start_tag() 121 .tag_str("p") 122 .tag_str(&hex::encode(replying_to.pubkey())); 123 124 seen_p.insert(replying_to.pubkey()); 125 126 for tag in replying_to.tags() { 127 if tag.count() < 2 { 128 continue; 129 } 130 131 if tag.get_unchecked(0).variant().str() != Some("p") { 132 continue; 133 } 134 135 let id = if let Some(id) = tag.get_unchecked(1).variant().id() { 136 id 137 } else { 138 continue; 139 }; 140 141 if seen_p.contains(id) { 142 continue; 143 } 144 145 seen_p.insert(id); 146 147 builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id)); 148 } 149 150 builder 151 .sign(seckey) 152 .build() 153 .expect("expected build to work") 154 } 155 156 pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note<'_> { 157 let new_content = format!( 158 "{}\nnostr:{}", 159 self.content, 160 enostr::NoteId::new(*quoting.id()).to_bech().unwrap() 161 ); 162 163 let builder = self.builder_with_shared_tags(new_content); 164 165 builder 166 .start_tag() 167 .tag_str("q") 168 .tag_str(&hex::encode(quoting.id())) 169 .start_tag() 170 .tag_str("p") 171 .tag_str(&hex::encode(quoting.pubkey())) 172 .sign(seckey) 173 .build() 174 .expect("expected build to work") 175 } 176 177 fn extract_hashtags(content: &str) -> HashSet<String> { 178 let mut hashtags = HashSet::new(); 179 for word in 180 content.split(|c: char| c.is_whitespace() || (c.is_ascii_punctuation() && c != '#')) 181 { 182 if word.starts_with('#') && word.len() > 1 { 183 let tag = word[1..].to_lowercase(); 184 if !tag.is_empty() { 185 hashtags.insert(tag); 186 } 187 } 188 } 189 hashtags 190 } 191 } 192 193 fn append_urls(content: &mut String, media: &Vec<Nip94Event>) { 194 for ev in media { 195 content.push(' '); 196 content.push_str(&ev.url); 197 } 198 } 199 200 fn add_mention_tags<'a>(builder: NoteBuilder<'a>, mentions: &Vec<Pubkey>) -> NoteBuilder<'a> { 201 let mut builder = builder; 202 203 for mention in mentions { 204 builder = builder.start_tag().tag_str("p").tag_str(&mention.hex()); 205 } 206 207 builder 208 } 209 210 fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec<Nip94Event>) -> NoteBuilder<'a> { 211 let mut builder = builder; 212 for item in media { 213 builder = builder 214 .start_tag() 215 .tag_str("imeta") 216 .tag_str(&format!("url {}", item.url)); 217 218 if let Some(ox) = &item.ox { 219 builder = builder.tag_str(&format!("ox {ox}")); 220 }; 221 if let Some(x) = &item.x { 222 builder = builder.tag_str(&format!("x {x}")); 223 } 224 if let Some(media_type) = &item.media_type { 225 builder = builder.tag_str(&format!("m {media_type}")); 226 } 227 if let Some(dims) = &item.dimensions { 228 builder = builder.tag_str(&format!("dim {}x{}", dims.0, dims.1)); 229 } 230 if let Some(bh) = &item.blurhash { 231 builder = builder.tag_str(&format!("blurhash {bh}")); 232 } 233 if let Some(thumb) = &item.thumb { 234 builder = builder.tag_str(&format!("thumb {thumb}")); 235 } 236 } 237 builder 238 } 239 240 type MentionKey = usize; 241 242 #[derive(Debug, Clone)] 243 pub struct PostBuffer { 244 pub text_buffer: String, 245 pub mention_indicator: char, 246 pub mentions: HashMap<MentionKey, MentionInfo>, 247 mentions_key: MentionKey, 248 pub selected_mention: bool, 249 250 // the start index of a mention is inclusive 251 pub mention_starts: BTreeMap<usize, MentionKey>, // maps the mention start index with the correct `MentionKey` 252 253 // the end index of a mention is exclusive 254 pub mention_ends: BTreeMap<usize, MentionKey>, // maps the mention end index with the correct `MentionKey` 255 } 256 257 impl Default for PostBuffer { 258 fn default() -> Self { 259 Self { 260 mention_indicator: '@', 261 mentions_key: 0, 262 selected_mention: false, 263 text_buffer: Default::default(), 264 mentions: Default::default(), 265 mention_starts: Default::default(), 266 mention_ends: Default::default(), 267 } 268 } 269 } 270 271 /// New cursor index (indexed by characters) after operation is performed 272 #[must_use = "must call MentionSelectedResponse::process"] 273 pub struct MentionSelectedResponse { 274 pub next_cursor_index: usize, 275 } 276 277 impl MentionSelectedResponse { 278 pub fn process(&self, ctx: &egui::Context, text_edit_output: &TextEditOutput) { 279 let text_edit_id = text_edit_output.response.id; 280 let Some(mut before_state) = TextEdit::load_state(ctx, text_edit_id) else { 281 return; 282 }; 283 284 before_state 285 .cursor 286 .set_char_range(Some(CCursorRange::one(CCursor::new( 287 self.next_cursor_index, 288 )))); 289 290 ctx.memory_mut(|mem| mem.request_focus(text_edit_id)); 291 292 TextEdit::store_state(ctx, text_edit_id, before_state); 293 } 294 } 295 296 impl PostBuffer { 297 pub fn get_new_mentions_key(&mut self) -> usize { 298 let prev = self.mentions_key; 299 self.mentions_key += 1; 300 prev 301 } 302 303 pub fn get_mention(&self, cursor_index: usize) -> Option<MentionIndex<'_>> { 304 self.mention_ends 305 .range(cursor_index..) 306 .next() 307 .and_then(|(_, mention_key)| { 308 self.mentions 309 .get(mention_key) 310 .filter(|info| { 311 if let MentionType::Finalized(_) = info.mention_type { 312 // should exclude the last character if we're finalized 313 info.start_index <= cursor_index && cursor_index < info.end_index 314 } else { 315 info.start_index <= cursor_index && cursor_index <= info.end_index 316 } 317 }) 318 .map(|info| MentionIndex { 319 index: *mention_key, 320 info, 321 }) 322 }) 323 } 324 325 pub fn get_mention_string<'a>(&'a self, mention_key: &MentionIndex<'a>) -> &'a str { 326 self.text_buffer 327 .char_range(mention_key.info.start_index + 1..mention_key.info.end_index) 328 // don't include the delim 329 } 330 331 pub fn select_full_mention(&mut self, mention_key: usize, pk: Pubkey) { 332 if let Some(info) = self.mentions.get_mut(&mention_key) { 333 info.mention_type = MentionType::Finalized(pk); 334 self.selected_mention = true; 335 } else { 336 error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); 337 } 338 } 339 340 pub fn select_mention_and_replace_name( 341 &mut self, 342 mention_key: usize, 343 full_name: &str, 344 pk: Pubkey, 345 ) -> Option<MentionSelectedResponse> { 346 let Some(info) = self.mentions.get(&mention_key) else { 347 error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); 348 return None; 349 }; 350 let text_start_index = info.start_index + 1; // increment by one to exclude the mention indicator, '@' 351 self.delete_char_range(text_start_index..info.end_index); 352 let text_chars_inserted = self.insert_text(full_name, text_start_index); 353 self.select_full_mention(mention_key, pk); 354 355 let space_chars_inserted = self.insert_text(" ", text_start_index + text_chars_inserted); 356 357 Some(MentionSelectedResponse { 358 next_cursor_index: text_start_index + text_chars_inserted + space_chars_inserted, 359 }) 360 } 361 362 pub fn delete_mention(&mut self, mention_key: usize) { 363 if let Some(mention_info) = self.mentions.get(&mention_key) { 364 self.mention_starts.remove(&mention_info.start_index); 365 self.mention_ends.remove(&mention_info.end_index); 366 self.mentions.remove(&mention_key); 367 } 368 } 369 370 pub fn is_empty(&self) -> bool { 371 self.text_buffer.is_empty() 372 } 373 374 pub fn output(&self) -> PostOutput { 375 let mut out = self.text_buffer.clone(); 376 let mut mentions = Vec::new(); 377 for (cur_end_ind, mention_ind) in self.mention_ends.iter().rev() { 378 if let Some(info) = self.mentions.get(mention_ind) { 379 if let MentionType::Finalized(pk) = info.mention_type { 380 if let Some(bech) = pk.npub() { 381 if let Some(byte_range) = 382 char_indices_to_byte(&out, info.start_index..*cur_end_ind) 383 { 384 out.replace_range(byte_range, &format!("nostr:{bech}")); 385 mentions.push(pk); 386 } 387 } 388 } 389 } 390 } 391 mentions.reverse(); 392 393 PostOutput { 394 text: out, 395 mentions, 396 } 397 } 398 399 pub fn to_layout_job(&self, ui: &egui::Ui) -> LayoutJob { 400 let mut job = LayoutJob::default(); 401 let colored_fmt = default_text_format_colored(ui, notedeck_ui::colors::PINK); 402 403 let mut prev_text_char_index = 0; 404 let mut prev_text_byte_index = 0; 405 for (start_char_index, mention_ind) in &self.mention_starts { 406 if let Some(info) = self.mentions.get(mention_ind) { 407 if matches!(info.mention_type, MentionType::Finalized(_)) { 408 let end_char_index = info.end_index; 409 410 let char_indices = prev_text_char_index..*start_char_index; 411 if let Some(byte_indicies) = 412 char_indices_to_byte(&self.text_buffer, char_indices.clone()) 413 { 414 if let Some(prev_text) = self.text_buffer.get(byte_indicies.clone()) { 415 job.append(prev_text, 0.0, default_text_format(ui)); 416 prev_text_char_index = *start_char_index; 417 prev_text_byte_index = byte_indicies.end; 418 } 419 } 420 421 let char_indices = *start_char_index..end_char_index; 422 if let Some(byte_indicies) = 423 char_indices_to_byte(&self.text_buffer, char_indices.clone()) 424 { 425 if let Some(cur_text) = self.text_buffer.get(byte_indicies.clone()) { 426 job.append(cur_text, 0.0, colored_fmt.clone()); 427 prev_text_char_index = end_char_index; 428 prev_text_byte_index = byte_indicies.end; 429 } 430 } 431 } 432 } 433 } 434 435 if prev_text_byte_index < self.text_buffer.len() { 436 if let Some(cur_text) = self.text_buffer.get(prev_text_byte_index..) { 437 job.append(cur_text, 0.0, default_text_format(ui)); 438 } else { 439 error!( 440 "could not retrieve substring from [{} to {}) in PostBuffer::text_buffer", 441 prev_text_byte_index, 442 self.text_buffer.len() 443 ); 444 } 445 } 446 447 job 448 } 449 450 pub fn need_new_layout(&self, cache: Option<&(String, LayoutJob)>) -> bool { 451 if let Some((text, _)) = cache { 452 if self.selected_mention { 453 return true; 454 } 455 456 self.text_buffer != *text 457 } else { 458 true 459 } 460 } 461 } 462 463 fn char_indices_to_byte(text: &str, char_range: Range<usize>) -> Option<Range<usize>> { 464 let mut char_indices = text.char_indices(); 465 466 let start = char_indices.nth(char_range.start)?.0; 467 let end = if char_range.end < text.chars().count() { 468 char_indices.nth(char_range.end - char_range.start - 1)?.0 469 } else { 470 text.len() 471 }; 472 473 Some(start..end) 474 } 475 476 pub fn downcast_post_buffer(buffer: &dyn TextBuffer) -> Option<&PostBuffer> { 477 let mut hasher = DefaultHasher::new(); 478 TypeId::of::<PostBuffer>().hash(&mut hasher); 479 let post_id = hasher.finish() as usize; 480 481 if buffer.type_id() == post_id { 482 unsafe { Some(&*(buffer as *const dyn TextBuffer as *const PostBuffer)) } 483 } else { 484 None 485 } 486 } 487 488 fn default_text_format(ui: &egui::Ui) -> TextFormat { 489 default_text_format_colored( 490 ui, 491 ui.visuals() 492 .override_text_color 493 .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), 494 ) 495 } 496 497 fn default_text_format_colored(ui: &egui::Ui, color: egui::Color32) -> TextFormat { 498 TextFormat::simple(egui::FontSelection::default().resolve(ui.style()), color) 499 } 500 501 pub struct PostOutput { 502 pub text: String, 503 pub mentions: Vec<Pubkey>, 504 } 505 506 #[derive(Debug)] 507 pub struct MentionIndex<'a> { 508 pub index: usize, 509 pub info: &'a MentionInfo, 510 } 511 512 #[derive(Clone, Debug, PartialEq)] 513 pub enum MentionType { 514 Pending, 515 Finalized(Pubkey), 516 } 517 518 impl TextBuffer for PostBuffer { 519 fn is_mutable(&self) -> bool { 520 true 521 } 522 523 fn as_str(&self) -> &str { 524 self.text_buffer.as_str() 525 } 526 527 fn insert_text(&mut self, text: &str, char_index: usize) -> usize { 528 if text.is_empty() { 529 return 0; 530 } 531 let text_num_chars = text.chars().count(); 532 self.text_buffer.insert_text(text, char_index); 533 534 // the text was inserted before or inside these mentions. We need to at least move their ends 535 let pending_ends_to_update: Vec<usize> = self 536 .mention_ends 537 .range(char_index..) 538 .filter(|(k, v)| { 539 let is_last = **k == char_index; 540 let is_finalized = if let Some(info) = self.mentions.get(*v) { 541 matches!(info.mention_type, MentionType::Finalized(_)) 542 } else { 543 false 544 }; 545 !(is_last && is_finalized) 546 }) 547 .map(|(&k, _)| k) 548 .collect(); 549 550 let mut break_mentions = Vec::new(); 551 for cur_end in pending_ends_to_update { 552 let mention_key = if let Some(mention_key) = self.mention_ends.get(&cur_end) { 553 *mention_key 554 } else { 555 continue; 556 }; 557 558 self.mention_ends.remove(&cur_end); 559 560 let new_end = cur_end + text_num_chars; 561 self.mention_ends.insert(new_end, mention_key); 562 // replaced the current end with the new value 563 564 if let Some(mention_info) = self.mentions.get_mut(&mention_key) { 565 if mention_info.start_index >= char_index { 566 // the text is being inserted before this mention. move the start index as well 567 self.mention_starts.remove(&mention_info.start_index); 568 let new_start = mention_info.start_index + text_num_chars; 569 self.mention_starts.insert(new_start, mention_key); 570 mention_info.start_index = new_start; 571 } else { 572 if char_index == mention_info.end_index 573 && first_is_desired_char(&self.text_buffer, text, char_index, ' ') 574 { 575 // if the user wrote a double space at the end of the mention, break it 576 break_mentions.push(mention_key); 577 } 578 579 // text is being inserted inside this mention. Make sure it is in the pending state 580 mention_info.mention_type = MentionType::Pending; 581 } 582 583 mention_info.end_index = new_end; 584 } else { 585 error!("Could not find mention at index {}", mention_key); 586 } 587 } 588 589 for mention_key in break_mentions { 590 self.delete_mention(mention_key); 591 } 592 593 if first_is_desired_char(&self.text_buffer, text, char_index, self.mention_indicator) { 594 // if a mention already exists where we're inserting the delim, remove it 595 let to_remove = self.get_mention(char_index).map(|old_mention| { 596 ( 597 old_mention.index, 598 old_mention.info.start_index..old_mention.info.end_index, 599 ) 600 }); 601 602 if let Some((key, range)) = to_remove { 603 self.mention_ends.remove(&range.end); 604 self.mention_starts.remove(&range.start); 605 self.mentions.remove(&key); 606 } 607 608 let start_index = char_index; 609 let end_index = char_index + text_num_chars; 610 let mention_key = self.get_new_mentions_key(); 611 self.mentions.insert( 612 mention_key, 613 MentionInfo { 614 start_index, 615 end_index, 616 mention_type: MentionType::Pending, 617 }, 618 ); 619 self.mention_starts.insert(start_index, mention_key); 620 self.mention_ends.insert(end_index, mention_key); 621 } 622 623 text_num_chars 624 } 625 626 fn delete_char_range(&mut self, char_range: Range<usize>) { 627 let deletion_num_chars = char_range.len(); 628 let Range { 629 start: deletion_start, 630 end: deletion_end, 631 } = char_range; 632 633 self.text_buffer.delete_char_range(char_range); 634 635 // these mentions will be affected by the deletion 636 let ends_to_update: Vec<usize> = self 637 .mention_ends 638 .range(deletion_start..) 639 .map(|(&k, _)| k) 640 .collect(); 641 642 for cur_mention_end in ends_to_update { 643 let mention_key = match &self.mention_ends.get(&cur_mention_end) { 644 Some(ind) => **ind, 645 None => continue, 646 }; 647 let cur_mention_start = match self.mentions.get(&mention_key) { 648 Some(i) => i.start_index, 649 None => { 650 error!("Could not find mention at index {}", mention_key); 651 continue; 652 } 653 }; 654 655 if cur_mention_end <= deletion_start { 656 // nothing happens to this mention 657 continue; 658 } 659 660 let status = if cur_mention_start >= deletion_start { 661 if cur_mention_start >= deletion_end { 662 // mention falls after the range 663 // need to shift both start and end 664 665 DeletionStatus::ShiftStartAndEnd( 666 cur_mention_start - deletion_num_chars, 667 cur_mention_end - deletion_num_chars, 668 ) 669 } else { 670 // fully delete mention 671 672 DeletionStatus::FullyRemove 673 } 674 } else if cur_mention_end > deletion_end { 675 // inner partial delete 676 677 DeletionStatus::ShiftEnd(cur_mention_end - deletion_num_chars) 678 } else { 679 // outer partial delete 680 681 DeletionStatus::ShiftEnd(deletion_start) 682 }; 683 684 match status { 685 DeletionStatus::FullyRemove => { 686 self.mention_starts.remove(&cur_mention_start); 687 self.mention_ends.remove(&cur_mention_end); 688 self.mentions.remove(&mention_key); 689 } 690 DeletionStatus::ShiftEnd(new_end) 691 | DeletionStatus::ShiftStartAndEnd(_, new_end) => { 692 let mention_info = match self.mentions.get_mut(&mention_key) { 693 Some(i) => i, 694 None => { 695 error!("Could not find mention at index {}", mention_key); 696 continue; 697 } 698 }; 699 700 self.mention_ends.remove(&cur_mention_end); 701 self.mention_ends.insert(new_end, mention_key); 702 mention_info.end_index = new_end; 703 704 if let DeletionStatus::ShiftStartAndEnd(new_start, _) = status { 705 self.mention_starts.remove(&cur_mention_start); 706 self.mention_starts.insert(new_start, mention_key); 707 mention_info.start_index = new_start; 708 } 709 710 if let DeletionStatus::ShiftEnd(_) = status { 711 mention_info.mention_type = MentionType::Pending; 712 } 713 } 714 } 715 } 716 } 717 718 fn type_id(&self) -> usize { 719 let mut hasher = DefaultHasher::new(); 720 TypeId::of::<PostBuffer>().hash(&mut hasher); 721 hasher.finish() as usize 722 } 723 } 724 725 fn first_is_desired_char( 726 full_text: &str, 727 new_text: &str, 728 new_text_index: usize, 729 desired: char, 730 ) -> bool { 731 new_text.chars().next().is_some_and(|c| { 732 c == desired 733 && (new_text_index == 0 || full_text.chars().nth(new_text_index - 1) == Some(' ')) 734 }) 735 } 736 737 #[derive(Debug)] 738 enum DeletionStatus { 739 FullyRemove, 740 ShiftEnd(usize), 741 ShiftStartAndEnd(usize, usize), 742 } 743 744 #[derive(Debug, PartialEq, Clone)] 745 pub struct MentionInfo { 746 pub start_index: usize, 747 pub end_index: usize, 748 pub mention_type: MentionType, 749 } 750 751 #[cfg(test)] 752 mod tests { 753 use super::*; 754 use pretty_assertions::assert_eq; 755 756 impl MentionInfo { 757 pub fn bounds(&self) -> Range<usize> { 758 self.start_index..self.end_index 759 } 760 } 761 762 const JB55: fn() -> Pubkey = || { 763 Pubkey::from_hex("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245") 764 .unwrap() 765 }; 766 const KK: fn() -> Pubkey = || { 767 Pubkey::from_hex("4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967") 768 .unwrap() 769 }; 770 771 #[derive(PartialEq, Clone, Debug)] 772 struct MentionExample { 773 text: String, 774 mention1: Option<MentionInfo>, 775 mention2: Option<MentionInfo>, 776 mention3: Option<MentionInfo>, 777 mention4: Option<MentionInfo>, 778 } 779 780 fn apply_mention_example(buf: &mut PostBuffer) -> MentionExample { 781 buf.insert_text("test ", 0); 782 buf.insert_text("@jb55", 5); 783 buf.select_full_mention(0, JB55()); 784 buf.insert_text(" test ", 10); 785 buf.insert_text("@vrod", 16); 786 buf.select_full_mention(1, JB55()); 787 buf.insert_text(" test ", 21); 788 buf.insert_text("@elsat", 27); 789 buf.select_full_mention(2, JB55()); 790 buf.insert_text(" test ", 33); 791 buf.insert_text("@kernelkind", 39); 792 buf.select_full_mention(3, KK()); 793 buf.insert_text(" test", 50); 794 795 let mention1_bounds = 5..10; 796 let mention2_bounds = 16..21; 797 let mention3_bounds = 27..33; 798 let mention4_bounds = 39..50; 799 800 let text = "test @jb55 test @vrod test @elsat test @kernelkind test"; 801 802 assert_eq!(buf.as_str(), text); 803 assert_eq!(buf.mentions.len(), 4); 804 805 let mention1 = buf.mentions.get(&0).unwrap(); 806 assert_eq!(mention1.bounds(), mention1_bounds); 807 assert_eq!(mention1.mention_type, MentionType::Finalized(JB55())); 808 let mention2 = buf.mentions.get(&1).unwrap(); 809 assert_eq!(mention2.bounds(), mention2_bounds); 810 assert_eq!(mention2.mention_type, MentionType::Finalized(JB55())); 811 let mention3 = buf.mentions.get(&2).unwrap(); 812 assert_eq!(mention3.bounds(), mention3_bounds); 813 assert_eq!(mention3.mention_type, MentionType::Finalized(JB55())); 814 let mention4 = buf.mentions.get(&3).unwrap(); 815 assert_eq!(mention4.bounds(), mention4_bounds); 816 assert_eq!(mention4.mention_type, MentionType::Finalized(KK())); 817 818 let text = text.to_owned(); 819 MentionExample { 820 text, 821 mention1: Some(mention1.clone()), 822 mention2: Some(mention2.clone()), 823 mention3: Some(mention3.clone()), 824 mention4: Some(mention4.clone()), 825 } 826 } 827 828 impl PostBuffer { 829 fn to_example(&self) -> MentionExample { 830 let mention1 = self.mentions.get(&0).cloned(); 831 let mention2 = self.mentions.get(&1).cloned(); 832 let mention3 = self.mentions.get(&2).cloned(); 833 let mention4 = self.mentions.get(&3).cloned(); 834 835 MentionExample { 836 text: self.text_buffer.clone(), 837 mention1, 838 mention2, 839 mention3, 840 mention4, 841 } 842 } 843 } 844 845 impl MentionInfo { 846 fn shifted(mut self, offset: usize) -> Self { 847 self.end_index -= offset; 848 self.start_index -= offset; 849 850 self 851 } 852 } 853 854 #[test] 855 fn test_extract_hashtags() { 856 let test_cases = vec![ 857 ("Hello #world", vec!["world"]), 858 ("Multiple #tags #in #one post", vec!["tags", "in", "one"]), 859 ("No hashtags here", vec![]), 860 ("#tag1 with #tag2!", vec!["tag1", "tag2"]), 861 ("Ignore # empty", vec![]), 862 ("Testing emoji #🍌banana", vec!["🍌banana"]), 863 ("Testing emoji #🍌", vec!["🍌"]), 864 ("Duplicate #tag #tag #tag", vec!["tag"]), 865 ("Mixed case #TaG #tag #TAG", vec!["tag"]), 866 ( 867 "#tag1, #tag2, #tag3 with commas", 868 vec!["tag1", "tag2", "tag3"], 869 ), 870 ("Separated by commas #tag1,#tag2", vec!["tag1", "tag2"]), 871 ("Separated by periods #tag1.#tag2", vec!["tag1", "tag2"]), 872 ("Separated by semicolons #tag1;#tag2", vec!["tag1", "tag2"]), 873 ]; 874 875 for (input, expected) in test_cases { 876 let result = NewPost::extract_hashtags(input); 877 let expected: HashSet<String> = expected.into_iter().map(String::from).collect(); 878 assert_eq!(result, expected, "Failed for input: {}", input); 879 } 880 } 881 882 #[test] 883 fn test_insert_single_mention() { 884 let mut buf = PostBuffer::default(); 885 buf.insert_text("test ", 0); 886 buf.insert_text("@", 5); 887 assert!(buf.get_mention(5).is_some()); 888 buf.insert_text("jb55", 6); 889 assert_eq!(buf.as_str(), "test @jb55"); 890 assert_eq!(buf.mentions.len(), 1); 891 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 5..10); 892 893 buf.select_full_mention(0, JB55()); 894 895 assert_eq!( 896 buf.mentions.get(&0).unwrap().mention_type, 897 MentionType::Finalized(JB55()) 898 ); 899 } 900 901 #[test] 902 fn test_insert_mention_with_space() { 903 let mut buf = PostBuffer::default(); 904 buf.insert_text("@", 0); 905 buf.insert_text("jb", 1); 906 buf.insert_text("55", 3); 907 assert!(buf.get_mention(1).is_some()); 908 assert_eq!(buf.mentions.len(), 1); 909 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); 910 buf.insert_text(" test", 5); 911 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..10); 912 assert_eq!(buf.as_str(), "@jb55 test"); 913 914 buf.select_full_mention(0, JB55()); 915 916 assert_eq!( 917 buf.mentions.get(&0).unwrap().mention_type, 918 MentionType::Finalized(JB55()) 919 ); 920 } 921 922 #[test] 923 fn test_insert_mention_with_emojis() { 924 let mut buf = PostBuffer::default(); 925 buf.insert_text("test ", 0); 926 buf.insert_text("@test😀 🏴☠️ :D", 5); 927 buf.select_full_mention(0, JB55()); 928 buf.insert_text(" test", 19); 929 930 assert_eq!(buf.as_str(), "test @test😀 🏴☠️ :D test"); 931 let mention = buf.mentions.get(&0).unwrap(); 932 assert_eq!( 933 *mention, 934 MentionInfo { 935 start_index: 5, 936 end_index: 19, 937 mention_type: MentionType::Finalized(JB55()) 938 } 939 ); 940 } 941 942 #[test] 943 fn test_insert_partial_to_full() { 944 let mut buf = PostBuffer::default(); 945 buf.insert_text("@jb", 0); 946 assert_eq!(buf.mentions.len(), 1); 947 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..3); 948 buf.select_mention_and_replace_name(0, "jb55", JB55()); 949 assert_eq!(buf.as_str(), "@jb55 "); 950 951 buf.insert_text("test", 6); 952 assert_eq!(buf.as_str(), "@jb55 test"); 953 954 assert_eq!(buf.mentions.len(), 1); 955 let mention = buf.mentions.get(&0).unwrap(); 956 assert_eq!(mention.bounds(), 0..5); 957 assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); 958 } 959 960 #[test] 961 fn test_insert_mention_after_text() { 962 let mut buf = PostBuffer::default(); 963 buf.insert_text("test text here", 0); 964 buf.insert_text("@jb55", 4); 965 966 assert!(buf.mentions.is_empty()); 967 } 968 969 #[test] 970 fn test_insert_mention_with_space_after_text() { 971 let mut buf = PostBuffer::default(); 972 buf.insert_text("test text here", 0); 973 buf.insert_text("@jb55", 5); 974 975 assert!(buf.get_mention(5).is_some()); 976 assert_eq!(buf.mentions.len(), 1); 977 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 5..10); 978 assert_eq!("test @jb55 text here", buf.as_str()); 979 980 buf.select_full_mention(0, JB55()); 981 982 assert_eq!( 983 buf.mentions.get(&0).unwrap().mention_type, 984 MentionType::Finalized(JB55()) 985 ); 986 } 987 988 #[test] 989 fn test_insert_mention_then_text() { 990 let mut buf = PostBuffer::default(); 991 992 buf.insert_text("@jb55", 0); 993 buf.select_full_mention(0, JB55()); 994 995 buf.insert_text(" test", 5); 996 assert_eq!(buf.as_str(), "@jb55 test"); 997 assert_eq!(buf.mentions.len(), 1); 998 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); 999 assert!(buf.get_mention(6).is_none()); 1000 } 1001 1002 #[test] 1003 fn test_insert_two_mentions() { 1004 let mut buf = PostBuffer::default(); 1005 1006 buf.insert_text("@jb55", 0); 1007 buf.select_full_mention(0, JB55()); 1008 buf.insert_text(" test ", 5); 1009 buf.insert_text("@kernelkind", 11); 1010 buf.select_full_mention(1, KK()); 1011 buf.insert_text(" test", 22); 1012 1013 assert_eq!(buf.as_str(), "@jb55 test @kernelkind test"); 1014 assert_eq!(buf.mentions.len(), 2); 1015 assert_eq!(buf.mentions.get(&0).unwrap().bounds(), 0..5); 1016 assert_eq!(buf.mentions.get(&1).unwrap().bounds(), 11..22); 1017 } 1018 1019 #[test] 1020 fn test_insert_into_mention() { 1021 let mut buf = PostBuffer::default(); 1022 1023 buf.insert_text("@jb55", 0); 1024 buf.select_full_mention(0, JB55()); 1025 buf.insert_text(" test", 5); 1026 1027 assert_eq!(buf.mentions.len(), 1); 1028 let mention = buf.mentions.get(&0).unwrap(); 1029 assert_eq!(mention.bounds(), 0..5); 1030 assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); 1031 1032 buf.insert_text("oops", 2); 1033 assert_eq!(buf.as_str(), "@joopsb55 test"); 1034 assert_eq!(buf.mentions.len(), 1); 1035 let mention = buf.mentions.get(&0).unwrap(); 1036 assert_eq!(mention.bounds(), 0..9); 1037 assert_eq!(mention.mention_type, MentionType::Pending); 1038 } 1039 1040 #[test] 1041 fn test_insert_mention_inside_mention() { 1042 let mut buf = PostBuffer::default(); 1043 1044 buf.insert_text("@jb55", 0); 1045 buf.select_full_mention(0, JB55()); 1046 buf.insert_text(" test", 5); 1047 1048 assert_eq!(buf.mentions.len(), 1); 1049 let mention = buf.mentions.get(&0).unwrap(); 1050 assert_eq!(mention.bounds(), 0..5); 1051 assert_eq!(mention.mention_type, MentionType::Finalized(JB55())); 1052 1053 buf.insert_text(" ", 3); 1054 buf.insert_text("@oops", 4); 1055 assert_eq!(buf.as_str(), "@jb @oops55 test"); 1056 assert_eq!(buf.mentions.len(), 1); 1057 assert_eq!(buf.mention_ends.len(), 1); 1058 assert_eq!(buf.mention_starts.len(), 1); 1059 let mention = buf.mentions.get(&1).unwrap(); 1060 assert_eq!(mention.bounds(), 4..9); 1061 assert_eq!(mention.mention_type, MentionType::Pending); 1062 } 1063 1064 #[test] 1065 fn test_delete_before_mention() { 1066 let mut buf = PostBuffer::default(); 1067 let before = apply_mention_example(&mut buf); 1068 1069 let range = 1..5; 1070 let len = range.len(); 1071 buf.delete_char_range(range); 1072 1073 assert_eq!( 1074 MentionExample { 1075 text: "t@jb55 test @vrod test @elsat test @kernelkind test".to_owned(), 1076 mention1: Some(before.mention1.clone().unwrap().shifted(len)), 1077 mention2: Some(before.mention2.clone().unwrap().shifted(len)), 1078 mention3: Some(before.mention3.clone().unwrap().shifted(len)), 1079 mention4: Some(before.mention4.clone().unwrap().shifted(len)), 1080 }, 1081 buf.to_example(), 1082 ); 1083 } 1084 1085 #[test] 1086 fn test_delete_after_mention() { 1087 let mut buf = PostBuffer::default(); 1088 let before = apply_mention_example(&mut buf); 1089 1090 let range = 11..16; 1091 let len = range.len(); 1092 buf.delete_char_range(range); 1093 1094 assert_eq!( 1095 MentionExample { 1096 text: "test @jb55 @vrod test @elsat test @kernelkind test".to_owned(), 1097 mention2: Some(before.mention2.clone().unwrap().shifted(len)), 1098 mention3: Some(before.mention3.clone().unwrap().shifted(len)), 1099 mention4: Some(before.mention4.clone().unwrap().shifted(len)), 1100 ..before.clone() 1101 }, 1102 buf.to_example(), 1103 ); 1104 } 1105 1106 #[test] 1107 fn test_delete_mention_partial_inner() { 1108 let mut buf = PostBuffer::default(); 1109 let before = apply_mention_example(&mut buf); 1110 1111 let range = 17..20; 1112 let len = range.len(); 1113 buf.delete_char_range(range); 1114 1115 assert_eq!( 1116 MentionExample { 1117 text: "test @jb55 test @d test @elsat test @kernelkind test".to_owned(), 1118 mention2: Some(MentionInfo { 1119 start_index: 16, 1120 end_index: 18, 1121 mention_type: MentionType::Pending, 1122 }), 1123 mention3: Some(before.mention3.clone().unwrap().shifted(len)), 1124 mention4: Some(before.mention4.clone().unwrap().shifted(len)), 1125 ..before.clone() 1126 }, 1127 buf.to_example(), 1128 ); 1129 } 1130 1131 #[test] 1132 fn test_delete_mention_partial_outer() { 1133 let mut buf = PostBuffer::default(); 1134 let before = apply_mention_example(&mut buf); 1135 1136 let range = 17..27; 1137 let len = range.len(); 1138 buf.delete_char_range(range); 1139 1140 assert_eq!( 1141 MentionExample { 1142 text: "test @jb55 test @@elsat test @kernelkind test".to_owned(), 1143 mention2: Some(MentionInfo { 1144 start_index: 16, 1145 end_index: 17, 1146 mention_type: MentionType::Pending 1147 }), 1148 mention3: Some(before.mention3.clone().unwrap().shifted(len)), 1149 mention4: Some(before.mention4.clone().unwrap().shifted(len)), 1150 ..before.clone() 1151 }, 1152 buf.to_example(), 1153 ); 1154 } 1155 1156 #[test] 1157 fn test_delete_mention_partial_and_full() { 1158 let mut buf = PostBuffer::default(); 1159 let before = apply_mention_example(&mut buf); 1160 1161 buf.delete_char_range(17..28); 1162 1163 assert_eq!( 1164 MentionExample { 1165 text: "test @jb55 test @elsat test @kernelkind test".to_owned(), 1166 mention2: Some(MentionInfo { 1167 end_index: 17, 1168 mention_type: MentionType::Pending, 1169 ..before.mention2.clone().unwrap() 1170 }), 1171 mention3: None, 1172 mention4: Some(MentionInfo { 1173 start_index: 28, 1174 end_index: 39, 1175 ..before.mention4.clone().unwrap() 1176 }), 1177 ..before.clone() 1178 }, 1179 buf.to_example() 1180 ) 1181 } 1182 1183 #[test] 1184 fn test_delete_mention_full_one() { 1185 let mut buf = PostBuffer::default(); 1186 let before = apply_mention_example(&mut buf); 1187 1188 let range = 10..26; 1189 let len = range.len(); 1190 buf.delete_char_range(range); 1191 1192 assert_eq!( 1193 MentionExample { 1194 text: "test @jb55 @elsat test @kernelkind test".to_owned(), 1195 mention2: None, 1196 mention3: Some(before.mention3.clone().unwrap().shifted(len)), 1197 mention4: Some(before.mention4.clone().unwrap().shifted(len)), 1198 ..before.clone() 1199 }, 1200 buf.to_example() 1201 ); 1202 } 1203 1204 #[test] 1205 fn test_delete_mention_full_two() { 1206 let mut buf = PostBuffer::default(); 1207 let before = apply_mention_example(&mut buf); 1208 1209 buf.delete_char_range(11..28); 1210 1211 assert_eq!( 1212 MentionExample { 1213 text: "test @jb55 elsat test @kernelkind test".to_owned(), 1214 mention2: None, 1215 mention3: None, 1216 mention4: Some(MentionInfo { 1217 start_index: 22, 1218 end_index: 33, 1219 ..before.mention4.clone().unwrap() 1220 }), 1221 ..before.clone() 1222 }, 1223 buf.to_example() 1224 ) 1225 } 1226 1227 #[test] 1228 fn test_two_then_one_between() { 1229 let mut buf = PostBuffer::default(); 1230 1231 buf.insert_text("@jb", 0); 1232 buf.select_mention_and_replace_name(0, "jb55", JB55()); 1233 buf.insert_text("test ", 6); 1234 assert_eq!(buf.as_str(), "@jb55 test "); 1235 buf.insert_text("@kernel", 11); 1236 buf.select_mention_and_replace_name(1, "KernelKind", KK()); 1237 assert_eq!(buf.as_str(), "@jb55 test @KernelKind "); 1238 1239 buf.insert_text("test", 23); 1240 assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); 1241 1242 assert_eq!(buf.mentions.len(), 2); 1243 1244 buf.insert_text("@els", 6); 1245 assert_eq!(buf.as_str(), "@jb55 @elstest @KernelKind test"); 1246 1247 assert_eq!(buf.mentions.len(), 3); 1248 assert_eq!(buf.mentions.get(&2).unwrap().bounds(), 6..10); 1249 buf.select_mention_and_replace_name(2, "elsat", JB55()); 1250 assert_eq!(buf.as_str(), "@jb55 @elsat test @KernelKind test"); 1251 1252 let jb_mention = buf.mentions.get(&0).unwrap(); 1253 let kk_mention = buf.mentions.get(&1).unwrap(); 1254 let el_mention = buf.mentions.get(&2).unwrap(); 1255 assert_eq!(jb_mention.bounds(), 0..5); 1256 assert_eq!(jb_mention.mention_type, MentionType::Finalized(JB55())); 1257 assert_eq!(kk_mention.bounds(), 18..29); 1258 assert_eq!(kk_mention.mention_type, MentionType::Finalized(KK())); 1259 assert_eq!(el_mention.bounds(), 6..12); 1260 assert_eq!(el_mention.mention_type, MentionType::Finalized(JB55())); 1261 } 1262 1263 #[test] 1264 fn note_single_mention() { 1265 let mut buf = PostBuffer::default(); 1266 buf.insert_text("@jb55", 0); 1267 buf.select_full_mention(0, JB55()); 1268 1269 let out = buf.output(); 1270 let kp = FullKeypair::generate(); 1271 let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); 1272 let note = post.to_note(&kp.pubkey); 1273 1274 let mut tags_iter = note.tags().iter(); 1275 tags_iter.next(); //ignore the first one, the client tag 1276 let tag = tags_iter.next().unwrap(); 1277 assert_eq!(tag.count(), 2); 1278 assert_eq!(tag.get(0).unwrap().str().unwrap(), "p"); 1279 assert_eq!(tag.get(1).unwrap().id().unwrap(), JB55().bytes()); 1280 assert!(tags_iter.next().is_none()); 1281 assert_eq!( 1282 note.content(), 1283 "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s" 1284 ); 1285 } 1286 1287 #[test] 1288 fn note_two_mentions() { 1289 let mut buf = PostBuffer::default(); 1290 1291 buf.insert_text("@jb55", 0); 1292 buf.select_full_mention(0, JB55()); 1293 buf.insert_text(" test ", 5); 1294 buf.insert_text("@KernelKind", 11); 1295 buf.select_full_mention(1, KK()); 1296 buf.insert_text(" test", 22); 1297 assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); 1298 1299 let out = buf.output(); 1300 let kp = FullKeypair::generate(); 1301 let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); 1302 let note = post.to_note(&kp.pubkey); 1303 1304 let mut tags_iter = note.tags().iter(); 1305 tags_iter.next(); //ignore the first one, the client tag 1306 let jb_tag = tags_iter.next().unwrap(); 1307 assert_eq!(jb_tag.count(), 2); 1308 assert_eq!(jb_tag.get(0).unwrap().str().unwrap(), "p"); 1309 assert_eq!(jb_tag.get(1).unwrap().id().unwrap(), JB55().bytes()); 1310 1311 let kk_tag = tags_iter.next().unwrap(); 1312 assert_eq!(kk_tag.count(), 2); 1313 assert_eq!(kk_tag.get(0).unwrap().str().unwrap(), "p"); 1314 assert_eq!(kk_tag.get(1).unwrap().id().unwrap(), KK().bytes()); 1315 1316 assert!(tags_iter.next().is_none()); 1317 1318 assert_eq!(note.content(), "nostr:npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s test nostr:npub1fgz3pungsr2quse0fpjuk4c5m8fuyqx2d6a3ddqc4ek92h6hf9ns0mjeck test"); 1319 } 1320 1321 #[test] 1322 fn note_one_pending() { 1323 let mut buf = PostBuffer::default(); 1324 1325 buf.insert_text("test ", 0); 1326 buf.insert_text("@jb55 test", 5); 1327 1328 let out = buf.output(); 1329 let kp = FullKeypair::generate(); 1330 let post = NewPost::new(out.text, kp.clone(), Vec::new(), out.mentions); 1331 let note = post.to_note(&kp.pubkey); 1332 1333 let mut tags_iter = note.tags().iter(); 1334 tags_iter.next(); //ignore the first one, the client tag 1335 assert!(tags_iter.next().is_none()); 1336 assert_eq!(note.content(), "test @jb55 test"); 1337 } 1338 }