From 7abf1c9c1561b576372b2462a94cb440fd8ec8ab Mon Sep 17 00:00:00 2001 From: kernelkind Date: Wed, 22 Jan 2025 14:50:11 -0500 Subject: [PATCH] ui: user can upload images Signed-off-by: kernelkind --- crates/notedeck_columns/src/post.rs | 78 +++++- crates/notedeck_columns/src/ui/note/post.rs | 264 ++++++++++++++++++-- 2 files changed, 307 insertions(+), 35 deletions(-) diff --git a/crates/notedeck_columns/src/post.rs b/crates/notedeck_columns/src/post.rs index 9f53df06..51a03311 100644 --- a/crates/notedeck_columns/src/post.rs +++ b/crates/notedeck_columns/src/post.rs @@ -2,9 +2,12 @@ use enostr::FullKeypair; use nostrdb::{Note, NoteBuilder, NoteReply}; use std::collections::HashSet; +use crate::media_upload::Nip94Event; + pub struct NewPost { pub content: String, pub account: FullKeypair, + pub media: Vec, } fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { @@ -15,26 +18,36 @@ fn add_client_tag(builder: NoteBuilder<'_>) -> NoteBuilder<'_> { } impl NewPost { - pub fn new(content: String, account: FullKeypair) -> Self { - NewPost { content, account } + pub fn new(content: String, account: FullKeypair, media: Vec) -> Self { + NewPost { + content, + account, + media, + } } pub fn to_note(&self, seckey: &[u8; 32]) -> Note { - let mut builder = add_client_tag(NoteBuilder::new()) - .kind(1) - .content(&self.content); + let mut content = self.content.clone(); + append_urls(&mut content, &self.media); + + let mut builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder.sign(seckey).build().expect("note should be ok") } pub fn to_reply(&self, seckey: &[u8; 32], replying_to: &Note) -> Note { - let builder = add_client_tag(NoteBuilder::new()) - .kind(1) - .content(&self.content); + let mut content = self.content.clone(); + append_urls(&mut content, &self.media); + + let builder = add_client_tag(NoteBuilder::new()).kind(1).content(&content); let nip10 = NoteReply::new(replying_to.tags()); @@ -96,6 +109,10 @@ impl NewPost { builder = builder.start_tag().tag_str("p").tag_str(&hex::encode(id)); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder .sign(seckey) .build() @@ -103,18 +120,24 @@ impl NewPost { } pub fn to_quote(&self, seckey: &[u8; 32], quoting: &Note) -> Note { - let new_content = format!( + let mut new_content = format!( "{}\nnostr:{}", self.content, enostr::NoteId::new(*quoting.id()).to_bech().unwrap() ); + append_urls(&mut new_content, &self.media); + let mut builder = NoteBuilder::new().kind(1).content(&new_content); for hashtag in Self::extract_hashtags(&self.content) { builder = builder.start_tag().tag_str("t").tag_str(&hashtag); } + if !self.media.is_empty() { + builder = add_imeta_tags(builder, &self.media); + } + builder .start_tag() .tag_str("q") @@ -143,6 +166,43 @@ impl NewPost { } } +fn append_urls(content: &mut String, media: &Vec) { + for ev in media { + content.push(' '); + content.push_str(&ev.url); + } +} + +fn add_imeta_tags<'a>(builder: NoteBuilder<'a>, media: &Vec) -> NoteBuilder<'a> { + let mut builder = builder; + for item in media { + builder = builder + .start_tag() + .tag_str("imeta") + .tag_str(&format!("url {}", item.url)); + + if let Some(ox) = &item.ox { + builder = builder.tag_str(&format!("ox {ox}")); + }; + if let Some(x) = &item.x { + builder = builder.tag_str(&format!("x {x}")); + } + if let Some(media_type) = &item.media_type { + builder = builder.tag_str(&format!("m {media_type}")); + } + if let Some(dims) = &item.dimensions { + builder = builder.tag_str(&format!("dim {}x{}", dims.0, dims.1)); + } + if let Some(bh) = &item.blurhash { + builder = builder.tag_str(&format!("blurhash {bh}")); + } + if let Some(thumb) = &item.thumb { + builder = builder.tag_str(&format!("thumb {thumb}")); + } + } + builder +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs index 5ed7870e..e4283d7c 100644 --- a/crates/notedeck_columns/src/ui/note/post.rs +++ b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,13 +1,16 @@ use crate::draft::{Draft, Drafts}; +use crate::images::fetch_img; +use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::NewPost; use crate::ui::{self, Preview, PreviewConfig}; use crate::Result; use egui::widgets::text_edit::TextEdit; -use egui::{Frame, Layout}; +use egui::{vec2, Frame, Layout, Margin, Pos2, ScrollArea, Sense}; use enostr::{FilledKeypair, FullKeypair, NoteId, RelayPool}; use nostrdb::{Ndb, Transaction}; use notedeck::{ImageCache, NoteCache}; +use tracing::error; use super::contents::render_note_preview; @@ -156,7 +159,6 @@ impl<'a> PostView<'a> { let stroke = if focused { ui.visuals().selection.stroke } else { - //ui.visuals().selection.stroke ui.visuals().noninteractive().bg_stroke }; @@ -181,34 +183,46 @@ impl<'a> PostView<'a> { ui.vertical(|ui| { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; + if let PostType::Quote(id) = self.post_type { + let avail_size = ui.available_size_before_wrap(); + ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { + Frame::none().show(ui, |ui| { + ui.vertical(|ui| { + ui.set_max_width(avail_size.x * 0.8); + render_note_preview( + ui, + self.ndb, + self.note_cache, + self.img_cache, + txn, + id.bytes(), + nostrdb::NoteKey::new(0), + ); + }); + }); + }); + } + + Frame::none() + .inner_margin(Margin::symmetric(0.0, 8.0)) + .show(ui, |ui| { + ScrollArea::horizontal().show(ui, |ui| { + ui.with_layout(Layout::left_to_right(egui::Align::Min), |ui| { + ui.add_space(4.0); + self.show_media(ui); + }); + }); + }); + + self.transfer_uploads(ui); + self.show_upload_errors(ui); + let action = ui .horizontal(|ui| { - if let PostType::Quote(id) = self.post_type { - let avail_size = ui.available_size_before_wrap(); - ui.with_layout(Layout::left_to_right(egui::Align::TOP), |ui| { - Frame::none().show(ui, |ui| { - ui.vertical(|ui| { - ui.set_max_width(avail_size.x * 0.8); - render_note_preview( - ui, - self.ndb, - self.note_cache, - self.img_cache, - txn, - id.bytes(), - nostrdb::NoteKey::new(0), - ); - }); - }); - }); - } - ui.with_layout( egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| { - if ui.add(media_upload_button()).clicked() { - // TODO: implement media upload - } + self.show_upload_media_button(ui); }, ); @@ -223,6 +237,7 @@ impl<'a> PostView<'a> { let new_post = NewPost::new( self.draft.buffer.clone(), self.poster.to_full(), + self.draft.uploaded_media.clone(), ); Some(PostAction::new(self.post_type.clone(), new_post)) } else { @@ -242,6 +257,134 @@ impl<'a> PostView<'a> { }) .inner } + + fn show_media(&mut self, ui: &mut egui::Ui) { + let mut to_remove = Vec::new(); + for (i, media) in self.draft.uploaded_media.iter().enumerate() { + let (width, height) = if let Some(dims) = media.dimensions { + (dims.0, dims.1) + } else { + (300, 300) + }; + let m_cached_promise = self.img_cache.map().get(&media.url); + if m_cached_promise.is_none() { + let promise = fetch_img( + &self.img_cache, + ui.ctx(), + &media.url, + crate::images::ImageType::Content(width, height), + ); + self.img_cache + .map_mut() + .insert(media.url.to_owned(), promise); + } + + match self.img_cache.map()[&media.url].ready() { + Some(Ok(texture)) => { + let media_size = vec2(width as f32, height as f32); + let max_size = vec2(300.0, 300.0); + let size = if media_size.x > max_size.x || media_size.y > max_size.y { + max_size + } else { + media_size + }; + + let img_resp = ui.add(egui::Image::new(texture).max_size(size).rounding(12.0)); + + let remove_button_rect = { + let top_left = img_resp.rect.left_top(); + let spacing = 13.0; + let center = Pos2::new(top_left.x + spacing, top_left.y + spacing); + egui::Rect::from_center_size(center, egui::vec2(26.0, 26.0)) + }; + if show_remove_upload_button(ui, remove_button_rect).clicked() { + to_remove.push(i); + } + ui.advance_cursor_after_rect(img_resp.rect); + } + Some(Err(e)) => { + self.draft.upload_errors.push(e.to_string()); + error!("{e}"); + } + None => { + ui.spinner(); + } + } + } + to_remove.reverse(); + for i in to_remove { + self.draft.uploaded_media.remove(i); + } + } + + fn show_upload_media_button(&mut self, ui: &mut egui::Ui) { + if ui.add(media_upload_button()).clicked() { + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + { + if let Some(file) = rfd::FileDialog::new().pick_file() { + match MediaPath::new(file) { + Ok(media_path) => { + let promise = nostrbuild_nip96_upload( + self.poster.secret_key.secret_bytes(), + media_path, + ); + self.draft.uploading_media.push(promise); + } + Err(e) => { + error!("{e}"); + self.draft.upload_errors.push(e.to_string()); + } + } + } + } + } + } + + fn transfer_uploads(&mut self, ui: &mut egui::Ui) { + let mut indexes_to_remove = Vec::new(); + for (i, promise) in self.draft.uploading_media.iter().enumerate() { + match promise.ready() { + Some(Ok(media)) => { + self.draft.uploaded_media.push(media.clone()); + indexes_to_remove.push(i); + } + Some(Err(e)) => { + self.draft.upload_errors.push(e.to_string()); + error!("{e}"); + } + None => { + ui.spinner(); + } + } + } + + indexes_to_remove.reverse(); + for i in indexes_to_remove { + let _ = self.draft.uploading_media.remove(i); + } + } + + fn show_upload_errors(&mut self, ui: &mut egui::Ui) { + let mut to_remove = Vec::new(); + for (i, error) in self.draft.upload_errors.iter().enumerate() { + if ui + .add( + egui::Label::new(egui::RichText::new(error).color(ui.visuals().warn_fg_color)) + .sense(Sense::click()) + .selectable(false), + ) + .on_hover_text_at_pointer("Dismiss") + .clicked() + { + to_remove.push(i); + } + } + to_remove.reverse(); + + for i in to_remove { + self.draft.upload_errors.remove(i); + } + } } fn post_button(interactive: bool) -> impl egui::Widget { @@ -293,7 +436,54 @@ fn media_upload_button() -> impl egui::Widget { } } +fn show_remove_upload_button(ui: &mut egui::Ui, desired_rect: egui::Rect) -> egui::Response { + let resp = ui.allocate_rect(desired_rect, egui::Sense::click()); + let size = 24.0; + let (fill_color, stroke) = if resp.hovered() { + ( + ui.visuals().widgets.hovered.bg_fill, + ui.visuals().widgets.hovered.bg_stroke, + ) + } else if resp.clicked() { + ( + ui.visuals().widgets.active.bg_fill, + ui.visuals().widgets.active.bg_stroke, + ) + } else { + ( + ui.visuals().widgets.inactive.bg_fill, + ui.visuals().widgets.inactive.bg_stroke, + ) + }; + let center = desired_rect.center(); + let painter = ui.painter_at(desired_rect); + let radius = size / 2.0; + + painter.circle_filled(center, radius, fill_color); + painter.circle_stroke(center, radius, stroke); + + painter.line_segment( + [ + Pos2::new(center.x - 4.0, center.y - 4.0), + Pos2::new(center.x + 4.0, center.y + 4.0), + ], + egui::Stroke::new(1.33, ui.visuals().text_color()), + ); + + painter.line_segment( + [ + Pos2::new(center.x + 4.0, center.y - 4.0), + Pos2::new(center.x - 4.0, center.y + 4.0), + ], + egui::Stroke::new(1.33, ui.visuals().text_color()), + ); + resp +} + mod preview { + + use crate::media_upload::Nip94Event; + use super::*; use notedeck::{App, AppContext}; @@ -304,8 +494,30 @@ mod preview { impl PostPreview { fn new() -> Self { + let mut draft = Draft::new(); + // can use any url here + draft.uploaded_media.push(Nip94Event::new( + "https://image.nostr.build/41b40657dd6abf7c275dffc86b29bd863e9337a74870d4ee1c33a72a91c9d733.jpg".to_owned(), + 612, + 407, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://image.nostr.build/thumb/fdb46182b039d29af0f5eac084d4d30cd4ad2580ea04fe6c7e79acfe095f9852.png".to_owned(), + 80, + 80, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://i.nostr.build/7EznpHsnBZ36Akju.png".to_owned(), + 2438, + 1476, + )); + draft.uploaded_media.push(Nip94Event::new( + "https://i.nostr.build/qCCw8szrjTydTiMV.png".to_owned(), + 2002, + 2272, + )); PostPreview { - draft: Draft::new(), + draft, poster: FullKeypair::generate(), } }