diff --git a/assets/icons/like_icon_4x.png b/assets/icons/like_icon_4x.png index 3f0ead58d..4027a80bc 100644 Binary files a/assets/icons/like_icon_4x.png and b/assets/icons/like_icon_4x.png differ diff --git a/assets/icons/like_icon_filled_4x.png b/assets/icons/like_icon_filled_4x.png new file mode 100644 index 000000000..3f0ead58d Binary files /dev/null and b/assets/icons/like_icon_filled_4x.png differ diff --git a/crates/notedeck/src/note/action.rs b/crates/notedeck/src/note/action.rs index c2afa3388..83dbdbe0b 100644 --- a/crates/notedeck/src/note/action.rs +++ b/crates/notedeck/src/note/action.rs @@ -14,6 +14,9 @@ pub enum NoteAction { /// User has clicked the quote reply action Reply(NoteId), + /// User has clicked the like/reaction button + React(ReactAction), + /// User has clicked the repost button Repost(NoteId), @@ -53,6 +56,18 @@ impl NoteAction { } } +#[derive(Debug, Clone)] +pub struct ReactAction { + pub note_id: NoteId, + pub content: &'static str, +} + +impl ReactAction { + pub const fn new(note_id: NoteId, content: &'static str) -> Self { + Self { note_id, content } + } +} + #[derive(Debug, Eq, PartialEq, Clone)] pub enum ZapAction { Send(ZapTargetAmount), diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs index 882111997..83fb8a420 100644 --- a/crates/notedeck/src/note/mod.rs +++ b/crates/notedeck/src/note/mod.rs @@ -1,7 +1,7 @@ mod action; mod context; -pub use action::{NoteAction, ScrollInfo, ZapAction, ZapTargetAmount}; +pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; use crate::Accounts; @@ -212,3 +212,9 @@ pub fn event_tag<'a>(ev: &nostrdb::Note<'a>, name: &str) -> Option<&'a str> { tag.get_str(1) }) } + +/// Temporary way of checking whether a user has sent a reaction. +/// Should be replaced with nostrdb metadata +pub fn reaction_sent_id(sender_pk: &enostr::Pubkey, note_reacted_to: &[u8; 32]) -> egui::Id { + egui::Id::new(("sent-reaction-id", note_reacted_to, sender_pk)) +} diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs index a8af37dd1..75190d871 100644 --- a/crates/notedeck_columns/src/actionbar.rs +++ b/crates/notedeck_columns/src/actionbar.rs @@ -12,11 +12,13 @@ use crate::{ }; use egui_nav::Percent; -use enostr::{NoteId, Pubkey, RelayPool}; -use nostrdb::{Ndb, NoteKey, Transaction}; +use enostr::{FilledKeypair, NoteId, Pubkey, RelayPool}; +use nostrdb::{IngestMetadata, Ndb, NoteBuilder, NoteKey, Transaction}; use notedeck::{ - get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache, - NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, + get_wallet_for, + note::{reaction_sent_id, ReactAction, ZapTargetAmount}, + Accounts, GlobalWallet, Images, NoteAction, NoteCache, NoteZapTargetOwned, UnknownIds, + ZapAction, ZapTarget, ZappingError, Zaps, }; use notedeck_ui::media::MediaViewerFlags; use tracing::error; @@ -76,6 +78,21 @@ fn execute_note_action( router_action = Some(RouterAction::route_to(Route::accounts())); } } + NoteAction::React(react_action) => { + if let Some(filled) = accounts.selected_filled() { + if let Err(err) = send_reaction_event(ndb, txn, pool, filled, &react_action) { + tracing::error!("Failed to send reaction: {err}"); + } + ui.ctx().data_mut(|d| { + d.insert_temp( + reaction_sent_id(filled.pubkey, react_action.note_id.bytes()), + true, + ) + }); + } else { + router_action = Some(RouterAction::route_to(Route::accounts())); + } + } NoteAction::Profile(pubkey) => { let kind = TimelineKind::Profile(pubkey); router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone()))); @@ -262,6 +279,94 @@ pub fn execute_and_process_note_action( resp.router_action } +fn send_reaction_event( + ndb: &mut Ndb, + txn: &Transaction, + pool: &mut RelayPool, + kp: FilledKeypair<'_>, + reaction: &ReactAction, +) -> Result<(), String> { + let Ok(note) = ndb.get_note_by_id(txn, reaction.note_id.bytes()) else { + return Err(format!("noteid {:?} not found in ndb", reaction.note_id)); + }; + + let target_pubkey = Pubkey::new(*note.pubkey()); + let relay_hint: Option = note.relays(txn).next().map(|s| s.to_owned()); + let target_kind = note.kind(); + let d_tag_value = find_addressable_d_tag(¬e); + + let mut builder = NoteBuilder::new().kind(7).content(reaction.content); + + builder = builder + .start_tag() + .tag_str("e") + .tag_id(reaction.note_id.bytes()) + .tag_str(relay_hint.as_deref().unwrap_or("")) + .tag_str(&target_pubkey.hex()); + + builder = builder + .start_tag() + .tag_str("p") + .tag_id(target_pubkey.bytes()); + + if let Some(relay) = relay_hint.as_deref() { + builder = builder.tag_str(relay); + } + + // we don't support addressable events yet... but why not future proof it? + if let Some(d_value) = d_tag_value.as_deref() { + let coordinates = format!("{}:{}:{}", target_kind, target_pubkey.hex(), d_value); + + builder = builder.start_tag().tag_str("a").tag_str(&coordinates); + + if let Some(relay) = relay_hint.as_deref() { + builder = builder.tag_str(relay); + } + } + + builder = builder + .start_tag() + .tag_str("k") + .tag_str(&target_kind.to_string()); + + let note = builder + .sign(&kp.secret_key.secret_bytes()) + .build() + .ok_or_else(|| "failed to build reaction event".to_owned())?; + + let Ok(event) = &enostr::ClientMessage::event(¬e) else { + return Err("failed to convert reaction note into client message".to_owned()); + }; + + let Ok(json) = event.to_json() else { + return Err("failed to serialize reaction event to json".to_owned()); + }; + + let _ = ndb.process_event_with(&json, IngestMetadata::new().client(true)); + + pool.send(event); + + Ok(()) +} + +fn find_addressable_d_tag(note: &nostrdb::Note<'_>) -> Option { + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + + if tag.get_unchecked(0).variant().str() != Some("d") { + continue; + } + + if let Some(value) = tag.get_unchecked(1).variant().str() { + return Some(value.to_owned()); + } + } + + None +} + fn send_zap( sender: &Pubkey, zaps: &mut Zaps, diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs index 2c5fcf34a..b526f6e6f 100644 --- a/crates/notedeck_columns/src/ui/timeline.rs +++ b/crates/notedeck_columns/src/ui/timeline.rs @@ -7,7 +7,7 @@ use notedeck::fonts::get_font_size; use notedeck::name::get_display_name; use notedeck::ui::is_narrow; use notedeck::{tr_plural, JobsCache, Muted, NotedeckTextStyle}; -use notedeck_ui::app_images::{like_image, repost_image}; +use notedeck_ui::app_images::{like_image_filled, repost_image}; use notedeck_ui::{ProfilePic, ProfilePreview}; use std::f32::consts::PI; use tracing::{error, warn}; @@ -514,7 +514,7 @@ enum ReferencedNoteType { impl CompositeType { fn image(&self, darkmode: bool) -> egui::Image<'static> { match self { - CompositeType::Reaction => like_image(), + CompositeType::Reaction => like_image_filled(), CompositeType::Repost => { repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51)) } diff --git a/crates/notedeck_ui/src/app_images.rs b/crates/notedeck_ui/src/app_images.rs index 9ff01d75c..cf80c445f 100644 --- a/crates/notedeck_ui/src/app_images.rs +++ b/crates/notedeck_ui/src/app_images.rs @@ -249,6 +249,12 @@ pub fn zap_light_image() -> Image<'static> { zap_dark_image().tint(Color32::BLACK) } +pub fn like_image_filled() -> Image<'static> { + Image::new(include_image!( + "../../../assets/icons/like_icon_filled_4x.png" + )) +} + pub fn like_image() -> Image<'static> { Image::new(include_image!("../../../assets/icons/like_icon_4x.png")) } diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs index 074f87477..0f039ad8c 100644 --- a/crates/notedeck_ui/src/note/mod.rs +++ b/crates/notedeck_ui/src/note/mod.rs @@ -10,7 +10,7 @@ use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username} pub use contents::{render_note_preview, NoteContents}; pub use context::NoteContextButton; use notedeck::get_current_wallet; -use notedeck::note::ZapTargetAmount; +use notedeck::note::{reaction_sent_id, ZapTargetAmount}; use notedeck::ui::is_narrow; use notedeck::Accounts; use notedeck::GlobalWallet; @@ -26,7 +26,7 @@ use egui::{Id, Pos2, Rect, Response, Sense}; use enostr::{KeypairUnowned, NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction}; use notedeck::{ - note::{NoteAction, NoteContext, ZapAction}, + note::{NoteAction, NoteContext, ReactAction, ZapAction}, tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps, }; @@ -461,6 +461,7 @@ impl<'a, 'd> NoteView<'a, 'd> { ), self.note.id(), self.note.pubkey(), + self.note_context.accounts.selected_account_pubkey(), note_key, self.note_context.i18n, ) @@ -549,6 +550,7 @@ impl<'a, 'd> NoteView<'a, 'd> { ), self.note.id(), self.note.pubkey(), + self.note_context.accounts.selected_account_pubkey(), note_key, self.note_context.i18n, ) @@ -848,6 +850,7 @@ fn render_note_actionbar( zapper: Option>, note_id: &[u8; 32], note_pubkey: &[u8; 32], + current_user_pubkey: &Pubkey, note_key: NoteKey, i18n: &mut Localization, ) -> Option { @@ -859,6 +862,14 @@ fn render_note_actionbar( let reply_resp = reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); + let filled = ui + .ctx() + .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note_id))) + == Some(true); + + let like_resp = + like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand); + let quote_resp = quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); @@ -866,6 +877,13 @@ fn render_note_actionbar( action = Some(NoteAction::Reply(NoteId::new(*note_id))); } + if like_resp.clicked() { + action = Some(NoteAction::React(ReactAction::new( + NoteId::new(*note_id), + "🤙🏻", + ))); + } + if quote_resp.clicked() { action = Some(NoteAction::Repost(NoteId::new(*note_id))); } @@ -918,6 +936,42 @@ fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) - resp.union(put_resp) } +fn like_button( + ui: &mut egui::Ui, + i18n: &mut Localization, + note_key: NoteKey, + filled: bool, +) -> egui::Response { + let img = { + let img = if filled { + app_images::like_image_filled() + } else { + app_images::like_image() + }; + + if ui.visuals().dark_mode { + img.tint(ui.visuals().text_color()) + } else { + img + } + }; + + let (rect, size, resp) = + crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key))); + + // align rect to note contents + let expand_size = 5.0; // from hover_expand_small + let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); + + let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( + i18n, + "Like this note", + "Hover text for like button" + )); + + resp.union(put_resp) +} + fn repost_icon(dark_mode: bool) -> egui::Image<'static> { if dark_mode { app_images::repost_dark_image()