notedeck

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

thread.rs (11410B)


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