Files
notedeck/crates/notedeck_ui/src/note/mod.rs
William Casarin cb7dadee44 ui: note metadata stats
Signed-off-by: William Casarin <jb55@jb55.com>
2025-11-04 14:54:47 -08:00

1132 lines
34 KiB
Rust

pub mod contents;
pub mod context;
pub mod media;
pub mod options;
pub mod reply_description;
use crate::{app_images, secondary_label};
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::{reaction_sent_id, ZapTargetAmount};
use notedeck::ui::is_narrow;
use notedeck::Accounts;
use notedeck::GlobalWallet;
use notedeck::Images;
use notedeck::JobsCache;
use notedeck::Localization;
use notedeck::MediaAction;
pub use options::NoteOptions;
pub use reply_description::reply_desc;
use egui::emath::{pos2, Vec2};
use egui::{Id, Pos2, Rect, Response, Sense};
use enostr::{KeypairUnowned, NoteId, Pubkey};
use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
use notedeck::{
note::{NoteAction, NoteContext, ReactAction, ZapAction},
tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps,
};
pub struct NoteView<'a, 'd> {
note_context: &'a mut NoteContext<'d>,
parent: Option<NoteKey>,
note: &'a nostrdb::Note<'a>,
flags: NoteOptions,
jobs: &'a mut JobsCache,
}
pub struct NoteResponse {
pub response: egui::Response,
pub action: Option<NoteAction>,
pub pfp_rect: Option<egui::Rect>,
}
impl NoteResponse {
pub fn new(response: egui::Response) -> Self {
Self {
response,
action: None,
pfp_rect: None,
}
}
pub fn with_action(mut self, action: Option<NoteAction>) -> Self {
self.action = action;
self
}
pub fn with_pfp(mut self, pfp_rect: egui::Rect) -> Self {
self.pfp_rect = Some(pfp_rect);
self
}
}
/*
impl View for NoteView<'_, '_> {
fn ui(&mut self, ui: &mut egui::Ui) {
self.show(ui);
}
}
*/
impl egui::Widget for &mut NoteView<'_, '_> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
self.show(ui).response
}
}
impl<'a, 'd> NoteView<'a, 'd> {
pub fn new(
note_context: &'a mut NoteContext<'d>,
note: &'a nostrdb::Note<'a>,
flags: NoteOptions,
jobs: &'a mut JobsCache,
) -> Self {
let parent: Option<NoteKey> = None;
Self {
note_context,
parent,
note,
flags,
jobs,
}
}
pub fn preview_style(self) -> Self {
self.actionbar(false)
.small_pfp(true)
.frame(true)
.wide(true)
.note_previews(false)
.options_button(true)
.is_preview(true)
.full_date(false)
.client_name(false)
}
pub fn selected_style(self, selected: bool) -> Self {
self.wide(selected)
.full_date(selected)
.client_name(selected)
}
#[inline]
pub fn textmode(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::Textmode, enable);
self
}
#[inline]
pub fn client_name(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::ClientName, enable);
self
}
#[inline]
pub fn full_date(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::FullCreatedDate, enable);
self
}
#[inline]
pub fn actionbar(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::ActionBar, enable);
self
}
#[inline]
pub fn hide_media(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::HideMedia, enable);
self
}
#[inline]
pub fn frame(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::Framed, enable);
self
}
#[inline]
pub fn truncate(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::Truncate, enable);
self
}
#[inline]
pub fn small_pfp(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::SmallPfp, enable);
self
}
#[inline]
pub fn medium_pfp(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::MediumPfp, enable);
self
}
#[inline]
pub fn note_previews(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::HasNotePreviews, enable);
self
}
#[inline]
pub fn selectable_text(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::SelectableText, enable);
self
}
#[inline]
pub fn wide(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::Wide, enable);
self
}
#[inline]
pub fn options_button(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::OptionsButton, enable);
self
}
#[inline]
pub fn unread_indicator(mut self, enable: bool) -> Self {
self.options_mut().set(NoteOptions::UnreadIndicator, enable);
self
}
#[inline]
pub fn options(&self) -> NoteOptions {
self.flags
}
#[inline]
pub fn options_mut(&mut self) -> &mut NoteOptions {
&mut self.flags
}
#[inline]
pub fn parent(mut self, parent: NoteKey) -> Self {
self.parent = Some(parent);
self
}
#[inline]
pub fn is_preview(mut self, is_preview: bool) -> Self {
self.options_mut().set(NoteOptions::IsPreview, is_preview);
self
}
fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
let txn = self.note.txn().expect("todo: implement non-db notes");
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
//ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
render_notetime(ui, self.note_context.i18n, self.note.created_at(), false)
});
let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| {
ui.add(
Username::new(
self.note_context.i18n,
profile.as_ref().ok(),
self.note.pubkey(),
)
.abbreviated(6)
.pk_colored(true),
)
});
ui.add(&mut NoteContents::new(
self.note_context,
txn,
self.note,
self.flags,
self.jobs,
));
//});
})
.response
}
pub fn expand_size() -> i8 {
5
}
fn pfp(
&mut self,
note_key: NoteKey,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
ui: &mut egui::Ui,
) -> PfpResponse {
if !self.options().contains(NoteOptions::Wide) {
ui.spacing_mut().item_spacing.x = 16.0;
} else {
ui.spacing_mut().item_spacing.x = 4.0;
}
let pfp_size = self.options().pfp_size();
match profile
.as_ref()
.ok()
.and_then(|p| p.record().profile()?.picture())
{
// these have different lifetimes and types,
// so the calls must be separate
Some(pic) => show_actual_pfp(
ui,
self.note_context.img_cache,
pic,
pfp_size,
note_key,
profile,
),
None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size),
}
}
pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if !self.flags.contains(NoteOptions::TrustMedia) {
let acc = self.note_context.accounts.get_selected_account();
if self.note.pubkey() == acc.key.pubkey.bytes()
|| matches!(
acc.is_following(self.note.pubkey()),
notedeck::IsFollowing::Yes
)
{
self.flags = self.flags.union(NoteOptions::TrustMedia);
}
}
if self.options().contains(NoteOptions::Textmode) {
NoteResponse::new(self.textmode_ui(ui))
} else if self.options().contains(NoteOptions::Framed) {
egui::Frame::new()
.fill(ui.visuals().noninteractive().weak_bg_fill)
.inner_margin(egui::Margin::same(8))
.outer_margin(egui::Margin::symmetric(0, 8))
.corner_radius(egui::CornerRadius::same(10))
.stroke(egui::Stroke::new(
1.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
if is_narrow(ui.ctx()) {
ui.set_width(ui.available_width());
}
self.show_standard(ui)
})
.inner
} else {
self.show_standard(ui)
}
}
#[profiling::function]
fn note_header(
ui: &mut egui::Ui,
i18n: &mut Localization,
note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
flags: NoteOptions,
) {
let horiz_resp = ui
.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 };
let response = ui
.add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20));
if !flags.contains(NoteOptions::FullCreatedDate) {
return render_notetime(ui, i18n, note.created_at(), true);
}
response
})
.response;
if flags.contains(NoteOptions::UnreadIndicator) {
let radius = 4.0;
let circle_center = {
let mut center = horiz_resp.rect.right_center();
center.x += radius + 4.0;
center
};
ui.painter()
.circle_filled(circle_center, radius, crate::colors::PINK);
}
}
fn wide_ui(
&mut self,
ui: &mut egui::Ui,
txn: &Transaction,
note_key: NoteKey,
profile: &Result<ProfileRecord, nostrdb::Error>,
) -> egui::InnerResponse<NoteUiResponse> {
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
let mut note_action: Option<NoteAction> = None;
let mut pfp_rect = None;
if !self.flags.contains(NoteOptions::NotificationPreview) {
ui.horizontal(|ui| {
let pfp_resp = self.pfp(note_key, profile, ui);
pfp_rect = Some(pfp_resp.bounding_rect);
note_action = pfp_resp
.into_action(self.note.pubkey())
.or(note_action.take());
let size = ui.available_size();
ui.vertical(|ui| {
ui.add_sized(
[size.x, self.options().pfp_size() as f32],
|ui: &mut egui::Ui| {
ui.horizontal_centered(|ui| {
NoteView::note_header(
ui,
self.note_context.i18n,
self.note,
profile,
self.flags,
);
})
.response
},
);
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_none() {
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
note_action = reply_desc(
ui,
txn,
&note_reply,
self.note_context,
self.flags,
self.jobs,
)
.or(note_action.take());
});
});
});
}
let mut contents =
NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs);
ui.add(&mut contents);
note_action = contents.action.or(note_action);
if self.options().contains(NoteOptions::ActionBar) {
note_action = ui
.horizontal_wrapped(|ui| {
// NOTE(jb55): without this we get a weird artifact where
// there subsequent lines start sinking leftward off the screen.
// question: WTF? question 2: WHY?
ui.allocate_space(egui::vec2(0.0, 0.0));
let counts = self
.note_context
.ndb
.get_note_metadata(txn, self.note.id())
.ok()
.and_then(|md| {
md.into_iter().find_map(|e| {
if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
Some(ce)
} else {
None
}
})
});
render_note_actionbar(
ui,
counts,
get_zapper(
self.note_context.accounts,
self.note_context.global_wallet,
self.note_context.zaps,
),
self.note,
self.note_context.accounts.selected_account_pubkey(),
note_key,
self.note_context.i18n,
)
})
.inner
.or(note_action);
}
NoteUiResponse {
action: note_action,
pfp_rect,
}
})
}
fn standard_ui(
&mut self,
ui: &mut egui::Ui,
txn: &Transaction,
note_key: NoteKey,
profile: &Result<ProfileRecord, nostrdb::Error>,
) -> egui::InnerResponse<NoteUiResponse> {
// main design
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
let (mut note_action, pfp_rect) =
if self.flags.contains(NoteOptions::NotificationPreview) {
// do not render pfp
(None, None)
} else {
let pfp_resp = self.pfp(note_key, profile, ui);
let pfp_rect = pfp_resp.bounding_rect;
(pfp_resp.into_action(self.note.pubkey()), Some(pfp_rect))
};
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
if !self.flags.contains(NoteOptions::NotificationPreview) {
NoteView::note_header(
ui,
self.note_context.i18n,
self.note,
profile,
self.flags,
);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
let note_reply = self
.note_context
.note_cache
.cached_note_or_insert_mut(note_key, self.note)
.reply
.borrow(self.note.tags());
if note_reply.reply().is_none() {
return;
}
note_action = reply_desc(
ui,
txn,
&note_reply,
self.note_context,
self.flags,
self.jobs,
)
.or(note_action.take());
});
}
let mut contents =
NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs);
ui.add(&mut contents);
note_action = contents.action.or(note_action);
if self.options().contains(NoteOptions::ActionBar) {
let counts = self
.note_context
.ndb
.get_note_metadata(txn, self.note.id())
.ok()
.and_then(|md| {
md.into_iter().find_map(|e| {
if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
Some(ce)
} else {
None
}
})
});
note_action = ui
.horizontal_wrapped(|ui| {
render_note_actionbar(
ui,
counts,
get_zapper(
self.note_context.accounts,
self.note_context.global_wallet,
self.note_context.zaps,
),
self.note,
self.note_context.accounts.selected_account_pubkey(),
note_key,
self.note_context.i18n,
)
})
.inner
.or(note_action);
}
NoteUiResponse {
action: note_action,
pfp_rect,
}
})
.inner
})
}
#[profiling::function]
fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
let note_key = self.note.key().expect("todo: support non-db notes");
let txn = self.note.txn().expect("todo: support non-db notes");
let profile = self
.note_context
.ndb
.get_profile_by_pubkey(txn, self.note.pubkey());
let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
// wide design
let response = if self.options().contains(NoteOptions::Wide) {
self.wide_ui(ui, txn, note_key, &profile)
} else {
self.standard_ui(ui, txn, note_key, &profile)
};
let note_ui_resp = response.inner;
let mut note_action = note_ui_resp.action;
if self.options().contains(NoteOptions::OptionsButton) {
let context_pos = {
let size = NoteContextButton::max_width();
let top_right = response.response.rect.right_top();
let min = Pos2::new(top_right.x - size, top_right.y);
Rect::from_min_size(min, egui::vec2(size, size))
};
let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone())
{
note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
}
}
note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox)
.then_some(NoteAction::note(NoteId::new(*self.note.id())))
.or(note_action);
let mut resp = NoteResponse::new(response.response).with_action(note_action);
if let Some(pfp_rect) = note_ui_resp.pfp_rect {
resp = resp.with_pfp(pfp_rect);
}
resp
}
}
fn get_zapper<'a>(
accounts: &'a Accounts,
global_wallet: &'a GlobalWallet,
zaps: &'a Zaps,
) -> Option<Zapper<'a>> {
let has_wallet = get_current_wallet(accounts, global_wallet).is_some();
let cur_acc = accounts.get_selected_account();
has_wallet.then_some(Zapper {
zaps,
cur_acc: cur_acc.keypair(),
})
}
pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
if note.kind() != 6 {
return None;
}
let new_note_id: &[u8; 32] = {
let mut res = None;
for tag in note.tags().iter() {
if tag.count() == 0 {
continue;
}
if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
res = Some(note_id);
break;
}
}
}
res?
};
let note = ndb.get_note_by_id(txn, new_note_id).ok();
note.filter(|note| note.kind() == 1)
}
struct NoteUiResponse {
action: Option<NoteAction>,
pfp_rect: Option<egui::Rect>,
}
struct PfpResponse {
action: Option<MediaAction>,
response: egui::Response,
bounding_rect: egui::Rect,
}
impl PfpResponse {
fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> {
if self.response.clicked() {
return Some(NoteAction::Profile(Pubkey::new(*note_pk)));
}
self.action.map(NoteAction::Media)
}
}
fn show_actual_pfp(
ui: &mut egui::Ui,
images: &mut Images,
pic: &str,
pfp_size: i8,
note_key: NoteKey,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
) -> PfpResponse {
let anim_speed = 0.05;
let profile_key = profile.as_ref().unwrap().record().note_key();
let note_key = note_key.as_u64();
let (rect, size, resp) = crate::anim::hover_expand(
ui,
egui::Id::new((profile_key, note_key)),
pfp_size as f32,
NoteView::expand_size() as f32,
anim_speed,
);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
let mut pfp = ProfilePic::new(images, pic).size(size);
let pfp_resp = ui.put(rect, &mut pfp);
let action = pfp.action;
pfp_resp.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(300.0);
ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images));
});
PfpResponse {
response: resp,
action,
bounding_rect: rect.shrink((rect.width() - size) / 2.0),
}
}
fn show_fallback_pfp(ui: &mut egui::Ui, images: &mut Images, pfp_size: i8) -> PfpResponse {
let sense = Sense::click();
// This has to match the expand size from the above case to
// prevent bounciness
let size = (pfp_size + NoteView::expand_size()) as f32;
let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
let mut pfp = ProfilePic::new(images, notedeck::profile::no_pfp_url()).size(pfp_size as f32);
let response = ui.put(rect, &mut pfp).interact(sense);
PfpResponse {
action: pfp.action,
response,
bounding_rect: rect.shrink((rect.width() - size) / 2.0),
}
}
fn note_hitbox_id(
note_key: NoteKey,
note_options: NoteOptions,
parent: Option<NoteKey>,
) -> egui::Id {
Id::new(("note_size", note_key, note_options, parent))
}
fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
ui.ctx()
.data_mut(|d| d.get_temp(hitbox_id))
.map(|note_size: Vec2| {
// The hitbox should extend the entire width of the
// container. The hitbox height was cached last layout.
let container_rect = ui.max_rect();
let rect = Rect {
min: pos2(container_rect.min.x, container_rect.min.y),
max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
};
let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
response
})
}
fn note_hitbox_clicked(
ui: &mut egui::Ui,
hitbox_id: egui::Id,
note_rect: &Rect,
maybe_hitbox: Option<Response>,
) -> bool {
// Stash the dimensions of the note content so we can render the
// hitbox in the next frame
ui.ctx().data_mut(|d| {
d.insert_temp(hitbox_id, note_rect.size());
});
// If there was an hitbox and it was clicked open the thread
match maybe_hitbox {
Some(hitbox) => hitbox.clicked(),
_ => false,
}
}
struct Zapper<'a> {
zaps: &'a Zaps,
cur_acc: KeypairUnowned<'a>,
}
fn zap_actionbar_button(
ui: &mut egui::Ui,
note_id: &[u8; 32],
note_pubkey: &[u8; 32],
zapper: Option<Zapper<'_>>,
i18n: &mut Localization,
) -> Option<NoteAction> {
let mut action: Option<NoteAction> = None;
let Zapper { zaps, cur_acc } = zapper?;
let zap_target = ZapTarget::Note(NoteZapTarget {
note_id,
zap_recipient: note_pubkey,
});
let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target);
let target = NoteZapTargetOwned {
note_id: NoteId::new(*note_id),
zap_recipient: Pubkey::new(*note_pubkey),
};
cur_acc.secret_key.as_ref()?;
match zap_state {
Ok(any_zap_state) => {
let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id));
if zap_resp.secondary_clicked() {
action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone())));
}
if zap_resp.clicked() {
action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount {
target,
specified_msats: None,
})));
}
zap_resp
}
Err(err) => {
let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string());
if x_button.clicked() {
action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone())));
}
x_button
}
}
.on_hover_cursor(egui::CursorIcon::PointingHand);
action
}
fn is_root_note(note: &Note) -> bool {
for tag in note.tags() {
if tag.count() < 2 {
continue;
}
// any reference to an e tag is a non-root note
if tag.get_str(0) == Some("e") {
return false;
}
}
true
}
#[profiling::function]
fn render_note_actionbar(
ui: &mut egui::Ui,
counts: Option<nostrdb::CountsEntry<'_>>,
zapper: Option<Zapper<'_>>,
note: &Note,
current_user_pubkey: &Pubkey,
note_key: NoteKey,
i18n: &mut Localization,
) -> Option<NoteAction> {
let mut action = None;
let spacing = 24.0;
ui.spacing_mut().item_spacing.x = 2.0;
ui.set_min_height(26.0);
let reply_resp =
reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
if let Some(c) = &counts {
let count = if is_root_note(note) {
c.thread_replies()
} else {
c.direct_replies() as u32
};
if count > 0 {
ui.weak(format!("{}", count));
}
}
ui.add_space(spacing);
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);
if let Some(c) = &counts {
let count = c.reactions();
if count > 0 {
ui.weak(format!("{}", count));
}
}
ui.add_space(spacing);
let quote_resp =
quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
if let Some(c) = &counts {
let count = c.quotes() + c.reposts();
if count > 0 {
ui.weak(format!("{}", count));
}
}
ui.add_space(spacing);
if reply_resp.clicked() {
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())));
}
action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action);
action
}
#[profiling::function]
fn render_notetime(
ui: &mut egui::Ui,
i18n: &mut Localization,
created_at: u64,
before: bool,
) -> Response {
if before {
secondary_label(
ui,
format!("{}", notedeck::time_ago_since(i18n, created_at)),
)
} else {
secondary_label(
ui,
format!("{}", notedeck::time_ago_since(i18n, created_at)),
)
}
}
fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response {
let img = if ui.style().visuals.dark_mode {
app_images::reply_dark_image()
} else {
app_images::reply_light_image()
};
let (rect, size, resp) =
crate::anim::hover_expand_small(ui, ui.id().with(("reply_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,
"Reply to this note",
"Hover text for reply button"
));
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()
} else {
app_images::repost_light_image()
}
}
fn quote_repost_button(
ui: &mut egui::Ui,
i18n: &mut Localization,
note_key: NoteKey,
) -> egui::Response {
let size = crate::anim::hover_small_size() + 4.0;
let expand_size = 5.0;
let anim_speed = 0.05;
let id = ui.id().with(("repost_anim", note_key));
let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed);
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0));
let put_resp = ui
.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size))
.on_hover_text(tr!(
i18n,
"Repost this note",
"Hover text for repost button"
));
resp.union(put_resp)
}
fn zap_button<'a>(
i18n: &'a mut Localization,
state: AnyZapState,
noteid: &'a [u8; 32],
) -> impl egui::Widget + use<'a> {
move |ui: &mut egui::Ui| -> egui::Response {
let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap"));
let mut img = app_images::zap_dark_image().max_width(size);
let id = ui.id().with(("pulse", noteid));
let ctx = ui.ctx().clone();
match state {
AnyZapState::None => {
if !ui.visuals().dark_mode {
img = app_images::zap_light_image();
}
}
AnyZapState::Pending => {
let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 };
let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255)
.with_speed(0.35)
.animate();
let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha);
img = img.tint(cur_color);
}
AnyZapState::LocalOnly => {
img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57));
}
AnyZapState::Confirmed => {}
}
// 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).on_hover_text(tr!(
i18n,
"Zap this note",
"Hover text for zap button"
));
resp.union(put_resp)
}
}