notedeck

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

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