notedeck

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

thread.rs (11450B)


      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::note::NoteResponse;
      7 use notedeck_ui::{NoteOptions, NoteView};
      8 
      9 use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads};
     10 use notedeck::DragResponse;
     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 }
     19 
     20 impl<'a, 'd> ThreadView<'a, 'd> {
     21     #[allow(clippy::too_many_arguments)]
     22     pub fn new(
     23         threads: &'a mut Threads,
     24         selected_note_id: &'a [u8; 32],
     25         note_options: NoteOptions,
     26         note_context: &'a mut NoteContext<'d>,
     27         col: usize,
     28     ) -> Self {
     29         ThreadView {
     30             threads,
     31             selected_note_id,
     32             note_options,
     33             note_context,
     34             col,
     35         }
     36     }
     37 
     38     pub fn scroll_id(selected_note_id: &[u8; 32], col: usize) -> egui::Id {
     39         egui::Id::new(("threadscroll", selected_note_id, col))
     40     }
     41 
     42     pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<NoteAction> {
     43         let txn = Transaction::new(self.note_context.ndb).expect("txn");
     44 
     45         let scroll_id = ThreadView::scroll_id(self.selected_note_id, self.col);
     46         let mut scroll_area = egui::ScrollArea::vertical()
     47             .id_salt(scroll_id)
     48             .animated(false)
     49             .auto_shrink([false, false])
     50             .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
     51 
     52         if let Some(thread) = self.threads.threads.get_mut(&self.selected_note_id) {
     53             if let Some(new_offset) = thread.set_scroll_offset.take() {
     54                 scroll_area = scroll_area.vertical_scroll_offset(new_offset);
     55             }
     56         }
     57 
     58         let output = scroll_area.show(ui, |ui| self.notes(ui, &txn));
     59 
     60         let out_id = output.id;
     61         let mut resp = output.inner;
     62 
     63         if let Some(NoteAction::Note {
     64             note_id: _,
     65             preview: _,
     66             scroll_offset,
     67         }) = &mut resp
     68         {
     69             *scroll_offset = output.state.offset.y;
     70         }
     71 
     72         DragResponse::output(resp).scroll_raw(out_id)
     73     }
     74 
     75     fn notes(&mut self, ui: &mut egui::Ui, txn: &Transaction) -> Option<NoteAction> {
     76         let Ok(cur_note) = self
     77             .note_context
     78             .ndb
     79             .get_note_by_id(txn, self.selected_note_id)
     80         else {
     81             let id = *self.selected_note_id;
     82             tracing::error!("ndb: Did not find note {}", enostr::NoteId::new(id).hex());
     83             return None;
     84         };
     85 
     86         self.threads.update(
     87             &cur_note,
     88             self.note_context.note_cache,
     89             self.note_context.ndb,
     90             txn,
     91             self.note_context.unknown_ids,
     92             self.note_context.accounts,
     93             self.col,
     94         );
     95 
     96         let cur_node = self.threads.threads.get(&self.selected_note_id).unwrap();
     97 
     98         let full_chain = cur_node.have_all_ancestors;
     99         let mut note_builder = ThreadNoteBuilder::new(cur_note);
    100 
    101         let mut parent_state = cur_node.prev.clone();
    102         while let ParentState::Parent(id) = parent_state {
    103             if let Ok(note) = self.note_context.ndb.get_note_by_id(txn, id.bytes()) {
    104                 note_builder.add_chain(note);
    105                 if let Some(res) = self.threads.threads.get(&id.bytes()) {
    106                     parent_state = res.prev.clone();
    107                     continue;
    108                 }
    109             }
    110             parent_state = ParentState::Unknown;
    111         }
    112 
    113         for note_ref in cur_node.replies.values() {
    114             if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) {
    115                 note_builder.add_reply(note);
    116             }
    117         }
    118 
    119         let list = &mut self
    120             .threads
    121             .threads
    122             .get_mut(&self.selected_note_id)
    123             .unwrap()
    124             .list;
    125 
    126         let notes = note_builder.into_notes(
    127             self.note_options.contains(NoteOptions::RepliesNewestFirst),
    128             &mut self.threads.seen_flags,
    129         );
    130 
    131         if !full_chain {
    132             // TODO(kernelkind): insert UI denoting we don't have the full chain yet
    133             ui.colored_label(ui.visuals().error_fg_color, "LOADING NOTES");
    134         }
    135 
    136         show_notes(ui, list, &notes, self.note_context, self.note_options, txn)
    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     txn: &Transaction,
    148 ) -> Option<NoteAction> {
    149     let mut action = None;
    150 
    151     ui.spacing_mut().item_spacing.y = 0.0;
    152     ui.spacing_mut().item_spacing.x = 4.0;
    153 
    154     let selected_note_index = thread_notes.selected_index;
    155     let notes = &thread_notes.notes;
    156 
    157     let is_muted = note_context.accounts.mutefun();
    158 
    159     list.ui_custom_layout(ui, notes.len(), |ui, cur_index| {
    160         let note = &notes[cur_index];
    161 
    162         // should we mute the thread? we might not have it!
    163         let muted = root_note_id_from_selected_id(
    164             note_context.ndb,
    165             note_context.note_cache,
    166             txn,
    167             note.note.id(),
    168         )
    169         .ok()
    170         .is_some_and(|root_id| is_muted(&note.note, root_id.bytes()));
    171 
    172         if muted {
    173             return 1;
    174         }
    175 
    176         let resp = note.show(note_context, flags, ui);
    177 
    178         action = if cur_index == selected_note_index {
    179             resp.action.and_then(strip_note_action)
    180         } else {
    181             resp.action
    182         }
    183         .or(action.take());
    184 
    185         1
    186     });
    187 
    188     action
    189 }
    190 
    191 fn strip_note_action(action: NoteAction) -> Option<NoteAction> {
    192     if matches!(
    193         action,
    194         NoteAction::Note {
    195             note_id: _,
    196             preview: false,
    197             scroll_offset: _,
    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 impl ThreadNoteType {
    283     fn is_selected(&self) -> bool {
    284         matches!(self, ThreadNoteType::Selected { .. })
    285     }
    286 }
    287 
    288 struct ThreadNotes<'a> {
    289     notes: Vec<ThreadNote<'a>>,
    290     selected_index: usize,
    291 }
    292 
    293 struct ThreadNote<'a> {
    294     pub note: Note<'a>,
    295     note_type: ThreadNoteType,
    296     pub unread_and_have_replies: bool,
    297 }
    298 
    299 impl<'a> ThreadNote<'a> {
    300     fn options(&self, mut cur_options: NoteOptions) -> NoteOptions {
    301         match self.note_type {
    302             ThreadNoteType::Chain { root: _ } => cur_options,
    303             ThreadNoteType::Selected { root: _ } => {
    304                 cur_options.set(NoteOptions::Wide, true);
    305                 cur_options.set(NoteOptions::SelectableText, true);
    306                 cur_options.set(NoteOptions::FullCreatedDate, true);
    307                 cur_options
    308             }
    309             ThreadNoteType::Reply => cur_options,
    310         }
    311     }
    312 
    313     fn show(
    314         &self,
    315         note_context: &'a mut NoteContext<'_>,
    316         flags: NoteOptions,
    317         ui: &mut egui::Ui,
    318     ) -> NoteResponse {
    319         let inner = notedeck_ui::padding(8.0, ui, |ui| {
    320             NoteView::new(note_context, &self.note, self.options(flags))
    321                 .selected_style(self.note_type.is_selected())
    322                 .unread_indicator(self.unread_and_have_replies)
    323                 .show(ui)
    324         });
    325 
    326         match self.note_type {
    327             ThreadNoteType::Chain { root } => add_chain_adornment(ui, &inner, root),
    328             ThreadNoteType::Selected { root } => add_selected_adornment(ui, &inner, root),
    329             ThreadNoteType::Reply => notedeck_ui::hline(ui),
    330         }
    331 
    332         inner.inner
    333     }
    334 }
    335 
    336 fn add_chain_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) {
    337     let Some(pfp_rect) = note_resp.inner.pfp_rect else {
    338         return;
    339     };
    340 
    341     let note_rect = note_resp.response.rect;
    342 
    343     let painter = ui.painter_at(note_rect);
    344 
    345     if !root {
    346         paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect);
    347     }
    348 
    349     // painting line below pfp:
    350     let top_pt = {
    351         let mut top = pfp_rect.center();
    352         top.y = pfp_rect.bottom();
    353         top
    354     };
    355 
    356     let bottom_pt = {
    357         let mut bottom = top_pt;
    358         bottom.y = note_rect.bottom();
    359         bottom
    360     };
    361 
    362     painter.line_segment([top_pt, bottom_pt], LINE_STROKE(ui));
    363 
    364     let hline_min_x = top_pt.x + 6.0;
    365     notedeck_ui::hline_with_width(
    366         ui,
    367         egui::Rangef::new(hline_min_x, ui.available_rect_before_wrap().right()),
    368     );
    369 }
    370 
    371 fn add_selected_adornment(ui: &mut egui::Ui, note_resp: &InnerResponse<NoteResponse>, root: bool) {
    372     let Some(pfp_rect) = note_resp.inner.pfp_rect else {
    373         return;
    374     };
    375     let note_rect = note_resp.response.rect;
    376     let painter = ui.painter_at(note_rect);
    377 
    378     if !root {
    379         paint_line_above_pfp(ui, &painter, &pfp_rect, &note_rect);
    380     }
    381     notedeck_ui::hline(ui);
    382 }
    383 
    384 fn paint_line_above_pfp(
    385     ui: &egui::Ui,
    386     painter: &egui::Painter,
    387     pfp_rect: &egui::Rect,
    388     note_rect: &egui::Rect,
    389 ) {
    390     let bottom_pt = {
    391         let mut center = pfp_rect.center();
    392         center.y = pfp_rect.top();
    393         center
    394     };
    395 
    396     let top_pt = {
    397         let mut top = bottom_pt;
    398         top.y = note_rect.top();
    399         top
    400     };
    401 
    402     painter.line_segment([bottom_pt, top_pt], LINE_STROKE(ui));
    403 }
    404 
    405 const LINE_STROKE: fn(&egui::Ui) -> egui::Stroke = |ui: &egui::Ui| {
    406     let mut stroke = ui.style().visuals.widgets.noninteractive.bg_stroke;
    407     stroke.width = 2.0;
    408     stroke
    409 };