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