notedeck

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

convo_list.rs (9200B)


      1 use chrono::Local;
      2 use egui::{
      3     Align, Color32, CornerRadius, Frame, Label, Layout, Margin, RichText, ScrollArea, Sense,
      4 };
      5 use egui_extras::{Size, Strip, StripBuilder};
      6 use enostr::Pubkey;
      7 use nostrdb::{Ndb, Note, ProfileRecord, Transaction};
      8 use notedeck::{
      9     fonts::get_font_size, tr, ui::is_narrow, Images, Localization, MediaJobSender,
     10     NotedeckTextStyle,
     11 };
     12 use notedeck_ui::ProfilePic;
     13 
     14 use crate::{
     15     cache::{
     16         Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates,
     17     },
     18     nav::MessagesAction,
     19     ui::{
     20         conversation_title, convo::format_time_short, direct_chat_partner, local_datetime,
     21         ConversationSummary,
     22     },
     23 };
     24 
     25 pub struct ConversationListUi<'a> {
     26     cache: &'a ConversationCache,
     27     states: &'a mut ConversationStates,
     28     jobs: &'a MediaJobSender,
     29     ndb: &'a Ndb,
     30     img_cache: &'a mut Images,
     31     i18n: &'a mut Localization,
     32 }
     33 
     34 impl<'a> ConversationListUi<'a> {
     35     pub fn new(
     36         cache: &'a ConversationCache,
     37         states: &'a mut ConversationStates,
     38         jobs: &'a MediaJobSender,
     39         ndb: &'a Ndb,
     40         img_cache: &'a mut Images,
     41         i18n: &'a mut Localization,
     42     ) -> Self {
     43         Self {
     44             cache,
     45             states,
     46             ndb,
     47             jobs,
     48             img_cache,
     49             i18n,
     50         }
     51     }
     52 
     53     pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> {
     54         let mut action = None;
     55         if self.cache.is_empty() {
     56             ui.centered_and_justified(|ui| {
     57                 ui.label(tr!(
     58                     self.i18n,
     59                     "No conversations yet",
     60                     "Empty state text when the user has no conversations"
     61                 ));
     62             });
     63             return None;
     64         }
     65 
     66         ScrollArea::vertical()
     67             .auto_shrink([false, false])
     68             .show(ui, |ui| {
     69                 let num_convos = self.cache.len();
     70 
     71                 self.states
     72                     .convos_list
     73                     .ui_custom_layout(ui, num_convos, |ui, index| {
     74                         let Some(id) = self.cache.get_id_by_index(index).copied() else {
     75                             return 1;
     76                         };
     77 
     78                         let Some(convo) = self.cache.get(id) else {
     79                             return 1;
     80                         };
     81 
     82                         let state = self.states.cache.get(&id);
     83 
     84                         if let Some(a) = render_list_item(
     85                             ui,
     86                             self.ndb,
     87                             self.cache.active,
     88                             id,
     89                             convo,
     90                             state,
     91                             self.jobs,
     92                             self.img_cache,
     93                             selected_pubkey,
     94                             self.i18n,
     95                         ) {
     96                             action = Some(a);
     97                         }
     98 
     99                         1
    100                     });
    101             });
    102         action
    103     }
    104 }
    105 
    106 #[allow(clippy::too_many_arguments)]
    107 fn render_list_item(
    108     ui: &mut egui::Ui,
    109     ndb: &Ndb,
    110     active: Option<ConversationId>,
    111     id: ConversationId,
    112     convo: &Conversation,
    113     state: Option<&ConversationState>,
    114     jobs: &MediaJobSender,
    115     img_cache: &mut Images,
    116     selected_pubkey: &Pubkey,
    117     i18n: &mut Localization,
    118 ) -> Option<MessagesAction> {
    119     let txn = Transaction::new(ndb).expect("txn");
    120     let summary = ConversationSummary::new(convo, state.and_then(|s| s.last_read));
    121 
    122     let title = conversation_title(summary.metadata, &txn, ndb, selected_pubkey, i18n);
    123 
    124     let partner = direct_chat_partner(summary.metadata.participants.as_slice(), selected_pubkey);
    125     let partner_profile = partner.and_then(|pk| ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok());
    126 
    127     let last_msg = summary
    128         .last_message
    129         .and_then(|r| ndb.get_note_by_key(&txn, r.key).ok());
    130 
    131     let response = render_summary(
    132         ui,
    133         summary,
    134         active == Some(id),
    135         title.as_ref(),
    136         partner.is_some(),
    137         last_msg.as_ref(),
    138         partner_profile.as_ref(),
    139         jobs,
    140         img_cache,
    141         i18n,
    142     );
    143 
    144     response.clicked().then_some(MessagesAction::Open(id))
    145 }
    146 
    147 #[allow(clippy::too_many_arguments)]
    148 pub fn render_summary(
    149     ui: &mut egui::Ui,
    150     summary: ConversationSummary,
    151     selected: bool,
    152     title: &str,
    153     show_partner_avatar: bool,
    154     last_message: Option<&Note>,
    155     partner_profile: Option<&ProfileRecord<'_>>,
    156     jobs: &MediaJobSender,
    157     img_cache: &mut Images,
    158     i18n: &mut Localization,
    159 ) -> egui::Response {
    160     let visuals = ui.visuals();
    161     let fill = if is_narrow(ui.ctx()) {
    162         Color32::TRANSPARENT
    163     } else if selected {
    164         visuals.extreme_bg_color
    165     } else if summary.unread {
    166         visuals.faint_bg_color
    167     } else {
    168         Color32::TRANSPARENT
    169     };
    170 
    171     Frame::new()
    172         .fill(fill)
    173         .corner_radius(CornerRadius::same(12))
    174         .inner_margin(Margin::symmetric(12, 8))
    175         .show(ui, |ui| {
    176             render_summary_inner(
    177                 ui,
    178                 title,
    179                 show_partner_avatar,
    180                 last_message,
    181                 partner_profile,
    182                 jobs,
    183                 img_cache,
    184                 i18n,
    185             );
    186         })
    187         .response
    188         .interact(Sense::click())
    189         .on_hover_cursor(egui::CursorIcon::PointingHand)
    190 }
    191 
    192 #[allow(clippy::too_many_arguments)]
    193 fn render_summary_inner(
    194     ui: &mut egui::Ui,
    195     title: &str,
    196     show_partner_avatar: bool,
    197     last_message: Option<&Note>,
    198     partner_profile: Option<&ProfileRecord<'_>>,
    199     jobs: &MediaJobSender,
    200     img_cache: &mut Images,
    201     i18n: &mut Localization,
    202 ) {
    203     let summary_height = 40.0;
    204     StripBuilder::new(ui)
    205         .size(Size::exact(summary_height))
    206         .vertical(|mut strip| {
    207             strip.strip(|builder| {
    208                 builder
    209                     .size(Size::exact(summary_height + 8.0))
    210                     .size(Size::remainder())
    211                     .horizontal(|strip| {
    212                         render_summary_horizontal(
    213                             title,
    214                             show_partner_avatar,
    215                             last_message,
    216                             partner_profile,
    217                             jobs,
    218                             img_cache,
    219                             summary_height,
    220                             i18n,
    221                             strip,
    222                         );
    223                     });
    224             });
    225         });
    226 }
    227 
    228 #[allow(clippy::too_many_arguments)]
    229 fn render_summary_horizontal(
    230     title: &str,
    231     show_partner_avatar: bool,
    232     last_message: Option<&Note>,
    233     partner_profile: Option<&ProfileRecord<'_>>,
    234     jobs: &MediaJobSender,
    235     img_cache: &mut Images,
    236     summary_height: f32,
    237     i18n: &mut Localization,
    238     mut strip: Strip,
    239 ) {
    240     if show_partner_avatar {
    241         strip.cell(|ui| {
    242             ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
    243                 let size = ProfilePic::default_size() as f32;
    244                 let mut pic = ProfilePic::from_profile_or_default(img_cache, jobs, partner_profile)
    245                     .size(size);
    246                 ui.add(&mut pic);
    247             });
    248         });
    249     } else {
    250         strip.empty();
    251     }
    252 
    253     let title_height = 8.0;
    254     strip.cell(|ui| {
    255         StripBuilder::new(ui)
    256             .size(Size::exact(title_height))
    257             .size(Size::exact(summary_height - title_height))
    258             .vertical(|strip| {
    259                 render_summary_body(title, last_message, i18n, strip);
    260             });
    261     });
    262 }
    263 
    264 fn render_summary_body(
    265     title: &str,
    266     last_message: Option<&Note>,
    267     i18n: &mut Localization,
    268     mut strip: Strip,
    269 ) {
    270     strip.cell(|ui| {
    271         ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
    272             if let Some(last_msg) = last_message {
    273                 let today = Local::now().date_naive();
    274                 let last_msg_ts = i64::try_from(last_msg.created_at()).unwrap_or(i64::MAX);
    275                 let time_str = format_time_short(today, &local_datetime(last_msg_ts), i18n);
    276 
    277                 ui.add_enabled(
    278                     false,
    279                     Label::new(
    280                         RichText::new(time_str)
    281                             .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Heading4)),
    282                     ),
    283                 );
    284             }
    285 
    286             ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
    287                 ui.add(
    288                     egui::Label::new(RichText::new(title).strong())
    289                         .truncate()
    290                         .selectable(false),
    291                 );
    292             });
    293         });
    294     });
    295 
    296     let Some(last_msg) = last_message else {
    297         strip.empty();
    298         return;
    299     };
    300 
    301     strip.cell(|ui| {
    302         ui.add_enabled(
    303             false, // disables hover & makes text grayed out
    304             Label::new(
    305                 RichText::new(last_msg.content())
    306                     .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)),
    307             )
    308             .truncate(),
    309         );
    310     });
    311 }