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