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