notedeck

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

post.rs (18036B)


      1 use crate::draft::{Draft, Drafts};
      2 use crate::images::fetch_img;
      3 use crate::media_upload::{nostrbuild_nip96_upload, MediaPath};
      4 use crate::post::NewPost;
      5 use crate::ui::{self, Preview, PreviewConfig};
      6 use crate::Result;
      7 use egui::widgets::text_edit::TextEdit;
      8 use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense};
      9 use enostr::{FilledKeypair, FullKeypair, NoteId, RelayPool};
     10 use nostrdb::{Ndb, Transaction};
     11 
     12 use notedeck::{ImageCache, NoteCache};
     13 use tracing::error;
     14 
     15 use super::contents::render_note_preview;
     16 
     17 pub struct PostView<'a> {
     18     ndb: &'a Ndb,
     19     draft: &'a mut Draft,
     20     post_type: PostType,
     21     img_cache: &'a mut ImageCache,
     22     note_cache: &'a mut NoteCache,
     23     poster: FilledKeypair<'a>,
     24     id_source: Option<egui::Id>,
     25 }
     26 
     27 #[derive(Clone)]
     28 pub enum PostType {
     29     New,
     30     Quote(NoteId),
     31     Reply(NoteId),
     32 }
     33 
     34 pub struct PostAction {
     35     post_type: PostType,
     36     post: NewPost,
     37 }
     38 
     39 impl PostAction {
     40     pub fn new(post_type: PostType, post: NewPost) -> Self {
     41         PostAction { post_type, post }
     42     }
     43 
     44     pub fn execute(
     45         &self,
     46         ndb: &Ndb,
     47         txn: &Transaction,
     48         pool: &mut RelayPool,
     49         drafts: &mut Drafts,
     50     ) -> Result<()> {
     51         let seckey = self.post.account.secret_key.to_secret_bytes();
     52 
     53         let note = match self.post_type {
     54             PostType::New => self.post.to_note(&seckey),
     55 
     56             PostType::Reply(target) => {
     57                 let replying_to = ndb.get_note_by_id(txn, target.bytes())?;
     58                 self.post.to_reply(&seckey, &replying_to)
     59             }
     60 
     61             PostType::Quote(target) => {
     62                 let quoting = ndb.get_note_by_id(txn, target.bytes())?;
     63                 self.post.to_quote(&seckey, &quoting)
     64             }
     65         };
     66 
     67         pool.send(&enostr::ClientMessage::event(note)?);
     68         drafts.get_from_post_type(&self.post_type).clear();
     69 
     70         Ok(())
     71     }
     72 }
     73 
     74 pub struct PostResponse {
     75     pub action: Option<PostAction>,
     76     pub edit_response: egui::Response,
     77 }
     78 
     79 impl<'a> PostView<'a> {
     80     pub fn new(
     81         ndb: &'a Ndb,
     82         draft: &'a mut Draft,
     83         post_type: PostType,
     84         img_cache: &'a mut ImageCache,
     85         note_cache: &'a mut NoteCache,
     86         poster: FilledKeypair<'a>,
     87     ) -> Self {
     88         let id_source: Option<egui::Id> = None;
     89         PostView {
     90             ndb,
     91             draft,
     92             img_cache,
     93             note_cache,
     94             poster,
     95             id_source,
     96             post_type,
     97         }
     98     }
     99 
    100     pub fn id_source(mut self, id_source: impl std::hash::Hash) -> Self {
    101         self.id_source = Some(egui::Id::new(id_source));
    102         self
    103     }
    104 
    105     fn editbox(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> egui::Response {
    106         ui.spacing_mut().item_spacing.x = 12.0;
    107 
    108         let pfp_size = 24.0;
    109 
    110         // TODO: refactor pfp control to do all of this for us
    111         let poster_pfp = self
    112             .ndb
    113             .get_profile_by_pubkey(txn, self.poster.pubkey.bytes())
    114             .as_ref()
    115             .ok()
    116             .and_then(|p| Some(ui::ProfilePic::from_profile(self.img_cache, p)?.size(pfp_size)));
    117 
    118         if let Some(pfp) = poster_pfp {
    119             ui.add(pfp);
    120         } else {
    121             ui.add(
    122                 ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()).size(pfp_size),
    123             );
    124         }
    125 
    126         let response = ui.add_sized(
    127             ui.available_size(),
    128             TextEdit::multiline(&mut self.draft.buffer)
    129                 .hint_text(egui::RichText::new("Write a banger note here...").weak())
    130                 .frame(false),
    131         );
    132 
    133         let focused = response.has_focus();
    134 
    135         ui.ctx().data_mut(|d| d.insert_temp(self.id(), focused));
    136 
    137         response
    138     }
    139 
    140     fn focused(&self, ui: &egui::Ui) -> bool {
    141         ui.ctx()
    142             .data(|d| d.get_temp::<bool>(self.id()).unwrap_or(false))
    143     }
    144 
    145     fn id(&self) -> egui::Id {
    146         self.id_source.unwrap_or_else(|| egui::Id::new("post"))
    147     }
    148 
    149     pub fn outer_margin() -> f32 {
    150         16.0
    151     }
    152 
    153     pub fn inner_margin() -> f32 {
    154         12.0
    155     }
    156 
    157     pub fn ui(&mut self, txn: &nostrdb::Transaction, ui: &mut egui::Ui) -> PostResponse {
    158         let focused = self.focused(ui);
    159         let stroke = if focused {
    160             ui.visuals().selection.stroke
    161         } else {
    162             ui.visuals().noninteractive().bg_stroke
    163         };
    164 
    165         let mut frame = egui::Frame::default()
    166             .inner_margin(egui::Margin::same(PostView::inner_margin()))
    167             .outer_margin(egui::Margin::same(PostView::outer_margin()))
    168             .fill(ui.visuals().extreme_bg_color)
    169             .stroke(stroke)
    170             .rounding(12.0);
    171 
    172         if focused {
    173             frame = frame.shadow(egui::epaint::Shadow {
    174                 offset: egui::vec2(0.0, 0.0),
    175                 blur: 8.0,
    176                 spread: 0.0,
    177                 color: stroke.color,
    178             });
    179         }
    180 
    181         frame
    182             .show(ui, |ui| {
    183                 ui.vertical(|ui| {
    184                     let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner;
    185 
    186                     if let PostType::Quote(id) = self.post_type {
    187                         let avail_size = ui.available_size_before_wrap();
    188                         ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| {
    189                             Frame::none().show(ui, |ui| {
    190                                 ui.vertical(|ui| {
    191                                     ui.set_max_width(avail_size.x * 0.8);
    192                                     render_note_preview(
    193                                         ui,
    194                                         self.ndb,
    195                                         self.note_cache,
    196                                         self.img_cache,
    197                                         txn,
    198                                         id.bytes(),
    199                                         nostrdb::NoteKey::new(0),
    200                                     );
    201                                 });
    202                             });
    203                         });
    204                     }
    205 
    206                     Frame::none()
    207                         .inner_margin(Margin::symmetric(0.0, 8.0))
    208                         .show(ui, |ui| {
    209                             ScrollArea::horizontal().show(ui, |ui| {
    210                                 ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| {
    211                                     ui.add_space(4.0);
    212                                     self.show_media(ui);
    213                                 });
    214                             });
    215                         });
    216 
    217                     self.transfer_uploads(ui);
    218                     self.show_upload_errors(ui);
    219 
    220                     let action = ui
    221                         .horizontal(|ui| {
    222                             ui.with_layout(
    223                                 egui::Layout::left_to_right(egui::Align::BOTTOM),
    224                                 |ui| {
    225                                     self.show_upload_media_button(ui);
    226                                 },
    227                             );
    228 
    229                             ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| {
    230                                 if ui
    231                                     .add_sized(
    232                                         [91.0, 32.0],
    233                                         post_button(!self.draft.buffer.is_empty()),
    234                                     )
    235                                     .clicked()
    236                                 {
    237                                     let new_post = NewPost::new(
    238                                         self.draft.buffer.clone(),
    239                                         self.poster.to_full(),
    240                                         self.draft.uploaded_media.clone(),
    241                                     );
    242                                     Some(PostAction::new(self.post_type.clone(), new_post))
    243                                 } else {
    244                                     None
    245                                 }
    246                             })
    247                             .inner
    248                         })
    249                         .inner;
    250 
    251                     PostResponse {
    252                         action,
    253                         edit_response,
    254                     }
    255                 })
    256                 .inner
    257             })
    258             .inner
    259     }
    260 
    261     fn show_media(&mut self, ui: &mut egui::Ui) {
    262         let mut to_remove = Vec::new();
    263         for (i, media) in self.draft.uploaded_media.iter().enumerate() {
    264             let (width, height) = if let Some(dims) = media.dimensions {
    265                 (dims.0, dims.1)
    266             } else {
    267                 (300, 300)
    268             };
    269             let m_cached_promise = self.img_cache.map().get(&media.url);
    270             if m_cached_promise.is_none() {
    271                 let promise = fetch_img(
    272                     &self.img_cache,
    273                     ui.ctx(),
    274                     &media.url,
    275                     crate::images::ImageType::Content(width, height),
    276                 );
    277                 self.img_cache
    278                     .map_mut()
    279                     .insert(media.url.to_owned(), promise);
    280             }
    281 
    282             match self.img_cache.map()[&media.url].ready() {
    283                 Some(Ok(texture)) => {
    284                     let media_size = vec2(width as f32, height as f32);
    285                     let max_size = vec2(300.0, 300.0);
    286                     let size = if media_size.x > max_size.x || media_size.y > max_size.y {
    287                         max_size
    288                     } else {
    289                         media_size
    290                     };
    291 
    292                     let img_resp = ui.add(egui::Image::new(texture).max_size(size).rounding(12.0));
    293 
    294                     let remove_button_rect = {
    295                         let top_left = img_resp.rect.left_top();
    296                         let spacing = 13.0;
    297                         let center = Pos2::new(top_left.x + spacing, top_left.y + spacing);
    298                         egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0))
    299                     };
    300                     if show_remove_upload_button(ui, remove_button_rect).clicked() {
    301                         to_remove.push(i);
    302                     }
    303                     ui.advance_cursor_after_rect(img_resp.rect);
    304                 }
    305                 Some(Err(e)) => {
    306                     self.draft.upload_errors.push(e.to_string());
    307                     error!("{e}");
    308                 }
    309                 None => {
    310                     ui.spinner();
    311                 }
    312             }
    313         }
    314         to_remove.reverse();
    315         for i in to_remove {
    316             self.draft.uploaded_media.remove(i);
    317         }
    318     }
    319 
    320     fn show_upload_media_button(&mut self, ui: &mut egui::Ui) {
    321         if ui.add(media_upload_button()).clicked() {
    322             #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
    323             {
    324                 if let Some(file) = rfd::FileDialog::new().pick_file() {
    325                     match MediaPath::new(file) {
    326                         Ok(media_path) => {
    327                             let promise = nostrbuild_nip96_upload(
    328                                 self.poster.secret_key.secret_bytes(),
    329                                 media_path,
    330                             );
    331                             self.draft.uploading_media.push(promise);
    332                         }
    333                         Err(e) => {
    334                             error!("{e}");
    335                             self.draft.upload_errors.push(e.to_string());
    336                         }
    337                     }
    338                 }
    339             }
    340         }
    341     }
    342 
    343     fn transfer_uploads(&mut self, ui: &mut egui::Ui) {
    344         let mut indexes_to_remove = Vec::new();
    345         for (i, promise) in self.draft.uploading_media.iter().enumerate() {
    346             match promise.ready() {
    347                 Some(Ok(media)) => {
    348                     self.draft.uploaded_media.push(media.clone());
    349                     indexes_to_remove.push(i);
    350                 }
    351                 Some(Err(e)) => {
    352                     self.draft.upload_errors.push(e.to_string());
    353                     error!("{e}");
    354                 }
    355                 None => {
    356                     ui.spinner();
    357                 }
    358             }
    359         }
    360 
    361         indexes_to_remove.reverse();
    362         for i in indexes_to_remove {
    363             let _ = self.draft.uploading_media.remove(i);
    364         }
    365     }
    366 
    367     fn show_upload_errors(&mut self, ui: &mut egui::Ui) {
    368         let mut to_remove = Vec::new();
    369         for (i, error) in self.draft.upload_errors.iter().enumerate() {
    370             if ui
    371                 .add(
    372                     egui::Label::new(egui::RichText::new(error).color(ui.visuals().warn_fg_color))
    373                         .sense(Sense::click())
    374                         .selectable(false),
    375                 )
    376                 .on_hover_text_at_pointer("Dismiss")
    377                 .clicked()
    378             {
    379                 to_remove.push(i);
    380             }
    381         }
    382         to_remove.reverse();
    383 
    384         for i in to_remove {
    385             self.draft.upload_errors.remove(i);
    386         }
    387     }
    388 }
    389 
    390 fn post_button(interactive: bool) -> impl egui::Widget {
    391     move |ui: &mut egui::Ui| {
    392         let button = egui::Button::new("Post now");
    393         if interactive {
    394             ui.add(button)
    395         } else {
    396             ui.add(
    397                 button
    398                     .sense(egui::Sense::hover())
    399                     .fill(ui.visuals().widgets.noninteractive.bg_fill)
    400                     .stroke(ui.visuals().widgets.noninteractive.bg_stroke),
    401             )
    402             .on_hover_cursor(egui::CursorIcon::NotAllowed)
    403         }
    404     }
    405 }
    406 
    407 fn media_upload_button() -> impl egui::Widget {
    408     |ui: &mut egui::Ui| -> egui::Response {
    409         let resp = ui.allocate_response(egui::vec2(32.0, 32.0), egui::Sense::click());
    410         let painter = ui.painter();
    411         let (fill_color, stroke) = if resp.hovered() {
    412             (
    413                 ui.visuals().widgets.hovered.bg_fill,
    414                 ui.visuals().widgets.hovered.bg_stroke,
    415             )
    416         } else if resp.clicked() {
    417             (
    418                 ui.visuals().widgets.active.bg_fill,
    419                 ui.visuals().widgets.active.bg_stroke,
    420             )
    421         } else {
    422             (
    423                 ui.visuals().widgets.inactive.bg_fill,
    424                 ui.visuals().widgets.inactive.bg_stroke,
    425             )
    426         };
    427 
    428         painter.rect_filled(resp.rect, 8.0, fill_color);
    429         painter.rect_stroke(resp.rect, 8.0, stroke);
    430         egui::Image::new(egui::include_image!(
    431             "../../../../../assets/icons/media_upload_dark_4x.png"
    432         ))
    433         .max_size(egui::vec2(16.0, 16.0))
    434         .paint_at(ui, resp.rect.shrink(8.0));
    435         resp
    436     }
    437 }
    438 
    439 fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egui::Response {
    440     let resp = ui.allocate_rect(desired_rect, egui::Sense::click());
    441     let size = 24.0;
    442     let (fill_color, stroke) = if resp.hovered() {
    443         (
    444             ui.visuals().widgets.hovered.bg_fill,
    445             ui.visuals().widgets.hovered.bg_stroke,
    446         )
    447     } else if resp.clicked() {
    448         (
    449             ui.visuals().widgets.active.bg_fill,
    450             ui.visuals().widgets.active.bg_stroke,
    451         )
    452     } else {
    453         (
    454             ui.visuals().widgets.inactive.bg_fill,
    455             ui.visuals().widgets.inactive.bg_stroke,
    456         )
    457     };
    458     let center = desired_rect.center();
    459     let painter = ui.painter_at(desired_rect);
    460     let radius = size / 2.0;
    461 
    462     painter.circle_filled(center, radius, fill_color);
    463     painter.circle_stroke(center, radius, stroke);
    464 
    465     painter.line_segment(
    466         [
    467             Pos2::new(center.x - 4.0, center.y - 4.0),
    468             Pos2::new(center.x + 4.0, center.y + 4.0),
    469         ],
    470         egui::Stroke::new(1.33, ui.visuals().text_color()),
    471     );
    472 
    473     painter.line_segment(
    474         [
    475             Pos2::new(center.x + 4.0, center.y - 4.0),
    476             Pos2::new(center.x - 4.0, center.y + 4.0),
    477         ],
    478         egui::Stroke::new(1.33, ui.visuals().text_color()),
    479     );
    480     resp
    481 }
    482 
    483 mod preview {
    484 
    485     use crate::media_upload::Nip94Event;
    486 
    487     use super::*;
    488     use notedeck::{App, AppContext};
    489 
    490     pub struct PostPreview {
    491         draft: Draft,
    492         poster: FullKeypair,
    493     }
    494 
    495     impl PostPreview {
    496         fn new() -> Self {
    497             let mut draft = Draft::new();
    498             // can use any url here
    499             draft.uploaded_media.push(Nip94Event::new(
    500                 "https://image.nostr.build/41b40657dd6abf7c275dffc86b29bd863e9337a74870d4ee1c33a72a91c9d733.jpg".to_owned(),
    501                 612,
    502                 407,
    503             ));
    504             draft.uploaded_media.push(Nip94Event::new(
    505                 "https://image.nostr.build/thumb/fdb46182b039d29af0f5eac084d4d30cd4ad2580ea04fe6c7e79acfe095f9852.png".to_owned(),
    506                 80,
    507                 80,
    508             ));
    509             draft.uploaded_media.push(Nip94Event::new(
    510                 "https://i.nostr.build/7EznpHsnBZ36Akju.png".to_owned(),
    511                 2438,
    512                 1476,
    513             ));
    514             draft.uploaded_media.push(Nip94Event::new(
    515                 "https://i.nostr.build/qCCw8szrjTydTiMV.png".to_owned(),
    516                 2002,
    517                 2272,
    518             ));
    519             PostPreview {
    520                 draft,
    521                 poster: FullKeypair::generate(),
    522             }
    523         }
    524     }
    525 
    526     impl App for PostPreview {
    527         fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) {
    528             let txn = Transaction::new(app.ndb).expect("txn");
    529             PostView::new(
    530                 app.ndb,
    531                 &mut self.draft,
    532                 PostType::New,
    533                 app.img_cache,
    534                 app.note_cache,
    535                 self.poster.to_filled(),
    536             )
    537             .ui(&txn, ui);
    538         }
    539     }
    540 
    541     impl Preview for PostView<'_> {
    542         type Prev = PostPreview;
    543 
    544         fn preview(_cfg: PreviewConfig) -> Self::Prev {
    545             PostPreview::new()
    546         }
    547     }
    548 }