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 }