use egui::InnerResponse; use egui_virtual_list::VirtualList; use nostrdb::{Note, Transaction}; use notedeck::note::root_note_id_from_selected_id; use notedeck::{NoteAction, NoteContext}; use notedeck_ui::jobs::JobsCache; use notedeck_ui::note::NoteResponse; use notedeck_ui::{NoteOptions, NoteView}; use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads}; pub struct ThreadView<'a, 'd> { threads: &'a mut Threads, selected_note_id: &'a [u8; 32], note_options: NoteOptions, col: usize, id_source: egui::Id, note_context: &'a mut NoteContext<'d>, jobs: &'a mut JobsCache, } impl<'a, 'd> ThreadView<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( threads: &'a mut Threads, selected_note_id: &'a [u8; 32], note_options: NoteOptions, note_context: &'a mut NoteContext<'d>, jobs: &'a mut JobsCache, ) -> Self { let id_source = egui::Id::new("threadscroll_threadview"); ThreadView { threads, selected_note_id, note_options, id_source, note_context, jobs, col: 0, } } pub fn id_source(mut self, col: usize) -> Self { self.col = col; self.id_source = egui::Id::new(("threadscroll", col)); self } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option { let txn = Transaction::new(self.note_context.ndb).expect("txn"); let mut scroll_area = egui::ScrollArea::vertical() .id_salt(self.id_source) .animated(false) .auto_shrink([false, false]) .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible); let offset_id = self .id_source .with(("scroll_offset", self.selected_note_id)); if let Some(offset) = ui.data(|i| i.get_temp::(offset_id)) { scroll_area = scroll_area.vertical_scroll_offset(offset); } let output = scroll_area.show(ui, |ui| self.notes(ui, &txn)); ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y)); output.inner } fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option { let Ok(cur_note) = self .note_context .ndb .get_note_by_id(txn, self.selected_note_id) else { let id = *self.selected_note_id; tracing::error!("ndb: Did not find note {}", enostr::NoteId::new(id).hex()); return None; }; self.threads.update( &cur_note, self.note_context.note_cache, self.note_context.ndb, txn, self.note_context.unknown_ids, self.col, ); let cur_node = self.threads.threads.get(&self.selected_note_id).unwrap(); let full_chain = cur_node.have_all_ancestors; let mut note_builder = ThreadNoteBuilder::new(cur_note); let mut parent_state = cur_node.prev.clone(); while let ParentState::Parent(id) = parent_state { if let Ok(note) = self.note_context.ndb.get_note_by_id(txn, id.bytes()) { note_builder.add_chain(note); if let Some(res) = self.threads.threads.get(&id.bytes()) { parent_state = res.prev.clone(); continue; } } parent_state = ParentState::Unknown; } for note_ref in &cur_node.replies { if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) { note_builder.add_reply(note); } } let list = &mut self .threads .threads .get_mut(&self.selected_note_id) .unwrap() .list; let notes = note_builder.into_notes(&mut self.threads.seen_flags); if !full_chain { // TODO(kernelkind): insert UI denoting we don't have the full chain yet ui.colored_label(ui.visuals().error_fg_color, "LOADING NOTES"); } show_notes( ui, list, ¬es, self.note_context, self.note_options, self.jobs, txn, ) } } #[allow(clippy::too_many_arguments)] fn show_notes( ui: &mut egui::Ui, list: &mut VirtualList, thread_notes: &ThreadNotes, note_context: &mut NoteContext<'_>, flags: NoteOptions, jobs: &mut JobsCache, txn: &Transaction, ) -> Option { 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; let is_muted = note_context.accounts.mutefun(); list.ui_custom_layout(ui, notes.len(), |ui, cur_index| { 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 { return 1; } let resp = note.show(note_context, 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 { if matches!( action, NoteAction::Note { note_id: _, preview: false, } ) { return None; } Some(action) } struct ThreadNoteBuilder<'a> { chain: Vec>, selected: Note<'a>, replies: Vec>, } 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>, 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(NoteOptions::Wide, true); cur_options.set(NoteOptions::SelectableText, true); cur_options } ThreadNoteType::Reply => cur_options, } } fn show( &self, note_context: &'a mut NoteContext<'_>, flags: NoteOptions, jobs: &'a mut JobsCache, ui: &mut egui::Ui, ) -> NoteResponse { let inner = notedeck_ui::padding(8.0, ui, |ui| { NoteView::new(note_context, &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, 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, 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 };