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