notedeck

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

commit b3569e90d622400e46b94b102c83381d78599875
parent 51476772c46058a293574b84a30e3f3c53cfdae0
Author: kernelkind <kernelkind@gmail.com>
Date:   Mon, 16 Jun 2025 17:55:47 -0400

thread UI

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

Diffstat:
Mcrates/notedeck_columns/src/ui/thread.rs | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 265 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -1,10 +1,15 @@ +use egui::InnerResponse; +use egui_virtual_list::VirtualList; use enostr::KeypairUnowned; -use nostrdb::Transaction; +use nostrdb::{Note, Transaction}; +use notedeck::note::root_note_id_from_selected_id; use notedeck::{MuteFun, NoteAction, NoteContext, RootNoteId, UnknownIds}; use notedeck_ui::jobs::JobsCache; -use notedeck_ui::NoteOptions; +use notedeck_ui::note::NoteResponse; +use notedeck_ui::{NoteOptions, NoteView}; use tracing::error; +use crate::timeline::thread::NoteSeenFlags; use crate::timeline::{ThreadSelection, TimelineCache, TimelineKind}; use crate::ui::timeline::TimelineTabView; @@ -60,7 +65,9 @@ impl<'a, 'd> ThreadView<'a, 'd> { .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible); - let offset_id = self.id_source.with("scroll_offset"); + let offset_id = self + .id_source + .with(("scroll_offset", self.selected_note_id)); if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) { scroll_area = scroll_area.vertical_scroll_offset(offset); @@ -123,3 +130,258 @@ impl<'a, 'd> ThreadView<'a, 'd> { output.inner } } + +#[allow(clippy::too_many_arguments)] +fn show_notes( + ui: &mut egui::Ui, + list: &mut VirtualList, + thread_notes: &ThreadNotes, + note_context: &mut NoteContext<'_>, + zapping_acc: Option<&KeypairUnowned<'_>>, + flags: NoteOptions, + jobs: &mut JobsCache, + txn: &Transaction, + is_muted: &MuteFun, +) -> Option<NoteAction> { + let mut action = None; + + ui.spacing_mut().item_spacing.y = 0.0; + ui.spacing_mut().item_spacing.x = 4.0; + + let selected_note_index = thread_notes.selected_index; + let notes = &thread_notes.notes; + + list.ui_custom_layout(ui, notes.len(), |ui, cur_index| 's: { + let note = &notes[cur_index]; + + // should we mute the thread? we might not have it! + let muted = root_note_id_from_selected_id( + note_context.ndb, + note_context.note_cache, + txn, + note.note.id(), + ) + .ok() + .is_some_and(|root_id| is_muted(&note.note, root_id.bytes())); + + if muted { + break 's 0; + } + + let resp = note.show(note_context, zapping_acc, flags, jobs, ui); + + action = if cur_index == selected_note_index { + resp.action.and_then(strip_note_action) + } else { + resp.action + } + .or(action.take()); + + 1 + }); + + action +} + +fn strip_note_action(action: NoteAction) -> Option<NoteAction> { + if matches!(action, NoteAction::Note(_)) { + return None; + } + + Some(action) +} + +struct ThreadNoteBuilder<'a> { + chain: Vec<Note<'a>>, + selected: Note<'a>, + replies: Vec<Note<'a>>, +} + +impl<'a> ThreadNoteBuilder<'a> { + pub fn new(selected: Note<'a>) -> Self { + Self { + chain: Vec::new(), + selected, + replies: Vec::new(), + } + } + + pub fn add_chain(&mut self, note: Note<'a>) { + self.chain.push(note); + } + + pub fn add_reply(&mut self, note: Note<'a>) { + self.replies.push(note); + } + + pub fn into_notes(mut self, seen_flags: &mut NoteSeenFlags) -> ThreadNotes<'a> { + let mut notes = Vec::new(); + + let selected_is_root = self.chain.is_empty(); + let mut cur_is_root = true; + while let Some(note) = self.chain.pop() { + notes.push(ThreadNote { + unread_and_have_replies: *seen_flags.get(note.id()).unwrap_or(&false), + note, + note_type: ThreadNoteType::Chain { root: cur_is_root }, + }); + cur_is_root = false; + } + + let selected_index = notes.len(); + notes.push(ThreadNote { + note: self.selected, + note_type: ThreadNoteType::Selected { + root: selected_is_root, + }, + unread_and_have_replies: false, + }); + + for reply in self.replies { + notes.push(ThreadNote { + unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false), + note: reply, + note_type: ThreadNoteType::Reply, + }); + } + + ThreadNotes { + notes, + selected_index, + } + } +} + +enum ThreadNoteType { + Chain { root: bool }, + Selected { root: bool }, + Reply, +} + +struct ThreadNotes<'a> { + notes: Vec<ThreadNote<'a>>, + selected_index: usize, +} + +struct ThreadNote<'a> { + pub note: Note<'a>, + note_type: ThreadNoteType, + pub unread_and_have_replies: bool, +} + +impl<'a> ThreadNote<'a> { + fn options(&self, mut cur_options: NoteOptions) -> NoteOptions { + match self.note_type { + ThreadNoteType::Chain { root: _ } => cur_options, + ThreadNoteType::Selected { root: _ } => { + cur_options.set_wide(true); + cur_options + } + ThreadNoteType::Reply => cur_options, + } + } + + fn show( + &self, + note_context: &'a mut NoteContext<'_>, + zapping_acc: Option<&'a KeypairUnowned<'a>>, + flags: NoteOptions, + jobs: &'a mut JobsCache, + ui: &mut egui::Ui, + ) -> NoteResponse { + let inner = notedeck_ui::padding(8.0, ui, |ui| { + NoteView::new( + note_context, + zapping_acc, + &self.note, + self.options(flags), + jobs, + ) + .unread_indicator(self.unread_and_have_replies) + .show(ui) + }); + + match self.note_type { + ThreadNoteType::Chain { root } => add_chain_adornment(ui, &inner, root), + ThreadNoteType::Selected { root } => add_selected_adornment(ui, &inner, root), + ThreadNoteType::Reply => notedeck_ui::hline(ui), + } + + inner.inner + } +} + +fn add_chain_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { + let Some(pfp_rect) = note_resp.inner.pfp_rect else { + return; + }; + + let note_rect = note_resp.response.rect; + + let painter = ui.painter_at(note_rect); + + if !root { + paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect); + } + + // painting line below pfp: + let top_pt = { + let mut top = pfp_rect.center(); + top.y = pfp_rect.bottom(); + top + }; + + let bottom_pt = { + let mut bottom = top_pt; + bottom.y = note_rect.bottom(); + bottom + }; + + painter.line_segment([top_pt, bottom_pt], LINE_STROKE(ui)); + + let hline_min_x = top_pt.x + 6.0; + notedeck_ui::hline_with_width( + ui, + egui::Rangef::new(hline_min_x, ui.available_rect_before_wrap().right()), + ); +} + +fn add_selected_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { + let Some(pfp_rect) = note_resp.inner.pfp_rect else { + return; + }; + let note_rect = note_resp.response.rect; + let painter = ui.painter_at(note_rect); + + if !root { + paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect); + } + notedeck_ui::hline(ui); +} + +fn paint_line_above_pfp( + ui: &egui::Ui, + painter: &egui::Painter, + pfp_rect: &egui::Rect, + note_rect: &egui::Rect, +) { + let bottom_pt = { + let mut center = pfp_rect.center(); + center.y = pfp_rect.top(); + center + }; + + let top_pt = { + let mut top = bottom_pt; + top.y = note_rect.top(); + top + }; + + painter.line_segment([bottom_pt, top_pt], LINE_STROKE(ui)); +} + +const LINE_STROKE: fn(&egui::Ui) -> egui::Stroke = |ui: &egui::Ui| { + let mut stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; + stroke.width = 2.0; + stroke +};