notedeck

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

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             &notes,
    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 = &notes[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(&note.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, &note_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, &note_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 };