post.rs (25796B)
1 use crate::draft::{Draft, Drafts, MentionHint}; 2 use crate::gif::{handle_repaint, retrieve_latest_texture}; 3 use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; 4 use crate::post::{downcast_post_buffer, MentionType, NewPost}; 5 use crate::profile::get_display_name; 6 use crate::ui::images::render_images; 7 use crate::ui::search_results::SearchResultsView; 8 use crate::ui::{self, Preview, PreviewConfig}; 9 use crate::Result; 10 use egui::text::{CCursorRange, LayoutJob}; 11 use egui::text_edit::TextEditOutput; 12 use egui::widgets::text_edit::TextEdit; 13 use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense, TextBuffer}; 14 use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; 15 use nostrdb::{Ndb, Transaction}; 16 17 use notedeck::supported_mime_hosted_at_url; 18 use tracing::error; 19 20 use super::contents::{render_note_preview, NoteContext}; 21 use super::NoteContextSelection; 22 use super::NoteOptions; 23 24 pub struct PostView<'a, 'd> { 25 note_context: &'a mut NoteContext<'d>, 26 draft: &'a mut Draft, 27 post_type: PostType, 28 poster: FilledKeypair<'a>, 29 id_source: Option<egui::Id>, 30 inner_rect: egui::Rect, 31 note_options: NoteOptions, 32 } 33 34 #[derive(Clone)] 35 pub enum PostType { 36 New, 37 Quote(NoteId), 38 Reply(NoteId), 39 } 40 41 pub struct PostAction { 42 post_type: PostType, 43 post: NewPost, 44 } 45 46 impl PostAction { 47 pub fn new(post_type: PostType, post: NewPost) -> Self { 48 PostAction { post_type, post } 49 } 50 51 pub fn execute( 52 &self, 53 ndb: &Ndb, 54 txn: &Transaction, 55 pool: &mut RelayPool, 56 drafts: &mut Drafts, 57 ) -> Result<()> { 58 let seckey = self.post.account.secret_key.to_secret_bytes(); 59 60 let note = match self.post_type { 61 PostType::New => self.post.to_note(&seckey), 62 63 PostType::Reply(target) => { 64 let replying_to = ndb.get_note_by_id(txn, target.bytes())?; 65 self.post.to_reply(&seckey, &replying_to) 66 } 67 68 PostType::Quote(target) => { 69 let quoting = ndb.get_note_by_id(txn, target.bytes())?; 70 self.post.to_quote(&seckey, "ing) 71 } 72 }; 73 74 pool.send(&enostr::ClientMessage::event(note)?); 75 drafts.get_from_post_type(&self.post_type).clear(); 76 77 Ok(()) 78 } 79 } 80 81 pub struct PostResponse { 82 pub action: Option<PostAction>, 83 pub edit_response: egui::Response, 84 pub context_selection: Option<NoteContextSelection>, 85 } 86 87 impl<'a, 'd> PostView<'a, 'd> { 88 #[allow(clippy::too_many_arguments)] 89 pub fn new( 90 note_context: &'a mut NoteContext<'d>, 91 draft: &'a mut Draft, 92 post_type: PostType, 93 poster: FilledKeypair<'a>, 94 inner_rect: egui::Rect, 95 note_options: NoteOptions, 96 ) -> Self { 97 let id_source: Option<egui::Id> = None; 98 PostView { 99 note_context, 100 draft, 101 poster, 102 id_source, 103 post_type, 104 inner_rect, 105 note_options, 106 } 107 } 108 109 pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self { 110 self.id_source = Some(egui::Id::new(id_source)); 111 self 112 } 113 114 fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response { 115 ui.spacing_mut().item_spacing.x = 12.0; 116 117 let pfp_size = 24.0; 118 119 // TODO: refactor pfp control to do all of this for us 120 let poster_pfp = self 121 .note_context 122 .ndb 123 .get_profile_by_pubkey(txn, self.poster.pubkey.bytes()) 124 .as_ref() 125 .ok() 126 .and_then(|p| { 127 Some(ui::ProfilePic::from_profile(self.note_context.img_cache, p)?.size(pfp_size)) 128 }); 129 130 if let Some(pfp) = poster_pfp { 131 ui.add(pfp); 132 } else { 133 ui.add( 134 ui::ProfilePic::new(self.note_context.img_cache, ui::ProfilePic::no_pfp_url()) 135 .size(pfp_size), 136 ); 137 } 138 139 let mut updated_layout = false; 140 let mut layouter = |ui: &egui::Ui, buf: &dyn TextBuffer, wrap_width: f32| { 141 if let Some(post_buffer) = downcast_post_buffer(buf) { 142 let maybe_job = if post_buffer.need_new_layout(self.draft.cur_layout.as_ref()) { 143 Some(post_buffer.to_layout_job(ui)) 144 } else { 145 None 146 }; 147 148 if let Some(job) = maybe_job { 149 self.draft.cur_layout = Some((post_buffer.text_buffer.clone(), job)); 150 updated_layout = true; 151 } 152 }; 153 154 let mut layout_job = if let Some((_, job)) = &self.draft.cur_layout { 155 job.clone() 156 } else { 157 error!("Failed to get custom mentions layouter"); 158 text_edit_default_layout(ui, buf.as_str().to_owned(), wrap_width) 159 }; 160 161 layout_job.wrap.max_width = wrap_width; 162 ui.fonts(|f| f.layout_job(layout_job)) 163 }; 164 165 let textedit = TextEdit::multiline(&mut self.draft.buffer) 166 .hint_text(egui::RichText::new("Write a banger note here...").weak()) 167 .frame(false) 168 .desired_width(ui.available_width()) 169 .layouter(&mut layouter); 170 171 let out = textedit.show(ui); 172 173 if updated_layout { 174 self.draft.buffer.selected_mention = false; 175 } 176 177 if let Some(cursor_index) = get_cursor_index(&out.state.cursor.char_range()) { 178 self.show_mention_hints(txn, ui, cursor_index, &out); 179 } 180 181 let focused = out.response.has_focus(); 182 183 ui.ctx().data_mut(|d| d.insert_temp(self.id(), focused)); 184 185 out.response 186 } 187 188 fn show_mention_hints( 189 &mut self, 190 txn: &nostrdb::Transaction, 191 ui: &mut egui::Ui, 192 cursor_index: usize, 193 textedit_output: &TextEditOutput, 194 ) { 195 let mention = if let Some(mention) = self.draft.buffer.get_mention(cursor_index) { 196 mention 197 } else { 198 return; 199 }; 200 201 if mention.info.mention_type != MentionType::Pending { 202 return; 203 } 204 205 if ui.ctx().input(|r| r.key_pressed(egui::Key::Escape)) { 206 self.draft.buffer.delete_mention(mention.index); 207 return; 208 } 209 210 let mention_str = self.draft.buffer.get_mention_string(&mention); 211 212 if !mention_str.is_empty() { 213 if let Some(mention_hint) = &mut self.draft.cur_mention_hint { 214 if mention_hint.index != mention.index { 215 mention_hint.index = mention.index; 216 mention_hint.pos = 217 calculate_mention_hints_pos(textedit_output, mention.info.start_index); 218 } 219 mention_hint.text = mention_str.to_owned(); 220 } else { 221 self.draft.cur_mention_hint = Some(MentionHint { 222 index: mention.index, 223 text: mention_str.to_owned(), 224 pos: calculate_mention_hints_pos(textedit_output, mention.info.start_index), 225 }); 226 } 227 } 228 229 let hint_rect = { 230 let hint = if let Some(hint) = &self.draft.cur_mention_hint { 231 hint 232 } else { 233 return; 234 }; 235 236 let mut hint_rect = self.inner_rect; 237 hint_rect.set_top(hint.pos.y); 238 hint_rect 239 }; 240 241 let res = if let Ok(res) = self.note_context.ndb.search_profile(txn, mention_str, 10) { 242 res 243 } else { 244 return; 245 }; 246 247 let resp = SearchResultsView::new( 248 self.note_context.img_cache, 249 self.note_context.ndb, 250 txn, 251 &res, 252 ) 253 .show_in_rect(hint_rect, ui); 254 255 match resp { 256 ui::search_results::SearchResultsResponse::SelectResult(selection) => { 257 if let Some(hint_index) = selection { 258 if let Some(pk) = res.get(hint_index) { 259 let record = self.note_context.ndb.get_profile_by_pubkey(txn, pk); 260 261 self.draft.buffer.select_mention_and_replace_name( 262 mention.index, 263 get_display_name(record.ok().as_ref()).name(), 264 Pubkey::new(**pk), 265 ); 266 self.draft.cur_mention_hint = None; 267 } 268 } 269 } 270 271 ui::search_results::SearchResultsResponse::DeleteMention => { 272 self.draft.buffer.delete_mention(mention.index) 273 } 274 } 275 } 276 277 fn focused(&self, ui: &egui::Ui) -> bool { 278 ui.ctx() 279 .data(|d| d.get_temp::<bool>(self.id()).unwrap_or(false)) 280 } 281 282 fn id(&self) -> egui::Id { 283 self.id_source.unwrap_or_else(|| egui::Id::new("post")) 284 } 285 286 pub fn outer_margin() -> i8 { 287 16 288 } 289 290 pub fn inner_margin() -> i8 { 291 12 292 } 293 294 pub fn ui(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> PostResponse { 295 let focused = self.focused(ui); 296 let stroke = if focused { 297 ui.visuals().selection.stroke 298 } else { 299 ui.visuals().noninteractive().bg_stroke 300 }; 301 302 let mut frame = egui::Frame::default() 303 .inner_margin(egui::Margin::same(PostView::inner_margin())) 304 .outer_margin(egui::Margin::same(PostView::outer_margin())) 305 .fill(ui.visuals().extreme_bg_color) 306 .stroke(stroke) 307 .rounding(12.0); 308 309 if focused { 310 frame = frame.shadow(egui::epaint::Shadow { 311 offset: [0, 0], 312 blur: 8, 313 spread: 0, 314 color: stroke.color, 315 }); 316 } 317 318 frame 319 .show(ui, |ui| { 320 ui.vertical(|ui| { 321 let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; 322 let mut context_selection = None; 323 324 if let PostType::Quote(id) = self.post_type { 325 let avail_size = ui.available_size_before_wrap(); 326 ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { 327 context_selection = Frame::NONE 328 .show(ui, |ui| { 329 ui.vertical(|ui| { 330 ui.set_max_width(avail_size.x * 0.8); 331 let resp = render_note_preview( 332 ui, 333 self.note_context, 334 txn, 335 id.bytes(), 336 nostrdb::NoteKey::new(0), 337 self.note_options, 338 ); 339 resp 340 }) 341 .inner 342 .context_selection 343 }) 344 .inner; 345 }); 346 } 347 348 Frame::new() 349 .inner_margin(Margin::symmetric(0, 8)) 350 .show(ui, |ui| { 351 ScrollArea::horizontal().show(ui, |ui| { 352 ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| { 353 ui.add_space(4.0); 354 self.show_media(ui); 355 }); 356 }); 357 }); 358 359 self.transfer_uploads(ui); 360 self.show_upload_errors(ui); 361 362 let action = ui 363 .horizontal(|ui| { 364 ui.with_layout( 365 egui::Layout::left_to_right(egui::Align::BOTTOM), 366 |ui| { 367 self.show_upload_media_button(ui); 368 }, 369 ); 370 371 ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| { 372 let post_button_clicked = ui 373 .add_sized( 374 [91.0, 32.0], 375 post_button(!self.draft.buffer.is_empty()), 376 ) 377 .clicked(); 378 379 let shortcut_pressed = ui.input(|i| { 380 (i.modifiers.ctrl || i.modifiers.command) 381 && i.key_pressed(egui::Key::Enter) 382 }); 383 384 if post_button_clicked 385 || (!self.draft.buffer.is_empty() && shortcut_pressed) 386 { 387 let output = self.draft.buffer.output(); 388 let new_post = NewPost::new( 389 output.text, 390 self.poster.to_full(), 391 self.draft.uploaded_media.clone(), 392 output.mentions, 393 ); 394 Some(PostAction::new(self.post_type.clone(), new_post)) 395 } else { 396 None 397 } 398 }) 399 .inner 400 }) 401 .inner; 402 403 PostResponse { 404 action, 405 edit_response, 406 context_selection, 407 } 408 }) 409 .inner 410 }) 411 .inner 412 } 413 414 fn show_media(&mut self, ui: &mut egui::Ui) { 415 let mut to_remove = Vec::new(); 416 for (i, media) in self.draft.uploaded_media.iter().enumerate() { 417 let (width, height) = if let Some(dims) = media.dimensions { 418 (dims.0, dims.1) 419 } else { 420 (300, 300) 421 }; 422 423 if let Some(cache_type) = 424 supported_mime_hosted_at_url(&mut self.note_context.img_cache.urls, &media.url) 425 { 426 render_images( 427 ui, 428 self.note_context.img_cache, 429 &media.url, 430 crate::images::ImageType::Content(width, height), 431 cache_type, 432 |ui| { 433 ui.spinner(); 434 }, 435 |_, e| { 436 self.draft.upload_errors.push(e.to_string()); 437 error!("{e}"); 438 }, 439 |ui, url, renderable_media, gifs| { 440 let media_size = vec2(width as f32, height as f32); 441 let max_size = vec2(300.0, 300.0); 442 let size = if media_size.x > max_size.x || media_size.y > max_size.y { 443 max_size 444 } else { 445 media_size 446 }; 447 448 let texture_handle = handle_repaint( 449 ui, 450 retrieve_latest_texture(url, gifs, renderable_media), 451 ); 452 let img_resp = ui.add( 453 egui::Image::new(texture_handle) 454 .max_size(size) 455 .rounding(12.0), 456 ); 457 458 let remove_button_rect = { 459 let top_left = img_resp.rect.left_top(); 460 let spacing = 13.0; 461 let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); 462 egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) 463 }; 464 if show_remove_upload_button(ui, remove_button_rect).clicked() { 465 to_remove.push(i); 466 } 467 ui.advance_cursor_after_rect(img_resp.rect); 468 }, 469 ); 470 } else { 471 self.draft 472 .upload_errors 473 .push("Uploaded media is not supported.".to_owned()); 474 error!("Unsupported mime type at url: {}", &media.url); 475 } 476 } 477 to_remove.reverse(); 478 for i in to_remove { 479 self.draft.uploaded_media.remove(i); 480 } 481 } 482 483 fn show_upload_media_button(&mut self, ui: &mut egui::Ui) { 484 if ui.add(media_upload_button()).clicked() { 485 #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] 486 { 487 if let Some(files) = rfd::FileDialog::new().pick_files() { 488 for file in files { 489 match MediaPath::new(file) { 490 Ok(media_path) => { 491 let promise = nostrbuild_nip96_upload( 492 self.poster.secret_key.secret_bytes(), 493 media_path, 494 ); 495 self.draft.uploading_media.push(promise); 496 } 497 Err(e) => { 498 error!("{e}"); 499 self.draft.upload_errors.push(e.to_string()); 500 } 501 } 502 } 503 } 504 } 505 } 506 } 507 508 fn transfer_uploads(&mut self, ui: &mut egui::Ui) { 509 let mut indexes_to_remove = Vec::new(); 510 for (i, promise) in self.draft.uploading_media.iter().enumerate() { 511 match promise.ready() { 512 Some(Ok(media)) => { 513 self.draft.uploaded_media.push(media.clone()); 514 indexes_to_remove.push(i); 515 } 516 Some(Err(e)) => { 517 self.draft.upload_errors.push(e.to_string()); 518 error!("{e}"); 519 } 520 None => { 521 ui.spinner(); 522 } 523 } 524 } 525 526 indexes_to_remove.reverse(); 527 for i in indexes_to_remove { 528 let _ = self.draft.uploading_media.remove(i); 529 } 530 } 531 532 fn show_upload_errors(&mut self, ui: &mut egui::Ui) { 533 let mut to_remove = Vec::new(); 534 for (i, error) in self.draft.upload_errors.iter().enumerate() { 535 if ui 536 .add( 537 egui::Label::new(egui::RichText::new(error).color(ui.visuals().warn_fg_color)) 538 .sense(Sense::click()) 539 .selectable(false), 540 ) 541 .on_hover_text_at_pointer("Dismiss") 542 .clicked() 543 { 544 to_remove.push(i); 545 } 546 } 547 to_remove.reverse(); 548 549 for i in to_remove { 550 self.draft.upload_errors.remove(i); 551 } 552 } 553 } 554 555 fn post_button(interactive: bool) -> impl egui::Widget { 556 move |ui: &mut egui::Ui| { 557 let button = egui::Button::new("Post now"); 558 if interactive { 559 ui.add(button) 560 } else { 561 ui.add( 562 button 563 .sense(egui::Sense::hover()) 564 .fill(ui.visuals().widgets.noninteractive.bg_fill) 565 .stroke(ui.visuals().widgets.noninteractive.bg_stroke), 566 ) 567 .on_hover_cursor(egui::CursorIcon::NotAllowed) 568 } 569 } 570 } 571 572 fn media_upload_button() -> impl egui::Widget { 573 |ui: &mut egui::Ui| -> egui::Response { 574 let resp = ui.allocate_response(egui::vec2(32.0, 32.0), egui::Sense::click()); 575 let painter = ui.painter(); 576 let (fill_color, stroke) = if resp.hovered() { 577 ( 578 ui.visuals().widgets.hovered.bg_fill, 579 ui.visuals().widgets.hovered.bg_stroke, 580 ) 581 } else if resp.clicked() { 582 ( 583 ui.visuals().widgets.active.bg_fill, 584 ui.visuals().widgets.active.bg_stroke, 585 ) 586 } else { 587 ( 588 ui.visuals().widgets.inactive.bg_fill, 589 ui.visuals().widgets.inactive.bg_stroke, 590 ) 591 }; 592 593 painter.rect_filled(resp.rect, 8.0, fill_color); 594 painter.rect_stroke(resp.rect, 8.0, stroke); 595 egui::Image::new(egui::include_image!( 596 "../../../../../assets/icons/media_upload_dark_4x.png" 597 )) 598 .max_size(egui::vec2(16.0, 16.0)) 599 .paint_at(ui, resp.rect.shrink(8.0)); 600 resp 601 } 602 } 603 604 fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egui::Response { 605 let resp = ui.allocate_rect(desired_rect, egui::Sense::click()); 606 let size = 24.0; 607 let (fill_color, stroke) = if resp.hovered() { 608 ( 609 ui.visuals().widgets.hovered.bg_fill, 610 ui.visuals().widgets.hovered.bg_stroke, 611 ) 612 } else if resp.clicked() { 613 ( 614 ui.visuals().widgets.active.bg_fill, 615 ui.visuals().widgets.active.bg_stroke, 616 ) 617 } else { 618 ( 619 ui.visuals().widgets.inactive.bg_fill, 620 ui.visuals().widgets.inactive.bg_stroke, 621 ) 622 }; 623 let center = desired_rect.center(); 624 let painter = ui.painter_at(desired_rect); 625 let radius = size / 2.0; 626 627 painter.circle_filled(center, radius, fill_color); 628 painter.circle_stroke(center, radius, stroke); 629 630 painter.line_segment( 631 [ 632 Pos2::new(center.x - 4.0, center.y - 4.0), 633 Pos2::new(center.x + 4.0, center.y + 4.0), 634 ], 635 egui::Stroke::new(1.33, ui.visuals().text_color()), 636 ); 637 638 painter.line_segment( 639 [ 640 Pos2::new(center.x + 4.0, center.y - 4.0), 641 Pos2::new(center.x - 4.0, center.y + 4.0), 642 ], 643 egui::Stroke::new(1.33, ui.visuals().text_color()), 644 ); 645 resp 646 } 647 648 fn get_cursor_index(cursor: &Option<CCursorRange>) -> Option<usize> { 649 let range = cursor.as_ref()?; 650 651 if range.primary.index == range.secondary.index { 652 Some(range.primary.index) 653 } else { 654 None 655 } 656 } 657 658 fn calculate_mention_hints_pos(out: &TextEditOutput, char_pos: usize) -> egui::Pos2 { 659 let mut cur_pos = 0; 660 661 for row in &out.galley.rows { 662 if cur_pos + row.glyphs.len() <= char_pos { 663 cur_pos += row.glyphs.len(); 664 } else if let Some(glyph) = row.glyphs.get(char_pos - cur_pos) { 665 let mut pos = glyph.pos + out.galley_pos.to_vec2(); 666 pos.y += row.rect.height(); 667 return pos; 668 } 669 } 670 671 out.text_clip_rect.left_bottom() 672 } 673 674 fn text_edit_default_layout(ui: &egui::Ui, text: String, wrap_width: f32) -> LayoutJob { 675 LayoutJob::simple( 676 text, 677 egui::FontSelection::default().resolve(ui.style()), 678 ui.visuals() 679 .override_text_color 680 .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()), 681 wrap_width, 682 ) 683 } 684 685 mod preview { 686 687 use crate::media_upload::Nip94Event; 688 689 use super::*; 690 use notedeck::{App, AppContext}; 691 692 pub struct PostPreview { 693 draft: Draft, 694 poster: FullKeypair, 695 } 696 697 impl PostPreview { 698 fn new() -> Self { 699 let mut draft = Draft::new(); 700 // can use any url here 701 draft.uploaded_media.push(Nip94Event::new( 702 "https://image.nostr.build/41b40657dd6abf7c275dffc86b29bd863e9337a74870d4ee1c33a72a91c9d733.jpg".to_owned(), 703 612, 704 407, 705 )); 706 draft.uploaded_media.push(Nip94Event::new( 707 "https://image.nostr.build/thumb/fdb46182b039d29af0f5eac084d4d30cd4ad2580ea04fe6c7e79acfe095f9852.png".to_owned(), 708 80, 709 80, 710 )); 711 draft.uploaded_media.push(Nip94Event::new( 712 "https://i.nostr.build/7EznpHsnBZ36Akju.png".to_owned(), 713 2438, 714 1476, 715 )); 716 draft.uploaded_media.push(Nip94Event::new( 717 "https://i.nostr.build/qCCw8szrjTydTiMV.png".to_owned(), 718 2002, 719 2272, 720 )); 721 PostPreview { 722 draft, 723 poster: FullKeypair::generate(), 724 } 725 } 726 } 727 728 impl App for PostPreview { 729 fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) { 730 let txn = Transaction::new(app.ndb).expect("txn"); 731 let mut note_context = NoteContext { 732 ndb: app.ndb, 733 img_cache: app.img_cache, 734 note_cache: app.note_cache, 735 }; 736 737 PostView::new( 738 &mut note_context, 739 &mut self.draft, 740 PostType::New, 741 self.poster.to_filled(), 742 ui.available_rect_before_wrap(), 743 NoteOptions::default(), 744 ) 745 .ui(&txn, ui); 746 } 747 } 748 749 impl Preview for PostView<'_, '_> { 750 type Prev = PostPreview; 751 752 fn preview(_cfg: PreviewConfig) -> Self::Prev { 753 PostPreview::new() 754 } 755 } 756 }