notedeck

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

post.rs (26550B)


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