notedeck

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

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, &quoting)
     90             }
     91         };
     92 
     93         let event = enostr::ClientMessage::event(&note)?;
     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(&note, 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 }