notedeck

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

post.rs (26974B)


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