thread.rs (10890B)
1 use egui::InnerResponse; 2 use egui_virtual_list::VirtualList; 3 use nostrdb::{Note, Transaction}; 4 use notedeck::note::root_note_id_from_selected_id; 5 use notedeck::{NoteAction, NoteContext}; 6 use notedeck_ui::jobs::JobsCache; 7 use notedeck_ui::note::NoteResponse; 8 use notedeck_ui::{NoteOptions, NoteView}; 9 10 use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads}; 11 12 pub struct ThreadView<'a, 'd> { 13 threads: &'a mut Threads, 14 selected_note_id: &'a [u8; 32], 15 note_options: NoteOptions, 16 col: usize, 17 id_source: egui::Id, 18 note_context: &'a mut NoteContext<'d>, 19 jobs: &'a mut JobsCache, 20 } 21 22 impl<'a, 'd> ThreadView<'a, 'd> { 23 #[allow(clippy::too_many_arguments)] 24 pub fn new( 25 threads: &'a mut Threads, 26 selected_note_id: &'a [u8; 32], 27 note_options: NoteOptions, 28 note_context: &'a mut NoteContext<'d>, 29 jobs: &'a mut JobsCache, 30 ) -> Self { 31 let id_source = egui::Id::new("threadscroll_threadview"); 32 ThreadView { 33 threads, 34 selected_note_id, 35 note_options, 36 id_source, 37 note_context, 38 jobs, 39 col: 0, 40 } 41 } 42 43 pub fn id_source(mut self, col: usize) -> Self { 44 self.col = col; 45 self.id_source = egui::Id::new(("threadscroll", col)); 46 self 47 } 48 49 pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<NoteAction> { 50 let txn = Transaction::new(self.note_context.ndb).expect("txn"); 51 52 let mut scroll_area = egui::ScrollArea::vertical() 53 .id_salt(self.id_source) 54 .animated(false) 55 .auto_shrink([false, false]) 56 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible); 57 58 let offset_id = self 59 .id_source 60 .with(("scroll_offset", self.selected_note_id)); 61 62 if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) { 63 scroll_area = scroll_area.vertical_scroll_offset(offset); 64 } 65 66 let output = scroll_area.show(ui, |ui| self.notes(ui, &txn)); 67 68 ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y)); 69 70 output.inner 71 } 72 73 fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option<NoteAction> { 74 let Ok(cur_note) = self 75 .note_context 76 .ndb 77 .get_note_by_id(txn, self.selected_note_id) 78 else { 79 let id = *self.selected_note_id; 80 tracing::error!("ndb: Did not find note {}", enostr::NoteId::new(id).hex()); 81 return None; 82 }; 83 84 self.threads.update( 85 &cur_note, 86 self.note_context.note_cache, 87 self.note_context.ndb, 88 txn, 89 self.note_context.unknown_ids, 90 self.col, 91 ); 92 93 let cur_node = self.threads.threads.get(&self.selected_note_id).unwrap(); 94 95 let full_chain = cur_node.have_all_ancestors; 96 let mut note_builder = ThreadNoteBuilder::new(cur_note); 97 98 let mut parent_state = cur_node.prev.clone(); 99 while let ParentState::Parent(id) = parent_state { 100 if let Ok(note) = self.note_context.ndb.get_note_by_id(txn, id.bytes()) { 101 note_builder.add_chain(note); 102 if let Some(res) = self.threads.threads.get(&id.bytes()) { 103 parent_state = res.prev.clone(); 104 continue; 105 } 106 } 107 parent_state = ParentState::Unknown; 108 } 109 110 for note_ref in &cur_node.replies { 111 if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) { 112 note_builder.add_reply(note); 113 } 114 } 115 116 let list = &mut self 117 .threads 118 .threads 119 .get_mut(&self.selected_note_id) 120 .unwrap() 121 .list; 122 123 let notes = note_builder.into_notes(&mut self.threads.seen_flags); 124 125 if !full_chain { 126 // TODO(kernelkind): insert UI denoting we don't have the full chain yet 127 ui.colored_label(ui.visuals().error_fg_color, "LOADING NOTES"); 128 } 129 130 show_notes( 131 ui, 132 list, 133 ¬es, 134 self.note_context, 135 self.note_options, 136 self.jobs, 137 txn, 138 ) 139 } 140 } 141 142 #[allow(clippy::too_many_arguments)] 143 fn show_notes( 144 ui: &mut egui::Ui, 145 list: &mut VirtualList, 146 thread_notes: &ThreadNotes, 147 note_context: &mut NoteContext<'_>, 148 flags: NoteOptions, 149 jobs: &mut JobsCache, 150 txn: &Transaction, 151 ) -> Option<NoteAction> { 152 let mut action = None; 153 154 ui.spacing_mut().item_spacing.y = 0.0; 155 ui.spacing_mut().item_spacing.x = 4.0; 156 157 let selected_note_index = thread_notes.selected_index; 158 let notes = &thread_notes.notes; 159 160 let is_muted = note_context.accounts.mutefun(); 161 162 list.ui_custom_layout(ui, notes.len(), |ui, cur_index| { 163 let note = ¬es[cur_index]; 164 165 // should we mute the thread? we might not have it! 166 let muted = root_note_id_from_selected_id( 167 note_context.ndb, 168 note_context.note_cache, 169 txn, 170 note.note.id(), 171 ) 172 .ok() 173 .is_some_and(|root_id| is_muted(¬e.note, root_id.bytes())); 174 175 if muted { 176 return 1; 177 } 178 179 let resp = note.show(note_context, flags, jobs, ui); 180 181 action = if cur_index == selected_note_index { 182 resp.action.and_then(strip_note_action) 183 } else { 184 resp.action 185 } 186 .or(action.take()); 187 188 1 189 }); 190 191 action 192 } 193 194 fn strip_note_action(action: NoteAction) -> Option<NoteAction> { 195 if matches!( 196 action, 197 NoteAction::Note { 198 note_id: _, 199 preview: false, 200 } 201 ) { 202 return None; 203 } 204 205 Some(action) 206 } 207 208 struct ThreadNoteBuilder<'a> { 209 chain: Vec<Note<'a>>, 210 selected: Note<'a>, 211 replies: Vec<Note<'a>>, 212 } 213 214 impl<'a> ThreadNoteBuilder<'a> { 215 pub fn new(selected: Note<'a>) -> Self { 216 Self { 217 chain: Vec::new(), 218 selected, 219 replies: Vec::new(), 220 } 221 } 222 223 pub fn add_chain(&mut self, note: Note<'a>) { 224 self.chain.push(note); 225 } 226 227 pub fn add_reply(&mut self, note: Note<'a>) { 228 self.replies.push(note); 229 } 230 231 pub fn into_notes(mut self, seen_flags: &mut NoteSeenFlags) -> ThreadNotes<'a> { 232 let mut notes = Vec::new(); 233 234 let selected_is_root = self.chain.is_empty(); 235 let mut cur_is_root = true; 236 while let Some(note) = self.chain.pop() { 237 notes.push(ThreadNote { 238 unread_and_have_replies: *seen_flags.get(note.id()).unwrap_or(&false), 239 note, 240 note_type: ThreadNoteType::Chain { root: cur_is_root }, 241 }); 242 cur_is_root = false; 243 } 244 245 let selected_index = notes.len(); 246 notes.push(ThreadNote { 247 note: self.selected, 248 note_type: ThreadNoteType::Selected { 249 root: selected_is_root, 250 }, 251 unread_and_have_replies: false, 252 }); 253 254 for reply in self.replies { 255 notes.push(ThreadNote { 256 unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false), 257 note: reply, 258 note_type: ThreadNoteType::Reply, 259 }); 260 } 261 262 ThreadNotes { 263 notes, 264 selected_index, 265 } 266 } 267 } 268 269 enum ThreadNoteType { 270 Chain { root: bool }, 271 Selected { root: bool }, 272 Reply, 273 } 274 275 struct ThreadNotes<'a> { 276 notes: Vec<ThreadNote<'a>>, 277 selected_index: usize, 278 } 279 280 struct ThreadNote<'a> { 281 pub note: Note<'a>, 282 note_type: ThreadNoteType, 283 pub unread_and_have_replies: bool, 284 } 285 286 impl<'a> ThreadNote<'a> { 287 fn options(&self, mut cur_options: NoteOptions) -> NoteOptions { 288 match self.note_type { 289 ThreadNoteType::Chain { root: _ } => cur_options, 290 ThreadNoteType::Selected { root: _ } => { 291 cur_options.set(NoteOptions::Wide, true); 292 cur_options.set(NoteOptions::SelectableText, true); 293 cur_options 294 } 295 ThreadNoteType::Reply => cur_options, 296 } 297 } 298 299 fn show( 300 &self, 301 note_context: &'a mut NoteContext<'_>, 302 flags: NoteOptions, 303 jobs: &'a mut JobsCache, 304 ui: &mut egui::Ui, 305 ) -> NoteResponse { 306 let inner = notedeck_ui::padding(8.0, ui, |ui| { 307 NoteView::new(note_context, &self.note, self.options(flags), jobs) 308 .unread_indicator(self.unread_and_have_replies) 309 .show(ui) 310 }); 311 312 match self.note_type { 313 ThreadNoteType::Chain { root } => add_chain_adornment(ui, &inner, root), 314 ThreadNoteType::Selected { root } => add_selected_adornment(ui, &inner, root), 315 ThreadNoteType::Reply => notedeck_ui::hline(ui), 316 } 317 318 inner.inner 319 } 320 } 321 322 fn add_chain_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { 323 let Some(pfp_rect) = note_resp.inner.pfp_rect else { 324 return; 325 }; 326 327 let note_rect = note_resp.response.rect; 328 329 let painter = ui.painter_at(note_rect); 330 331 if !root { 332 paint_line_above_pfp(ui, &painter, &pfp_rect, ¬e_rect); 333 } 334 335 // painting line below pfp: 336 let top_pt = { 337 let mut top = pfp_rect.center(); 338 top.y = pfp_rect.bottom(); 339 top 340 }; 341 342 let bottom_pt = { 343 let mut bottom = top_pt; 344 bottom.y = note_rect.bottom(); 345 bottom 346 }; 347 348 painter.line_segment([top_pt, bottom_pt], LINE_STROKE(ui)); 349 350 let hline_min_x = top_pt.x + 6.0; 351 notedeck_ui::hline_with_width( 352 ui, 353 egui::Rangef::new(hline_min_x, ui.available_rect_before_wrap().right()), 354 ); 355 } 356 357 fn add_selected_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) { 358 let Some(pfp_rect) = note_resp.inner.pfp_rect else { 359 return; 360 }; 361 let note_rect = note_resp.response.rect; 362 let painter = ui.painter_at(note_rect); 363 364 if !root { 365 paint_line_above_pfp(ui, &painter, &pfp_rect, ¬e_rect); 366 } 367 notedeck_ui::hline(ui); 368 } 369 370 fn paint_line_above_pfp( 371 ui: &egui::Ui, 372 painter: &egui::Painter, 373 pfp_rect: &egui::Rect, 374 note_rect: &egui::Rect, 375 ) { 376 let bottom_pt = { 377 let mut center = pfp_rect.center(); 378 center.y = pfp_rect.top(); 379 center 380 }; 381 382 let top_pt = { 383 let mut top = bottom_pt; 384 top.y = note_rect.top(); 385 top 386 }; 387 388 painter.line_segment([bottom_pt, top_pt], LINE_STROKE(ui)); 389 } 390 391 const LINE_STROKE: fn(&egui::Ui) -> egui::Stroke = |ui: &egui::Ui| { 392 let mut stroke = ui.style().visuals.widgets.noninteractive.bg_stroke; 393 stroke.width = 2.0; 394 stroke 395 };