notedeck

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

post.rs (25796B)


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