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:
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 = ¬es[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(¬e.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, ¬e_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, ¬e_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
+};