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