notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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, &quoting)
     89             }
     90         };
     91 
     92         pool.send(&enostr::ClientMessage::event(&note)?);
     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 }