Merge send reactions by kernel #1170

This commit is contained in:
William Casarin
2025-10-20 11:22:54 -07:00
8 changed files with 195 additions and 9 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -14,6 +14,9 @@ pub enum NoteAction {
/// User has clicked the quote reply action /// User has clicked the quote reply action
Reply(NoteId), Reply(NoteId),
/// User has clicked the like/reaction button
React(ReactAction),
/// User has clicked the repost button /// User has clicked the repost button
Repost(NoteId), 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)] #[derive(Debug, Eq, PartialEq, Clone)]
pub enum ZapAction { pub enum ZapAction {
Send(ZapTargetAmount), Send(ZapTargetAmount),

View File

@@ -1,7 +1,7 @@
mod action; mod action;
mod context; mod context;
pub use action::{NoteAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use action::{NoteAction, ReactAction, ScrollInfo, ZapAction, ZapTargetAmount};
pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection};
use crate::Accounts; 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) 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))
}

View File

@@ -12,11 +12,13 @@ use crate::{
}; };
use egui_nav::Percent; use egui_nav::Percent;
use enostr::{NoteId, Pubkey, RelayPool}; use enostr::{FilledKeypair, NoteId, Pubkey, RelayPool};
use nostrdb::{Ndb, NoteKey, Transaction}; use nostrdb::{IngestMetadata, Ndb, NoteBuilder, NoteKey, Transaction};
use notedeck::{ use notedeck::{
get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache, get_wallet_for,
NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, note::{reaction_sent_id, ReactAction, ZapTargetAmount},
Accounts, GlobalWallet, Images, NoteAction, NoteCache, NoteZapTargetOwned, UnknownIds,
ZapAction, ZapTarget, ZappingError, Zaps,
}; };
use notedeck_ui::media::MediaViewerFlags; use notedeck_ui::media::MediaViewerFlags;
use tracing::error; use tracing::error;
@@ -76,6 +78,21 @@ fn execute_note_action(
router_action = Some(RouterAction::route_to(Route::accounts())); 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) => { NoteAction::Profile(pubkey) => {
let kind = TimelineKind::Profile(pubkey); let kind = TimelineKind::Profile(pubkey);
router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone()))); router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
@@ -262,6 +279,94 @@ pub fn execute_and_process_note_action(
resp.router_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<String> = note.relays(txn).next().map(|s| s.to_owned());
let target_kind = note.kind();
let d_tag_value = find_addressable_d_tag(&note);
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(&note) 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<String> {
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( fn send_zap(
sender: &Pubkey, sender: &Pubkey,
zaps: &mut Zaps, zaps: &mut Zaps,

View File

@@ -7,7 +7,7 @@ use notedeck::fonts::get_font_size;
use notedeck::name::get_display_name; use notedeck::name::get_display_name;
use notedeck::ui::is_narrow; use notedeck::ui::is_narrow;
use notedeck::{tr_plural, JobsCache, Muted, NotedeckTextStyle}; 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 notedeck_ui::{ProfilePic, ProfilePreview};
use std::f32::consts::PI; use std::f32::consts::PI;
use tracing::{error, warn}; use tracing::{error, warn};
@@ -514,7 +514,7 @@ enum ReferencedNoteType {
impl CompositeType { impl CompositeType {
fn image(&self, darkmode: bool) -> egui::Image<'static> { fn image(&self, darkmode: bool) -> egui::Image<'static> {
match self { match self {
CompositeType::Reaction => like_image(), CompositeType::Reaction => like_image_filled(),
CompositeType::Repost => { CompositeType::Repost => {
repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51)) repost_image(darkmode).tint(Color32::from_rgb(0x68, 0xC3, 0x51))
} }

View File

@@ -253,6 +253,12 @@ pub fn zap_light_image() -> Image<'static> {
zap_dark_image().tint(Color32::BLACK) 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> { pub fn like_image() -> Image<'static> {
Image::new(include_image!("../../../assets/icons/like_icon_4x.png")) Image::new(include_image!("../../../assets/icons/like_icon_4x.png"))
} }

View File

@@ -10,7 +10,7 @@ use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username}
pub use contents::{render_note_preview, NoteContents}; pub use contents::{render_note_preview, NoteContents};
pub use context::NoteContextButton; pub use context::NoteContextButton;
use notedeck::get_current_wallet; use notedeck::get_current_wallet;
use notedeck::note::ZapTargetAmount; use notedeck::note::{reaction_sent_id, ZapTargetAmount};
use notedeck::ui::is_narrow; use notedeck::ui::is_narrow;
use notedeck::Accounts; use notedeck::Accounts;
use notedeck::GlobalWallet; use notedeck::GlobalWallet;
@@ -26,7 +26,7 @@ use egui::{Id, Pos2, Rect, Response, Sense};
use enostr::{KeypairUnowned, NoteId, Pubkey}; use enostr::{KeypairUnowned, NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction}; use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
use notedeck::{ use notedeck::{
note::{NoteAction, NoteContext, ZapAction}, note::{NoteAction, NoteContext, ReactAction, ZapAction},
tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps, tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps,
}; };
@@ -461,6 +461,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
), ),
self.note.id(), self.note.id(),
self.note.pubkey(), self.note.pubkey(),
self.note_context.accounts.selected_account_pubkey(),
note_key, note_key,
self.note_context.i18n, self.note_context.i18n,
) )
@@ -549,6 +550,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
), ),
self.note.id(), self.note.id(),
self.note.pubkey(), self.note.pubkey(),
self.note_context.accounts.selected_account_pubkey(),
note_key, note_key,
self.note_context.i18n, self.note_context.i18n,
) )
@@ -848,6 +850,7 @@ fn render_note_actionbar(
zapper: Option<Zapper<'_>>, zapper: Option<Zapper<'_>>,
note_id: &[u8; 32], note_id: &[u8; 32],
note_pubkey: &[u8; 32], note_pubkey: &[u8; 32],
current_user_pubkey: &Pubkey,
note_key: NoteKey, note_key: NoteKey,
i18n: &mut Localization, i18n: &mut Localization,
) -> Option<NoteAction> { ) -> Option<NoteAction> {
@@ -859,6 +862,14 @@ fn render_note_actionbar(
let reply_resp = let reply_resp =
reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 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 = let quote_resp =
quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 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))); 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() { if quote_resp.clicked() {
action = Some(NoteAction::Repost(NoteId::new(*note_id))); 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) 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> { fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
if dark_mode { if dark_mode {
app_images::repost_dark_image() app_images::repost_dark_image()