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