notedeck

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

commit 50dec5b5d5f0ce4b56887cd22d86a30514ecb8cb
parent 956c557851e350dec02e774e316e2374244928eb
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 10 Apr 2025 10:39:40 -0700

context: implement note broadcasting

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/enostr/src/client/message.rs | 6+++++-
Mcrates/notedeck/src/accounts.rs | 2+-
Mcrates/notedeck_columns/src/actionbar.rs | 21+++++++++++++++++++++
Mcrates/notedeck_columns/src/app.rs | 47++++++++++++++++++++++++++---------------------
Mcrates/notedeck_columns/src/nav.rs | 37++++++++++++++++++++++++-------------
Mcrates/notedeck_columns/src/ui/note/contents.rs | 3++-
Mcrates/notedeck_columns/src/ui/note/context.rs | 15++++++++++++---
Mcrates/notedeck_columns/src/ui/note/mod.rs | 20++++++--------------
Mcrates/notedeck_columns/src/ui/note/post.rs | 75++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/notedeck_columns/src/ui/note/reply.rs | 11++++++-----
Mcrates/notedeck_columns/src/ui/timeline.rs | 4----
11 files changed, 147 insertions(+), 94 deletions(-)

diff --git a/crates/enostr/src/client/message.rs b/crates/enostr/src/client/message.rs @@ -28,12 +28,16 @@ pub enum ClientMessage { } impl ClientMessage { - pub fn event(note: Note) -> Result<Self, Error> { + pub fn event(note: &Note) -> Result<Self, Error> { Ok(ClientMessage::Event(EventClientMessage { note_json: note.json()?, })) } + pub fn event_json(note_json: String) -> Result<Self, Error> { + Ok(ClientMessage::Event(EventClientMessage { note_json })) + } + pub fn raw(raw: String) -> Self { ClientMessage::Raw(raw) } diff --git a/crates/notedeck/src/accounts.rs b/crates/notedeck/src/accounts.rs @@ -180,7 +180,7 @@ impl AccountRelayData { } } let note = builder.sign(seckey).build().expect("note build"); - pool.send(&enostr::ClientMessage::event(note).expect("note client message")); + pool.send(&enostr::ClientMessage::event(&note).expect("note client message")); } } diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -2,6 +2,7 @@ use crate::{ column::Columns, route::{Route, Router}, timeline::{TimelineCache, TimelineKind}, + ui::note::NoteContextSelection, }; use enostr::{NoteId, Pubkey, RelayPool}; @@ -13,10 +14,17 @@ use notedeck::{ use tracing::error; #[derive(Debug, Eq, PartialEq, Clone)] +pub struct ContextSelection { + pub note_key: NoteKey, + pub action: NoteContextSelection, +} + +#[derive(Debug, Eq, PartialEq, Clone)] pub enum NoteAction { Reply(NoteId), Quote(NoteId), OpenTimeline(TimelineKind), + Context(ContextSelection), Zap(ZapAction), } @@ -48,6 +56,7 @@ impl NoteAction { accounts: &mut Accounts, global_wallet: &mut GlobalWallet, zaps: &mut Zaps, + ui: &mut egui::Ui, ) -> Option<TimelineOpenResult> { match self { NoteAction::Reply(note_id) => { @@ -89,6 +98,16 @@ impl NoteAction { None } + + NoteAction::Context(context) => { + match ndb.get_note_by_key(txn, context.note_key) { + Err(err) => tracing::error!("{err}"), + Ok(note) => { + context.action.process(ui, &note, pool); + } + } + None + } } } @@ -107,6 +126,7 @@ impl NoteAction { accounts: &mut Accounts, global_wallet: &mut GlobalWallet, zaps: &mut Zaps, + ui: &mut egui::Ui, ) { let router = columns.column_mut(col).router_mut(); if let Some(br) = self.execute( @@ -119,6 +139,7 @@ impl NoteAction { accounts, global_wallet, zaps, + ui, ) { br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -513,7 +513,7 @@ fn render_damus_mobile(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut e if !app.columns(app_ctx.accounts).columns().is_empty() && nav::render_nav(0, ui.available_rect_before_wrap(), app, app_ctx, ui) - .process_render_nav_response(app, app_ctx) + .process_render_nav_response(app, app_ctx, ui) && !app.tmp_columns { storage::save_decks_cache(app_ctx.path, &app.decks_cache); @@ -546,12 +546,14 @@ fn render_damus_desktop(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut AppContext<'_>) { let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns(); + let mut side_panel_action: Option<nav::SwitchingAction> = None; + let mut responses = Vec::with_capacity(num_cols); + StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) .sizes(sizes, num_cols) .clip(true) .horizontal(|mut strip| { - let mut side_panel_action: Option<nav::SwitchingAction> = None; strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let side_panel = @@ -589,7 +591,6 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut App ); }); - let mut responses = Vec::with_capacity(num_cols); for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); @@ -604,32 +605,36 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut App // vertical line ui.painter() .vline(rect.right(), rect.y_range(), v_line_stroke); + + // we need borrow ui context for processing, so proces + // responses in the last cell + + if col_index == num_cols - 1 {} }); //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } + }); - // process the side panel action after so we don't change the number of columns during - // StripBuilder rendering - let mut save_cols = false; - if let Some(action) = side_panel_action { - save_cols = - save_cols || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx); - } + // process the side panel action after so we don't change the number of columns during + // StripBuilder rendering + let mut save_cols = false; + if let Some(action) = side_panel_action { + save_cols = save_cols || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx); + } - for response in responses { - let save = response.process_render_nav_response(app, ctx); - save_cols = save_cols || save; - } + for response in responses { + let save = response.process_render_nav_response(app, ctx, ui); + save_cols = save_cols || save; + } - if app.tmp_columns { - save_cols = false; - } + if app.tmp_columns { + save_cols = false; + } - if save_cols { - storage::save_decks_cache(ctx.path, &app.decks_cache); - } - }); + if save_cols { + storage::save_decks_cache(ctx.path, &app.decks_cache); + } } impl notedeck::App for Damus { diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -16,7 +16,7 @@ use crate::{ column::NavTitle, configure_deck::ConfigureDeckView, edit_deck::{EditDeckResponse, EditDeckView}, - note::{contents::NoteContext, PostAction, PostType}, + note::{contents::NoteContext, NewPostAction, PostAction, PostType}, profile::EditProfileView, search::{FocusState, SearchView}, support::SupportView, @@ -35,7 +35,7 @@ use tracing::error; pub enum RenderNavAction { Back, RemoveColumn, - PostAction(PostAction), + PostAction(NewPostAction), NoteAction(NoteAction), ProfileAction(ProfileAction), SwitchingAction(SwitchingAction), @@ -100,6 +100,15 @@ impl SwitchingAction { impl From<PostAction> for RenderNavAction { fn from(post_action: PostAction) -> Self { + match post_action { + PostAction::QuotedNoteAction(note_action) => Self::NoteAction(note_action), + PostAction::NewPostAction(new_post) => Self::PostAction(new_post), + } + } +} + +impl From<NewPostAction> for RenderNavAction { + fn from(post_action: NewPostAction) -> Self { Self::PostAction(post_action) } } @@ -124,7 +133,12 @@ impl RenderNavResponse { } #[must_use = "Make sure to save columns if result is true"] - pub fn process_render_nav_response(&self, app: &mut Damus, ctx: &mut AppContext<'_>) -> bool { + pub fn process_render_nav_response( + &self, + app: &mut Damus, + ctx: &mut AppContext<'_>, + ui: &mut egui::Ui, + ) -> bool { let mut switching_occured: bool = false; let col = self.column; @@ -155,9 +169,12 @@ impl RenderNavResponse { switching_occured = true; } - RenderNavAction::PostAction(post_action) => { + RenderNavAction::PostAction(new_post_action) => { let txn = Transaction::new(ctx.ndb).expect("txn"); - let _ = post_action.execute(ctx.ndb, &txn, ctx.pool, &mut app.drafts); + match new_post_action.execute(ctx.ndb, &txn, ctx.pool, &mut app.drafts) { + Err(err) => tracing::error!("Error executing post action: {err}"), + Ok(_) => tracing::debug!("Post action executed"), + } get_active_columns_mut(ctx.accounts, &mut app.decks_cache) .column_mut(col) .router_mut() @@ -179,6 +196,7 @@ impl RenderNavResponse { ctx.accounts, ctx.global_wallet, ctx.zaps, + ui, ); } @@ -260,6 +278,7 @@ fn render_nav_body( img_cache: ctx.img_cache, note_cache: ctx.note_cache, zaps: ctx.zaps, + pool: ctx.pool, }; match top { Route::Timeline(kind) => render_timeline_route( @@ -334,10 +353,6 @@ fn render_nav_body( }) .inner; - if let Some(selection) = response.context_selection { - selection.process(ui, &note); - } - response.action }; @@ -374,10 +389,6 @@ fn render_nav_body( }) .inner; - if let Some(selection) = response.context_selection { - selection.process(ui, &note); - } - response.action.map(Into::into) } diff --git a/crates/notedeck_columns/src/ui/note/contents.rs b/crates/notedeck_columns/src/ui/note/contents.rs @@ -4,7 +4,7 @@ use crate::ui::{ }; use crate::{actionbar::NoteAction, timeline::TimelineKind}; use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window}; -use enostr::KeypairUnowned; +use enostr::{KeypairUnowned, RelayPool}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use notedeck_ui::images::ImageType; use notedeck_ui::{ @@ -22,6 +22,7 @@ pub struct NoteContext<'d> { pub img_cache: &'d mut Images, pub note_cache: &'d mut NoteCache, pub zaps: &'d mut Zaps, + pub pool: &'d mut RelayPool, } pub struct NoteContents<'a, 'd> { diff --git a/crates/notedeck_columns/src/ui/note/context.rs b/crates/notedeck_columns/src/ui/note/context.rs @@ -1,20 +1,25 @@ use egui::{Rect, Vec2}; -use enostr::{NoteId, Pubkey}; +use enostr::{ClientMessage, NoteId, Pubkey, RelayPool}; use nostrdb::{Note, NoteKey}; use tracing::error; -#[derive(Clone)] +#[derive(Debug, Clone, Eq, PartialEq)] #[allow(clippy::enum_variant_names)] pub enum NoteContextSelection { CopyText, CopyPubkey, CopyNoteId, CopyNoteJSON, + Broadcast, } impl NoteContextSelection { - pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>) { + pub fn process(&self, ui: &mut egui::Ui, note: &Note<'_>, pool: &mut RelayPool) { match self { + NoteContextSelection::Broadcast => { + tracing::info!("Broadcasting note {}", hex::encode(note.id())); + pool.send(&ClientMessage::event(note).unwrap()); + } NoteContextSelection::CopyText => { ui.ctx().copy_text(note.content().to_string()); } @@ -161,6 +166,10 @@ impl NoteContextButton { context_selection = Some(NoteContextSelection::CopyNoteJSON); ui.close_menu(); } + if ui.button("Broadcast").clicked() { + context_selection = Some(NoteContextSelection::Broadcast); + ui.close_menu(); + } }); context_selection diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs @@ -10,13 +10,13 @@ pub use contents::NoteContents; use contents::NoteContext; pub use context::{NoteContextButton, NoteContextSelection}; pub use options::NoteOptions; -pub use post::{PostAction, PostResponse, PostType, PostView}; +pub use post::{NewPostAction, PostAction, PostResponse, PostType, PostView}; pub use quote_repost::QuoteRepostView; pub use reply::PostReplyView; pub use reply_description::reply_desc; use crate::{ - actionbar::{NoteAction, ZapAction}, + actionbar::{ContextSelection, NoteAction, ZapAction}, profile::get_display_name, timeline::{ThreadSelection, TimelineKind}, ui::{self, View}, @@ -43,7 +43,6 @@ pub struct NoteView<'a, 'd> { pub struct NoteResponse { pub response: egui::Response, - pub context_selection: Option<NoteContextSelection>, pub action: Option<NoteAction>, } @@ -51,7 +50,6 @@ impl NoteResponse { pub fn new(response: egui::Response) -> Self { Self { response, - context_selection: None, action: None, } } @@ -60,11 +58,6 @@ impl NoteResponse { self.action = action; self } - - pub fn select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self { - self.context_selection = context_selection; - self - } } impl View for NoteView<'_, '_> { @@ -338,7 +331,6 @@ impl<'a, 'd> NoteView<'a, 'd> { let txn = self.note.txn().expect("todo: support non-db notes"); let mut note_action: Option<NoteAction> = None; - let mut selected_option: Option<NoteContextSelection> = None; let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); let profile = self @@ -505,7 +497,9 @@ impl<'a, 'd> NoteView<'a, 'd> { }; let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); - selected_option = NoteContextButton::menu(ui, resp.clone()); + if let Some(action) = NoteContextButton::menu(ui, resp.clone()) { + note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); + } } let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { @@ -523,9 +517,7 @@ impl<'a, 'd> NoteView<'a, 'd> { note_action }; - NoteResponse::new(response) - .with_action(note_action) - .select_option(selected_option) + NoteResponse::new(response).with_action(note_action) } } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -1,3 +1,4 @@ +use crate::actionbar::NoteAction; use crate::draft::{Draft, Drafts, MentionHint}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::post::{downcast_post_buffer, MentionType, NewPost}; @@ -20,7 +21,6 @@ use notedeck::supported_mime_hosted_at_url; use tracing::error; use super::contents::{render_note_preview, NoteContext}; -use super::NoteContextSelection; use super::NoteOptions; pub struct PostView<'a, 'd> { @@ -40,14 +40,22 @@ pub enum PostType { Reply(NoteId), } -pub struct PostAction { +pub enum PostAction { + /// The NoteAction on a note you are replying to. + QuotedNoteAction(NoteAction), + + /// The reply/new post action + NewPostAction(NewPostAction), +} + +pub struct NewPostAction { post_type: PostType, post: NewPost, } -impl PostAction { +impl NewPostAction { pub fn new(post_type: PostType, post: NewPost) -> Self { - PostAction { post_type, post } + NewPostAction { post_type, post } } pub fn execute( @@ -73,7 +81,7 @@ impl PostAction { } }; - pool.send(&enostr::ClientMessage::event(note)?); + pool.send(&enostr::ClientMessage::event(&note)?); drafts.get_from_post_type(&self.post_type).clear(); Ok(()) @@ -83,7 +91,6 @@ impl PostAction { pub struct PostResponse { pub action: Option<PostAction>, pub edit_response: egui::Response, - pub context_selection: Option<NoteContextSelection>, } impl<'a, 'd> PostView<'a, 'd> { @@ -321,32 +328,34 @@ impl<'a, 'd> PostView<'a, 'd> { .show(ui, |ui| { ui.vertical(|ui| { let edit_response = ui.horizontal(|ui| self.editbox(txn, ui)).inner; - let mut context_selection = None; - if let PostType::Quote(id) = self.post_type { + let note_response = 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| { - context_selection = Frame::NONE - .show(ui, |ui| { - ui.vertical(|ui| { - ui.set_max_width(avail_size.x * 0.8); - let resp = render_note_preview( - ui, - self.note_context, - &Some(self.poster.into()), - txn, - id.bytes(), - nostrdb::NoteKey::new(0), - self.note_options, - ); - resp + Some( + 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.note_context, + &Some(self.poster.into()), + txn, + id.bytes(), + nostrdb::NoteKey::new(0), + self.note_options, + ) + }) + .inner }) .inner - .context_selection - }) - .inner; - }); - } + }) + .inner, + ) + } else { + None + }; Frame::new() .inner_margin(Margin::symmetric(0, 8)) @@ -362,7 +371,7 @@ impl<'a, 'd> PostView<'a, 'd> { self.transfer_uploads(ui); self.show_upload_errors(ui); - let action = ui + let post_action = ui .horizontal(|ui| { ui.with_layout( egui::Layout::left_to_right(egui::Align::BOTTOM), @@ -394,7 +403,7 @@ impl<'a, 'd> PostView<'a, 'd> { self.draft.uploaded_media.clone(), output.mentions, ); - Some(PostAction::new(self.post_type.clone(), new_post)) + Some(NewPostAction::new(self.post_type.clone(), new_post)) } else { None } @@ -403,10 +412,13 @@ impl<'a, 'd> PostView<'a, 'd> { }) .inner; + let action = note_response + .and_then(|nr| nr.action.map(PostAction::QuotedNoteAction)) + .or(post_action.map(PostAction::NewPostAction)); + PostResponse { action, edit_response, - context_selection, } }) .inner @@ -736,6 +748,7 @@ mod preview { img_cache: app.img_cache, note_cache: app.note_cache, zaps: app.zaps, + pool: app.pool, }; PostView::new( diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -1,6 +1,6 @@ use crate::draft::Draft; use crate::ui; -use crate::ui::note::{PostResponse, PostType}; +use crate::ui::note::{PostAction, PostResponse, PostType}; use enostr::{FilledKeypair, NoteId}; use super::contents::NoteContext; @@ -61,7 +61,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { let note_offset: i8 = pfp_offset - ui::ProfilePic::medium_size() / 2 - ui::NoteView::expand_size() / 2; - let selection = egui::Frame::NONE + let quoted_note = egui::Frame::NONE .outer_margin(egui::Margin::same(note_offset)) .show(ui, |ui| { ui::NoteView::new( @@ -75,8 +75,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { .options_button(true) .show(ui) }) - .inner - .context_selection; + .inner; let id = self.id(); let replying_to = self.note.id(); @@ -95,7 +94,9 @@ impl<'a, 'd> PostReplyView<'a, 'd> { .ui(self.note.txn().unwrap(), ui) }; - post_response.context_selection = selection; + post_response.action = post_response + .action + .or(quoted_note.action.map(PostAction::QuotedNoteAction)); // // reply line diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -407,10 +407,6 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { if let Some(note_action) = resp.action { action = Some(note_action) } - - if let Some(context) = resp.context_selection { - context.process(ui, &note); - } }); ui::hline(ui);