notedeck

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

post.rs (25101B)


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