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, "ing) 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 }