notedeck

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

commit 7d4e9799e531f5cec273cc8b4dece2682ec035b8
parent 55d7cd3379fe8aaa9ba4c1965df45f468171fbbe
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 25 Aug 2025 10:05:28 -0400

ui: add rendering for `NoteUnit`s

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck_columns/src/ui/timeline.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 204 insertions(+), 4 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -1,13 +1,19 @@ use egui::containers::scroll_area::ScrollBarVisibility; -use egui::{vec2, Direction, Layout, Pos2, Stroke}; +use egui::{vec2, Direction, Layout, Pos2, ScrollArea, Sense, Stroke}; use egui_tabs::TabColor; -use nostrdb::Transaction; +use enostr::Pubkey; +use nostrdb::{ProfileRecord, Transaction}; +use notedeck::name::get_display_name; use notedeck::ui::is_narrow; -use notedeck::JobsCache; +use notedeck::{JobsCache, Muted, NoteRef}; +use notedeck_ui::app_images::like_image; +use notedeck_ui::{padding, ProfilePic}; use std::f32::consts::PI; use tracing::{error, warn}; -use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter}; +use crate::timeline::{ + CompositeUnit, NoteUnit, ReactionUnit, TimelineCache, TimelineKind, TimelineTab, ViewFilter, +}; use notedeck::{ note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, }; @@ -467,4 +473,198 @@ impl<'a, 'd> TimelineTabView<'a, 'd> { action } + + fn render_entry( + &mut self, + ui: &mut egui::Ui, + entry: &NoteUnit, + mute: &std::sync::Arc<Muted>, + ) -> RenderEntryResponse { + match entry { + NoteUnit::Single(note_ref) => render_note( + ui, + self.note_context, + self.note_options, + self.jobs, + mute, + self.txn, + note_ref, + ), + NoteUnit::Composite(composite) => match composite { + CompositeUnit::Reaction(reaction_unit) => render_reaction_cluster( + ui, + self.note_context, + self.note_options, + self.jobs, + mute, + self.txn, + reaction_unit, + ), + }, + } + } +} + +fn render_note( + ui: &mut egui::Ui, + note_context: &mut NoteContext, + note_options: NoteOptions, + jobs: &mut JobsCache, + mute: &std::sync::Arc<Muted>, + txn: &Transaction, + note_ref: &NoteRef, +) -> RenderEntryResponse { + let note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, note_ref.key) { + note + } else { + warn!("failed to query note {:?}", note_ref.key); + return RenderEntryResponse::Unsuccessful; + }; + + let muted = if let Ok(root_id) = + root_note_id_from_selected_id(note_context.ndb, note_context.note_cache, txn, note.id()) + { + mute.is_muted(&note, root_id.bytes()) + } else { + false + }; + + if muted { + return RenderEntryResponse::Success(None); + } + + let mut action = None; + notedeck_ui::padding(8.0, ui, |ui| { + let resp = NoteView::new(note_context, &note, note_options, jobs).show(ui); + + if let Some(note_action) = resp.action { + action = Some(note_action); + } + }); + + notedeck_ui::hline(ui); + + RenderEntryResponse::Success(action) +} + +fn render_reaction_cluster( + ui: &mut egui::Ui, + note_context: &mut NoteContext, + note_options: NoteOptions, + jobs: &mut JobsCache, + mute: &std::sync::Arc<Muted>, + txn: &Transaction, + reaction: &ReactionUnit, +) -> RenderEntryResponse { + let reacted_to_key = reaction.note_reacted_to.key; + let reacted_to_note = if let Ok(note) = note_context.ndb.get_note_by_key(txn, reacted_to_key) { + note + } else { + warn!("failed to query note {:?}", reacted_to_key); + return RenderEntryResponse::Unsuccessful; + }; + + let profiles_to_show: Vec<ProfileEntry> = reaction + .reactions + .values() + .filter(|r| !mute.is_pk_muted(r.sender.bytes())) + .map(|r| &r.sender) + .map(|p| ProfileEntry { + record: note_context.ndb.get_profile_by_pubkey(txn, p.bytes()).ok(), + pk: p, + }) + .collect(); + + let first_name = get_display_name(profiles_to_show.iter().find_map(|opt| opt.record.as_ref())) + .name() + .to_string(); + let num_profiles_other = profiles_to_show.len() - 1; + + let mut action = None; + padding(8.0, ui, |ui| { + ui.allocate_ui_with_layout( + vec2(ui.available_width(), 32.0), + Layout::left_to_right(egui::Align::Center), + |ui| { + ui.vertical(|ui| { + ui.add_space(16.0); + ui.add_sized(vec2(32.0, 32.0), like_image()); + }); + + ui.add_space(16.0); + + ui.horizontal(|ui| { + ScrollArea::horizontal() + .scroll_bar_visibility(ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + for entry in profiles_to_show { + let resp = ui.add( + &mut ProfilePic::from_profile_or_default( + note_context.img_cache, + entry.record.as_ref(), + ) + .sense(Sense::click()), + ); + + if resp.clicked() { + action = Some(NoteAction::Profile(*entry.pk)) + } + } + }); + }); + }, + ); + + let note_type_desc = if note_context + .accounts + .get_selected_account() + .key + .pubkey + .bytes() + != reacted_to_note.pubkey() + { + "note you were tagged in" + } else { + "your note" + }; + + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.add_space(52.0); + + ui.horizontal_wrapped(|ui| { + if num_profiles_other > 0 { + ui.label(format!( + "{first_name} and {num_profiles_other} others reacted to {note_type_desc}", + )); + } else { + ui.label(format!("{first_name} reacted to {note_type_desc}")); + } + }); + }); + + ui.add_space(16.0); + + ui.horizontal(|ui| { + ui.add_space(48.0); + let resp = NoteView::new(note_context, &reacted_to_note, note_options, jobs).show(ui); + + if let Some(note_action) = resp.action { + action = Some(note_action); + } + }); + }); + + notedeck_ui::hline(ui); + RenderEntryResponse::Success(action) +} + +enum RenderEntryResponse { + Unsuccessful, + Success(Option<NoteAction>), +} + +struct ProfileEntry<'a> { + record: Option<ProfileRecord<'a>>, + pk: &'a Pubkey, }