convo.rs (18968B)
1 use chrono::{DateTime, Duration, Local, NaiveDate}; 2 use egui::{ 3 vec2, Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, ScrollArea, TextEdit, 4 }; 5 use egui_extras::{Size, StripBuilder}; 6 use enostr::Pubkey; 7 use nostrdb::{Ndb, NoteKey, Transaction}; 8 use notedeck::{ 9 name::get_display_name, tr, ui::is_narrow, Images, Localization, MediaJobSender, NostrName, 10 }; 11 use notedeck_ui::{include_input, ProfilePic}; 12 13 use crate::{ 14 cache::{ 15 Conversation, ConversationCache, ConversationId, ConversationState, ConversationStates, 16 }, 17 convo_renderable::{ConversationItem, MessageType}, 18 nav::MessagesAction, 19 nip17::{parse_chat_message, Nip17ChatMessage}, 20 ui::{local_datetime_from_nostr, title_label}, 21 }; 22 23 pub struct ConversationUi<'a> { 24 conversation: &'a Conversation, 25 state: &'a mut ConversationState, 26 ndb: &'a Ndb, 27 jobs: &'a MediaJobSender, 28 img_cache: &'a mut Images, 29 i18n: &'a mut Localization, 30 } 31 32 impl<'a> ConversationUi<'a> { 33 pub fn new( 34 conversation: &'a Conversation, 35 state: &'a mut ConversationState, 36 ndb: &'a Ndb, 37 jobs: &'a MediaJobSender, 38 img_cache: &'a mut Images, 39 i18n: &'a mut Localization, 40 ) -> Self { 41 Self { 42 conversation, 43 state, 44 ndb, 45 jobs, 46 img_cache, 47 i18n, 48 } 49 } 50 51 pub fn ui(&mut self, ui: &mut egui::Ui, selected_pubkey: &Pubkey) -> Option<MessagesAction> { 52 let txn = Transaction::new(self.ndb).expect("txn"); 53 54 let mut action = None; 55 Frame::new().fill(ui.visuals().panel_fill).show(ui, |ui| { 56 ui.with_layout(Layout::bottom_up(Align::Min), |ui| { 57 let focusing_composer = ui 58 .allocate_ui(vec2(ui.available_width(), 64.0), |ui| { 59 let comp_resp = 60 conversation_composer(ui, self.state, self.conversation.id, self.i18n); 61 if action.is_none() { 62 action = comp_resp.action; 63 } 64 comp_resp.composer_has_focus 65 }) 66 .inner; 67 ui.with_layout(Layout::top_down(Align::Min), |ui| { 68 ScrollArea::vertical() 69 .stick_to_bottom(focusing_composer) 70 .id_salt(ui.id().with(self.conversation.id)) 71 .show(ui, |ui| { 72 conversation_history( 73 ui, 74 self.conversation, 75 self.state, 76 self.jobs, 77 self.ndb, 78 &txn, 79 self.img_cache, 80 selected_pubkey, 81 self.i18n, 82 ); 83 }); 84 }); 85 }) 86 }); 87 88 action 89 } 90 } 91 92 #[allow(clippy::too_many_arguments)] 93 fn conversation_history( 94 ui: &mut egui::Ui, 95 conversation: &Conversation, 96 state: &mut ConversationState, 97 jobs: &MediaJobSender, 98 ndb: &Ndb, 99 txn: &Transaction, 100 img_cache: &mut Images, 101 selected_pk: &Pubkey, 102 i18n: &mut Localization, 103 ) { 104 let renderable = &conversation.renderable; 105 106 state.last_read = conversation 107 .messages 108 .messages_ordered 109 .first() 110 .map(|n| &n.note_ref) 111 .copied(); 112 Frame::new() 113 .inner_margin(Margin::symmetric(16, 0)) 114 .show(ui, |ui| { 115 let today = Local::now().date_naive(); 116 let total = renderable.len(); 117 state.list.ui_custom_layout(ui, total, |ui, index| { 118 let Some(renderable) = renderable.get(index) else { 119 return 1; 120 }; 121 122 match renderable { 123 ConversationItem::Date(date) => render_date_line(ui, *date, &today, i18n), 124 ConversationItem::Message { msg_type, key } => { 125 render_chat_msg( 126 ui, 127 img_cache, 128 jobs, 129 ndb, 130 txn, 131 *key, 132 *msg_type, 133 selected_pk, 134 ); 135 } 136 }; 137 138 1 139 }); 140 }); 141 } 142 143 fn render_date_line( 144 ui: &mut egui::Ui, 145 date: NaiveDate, 146 today: &NaiveDate, 147 i18n: &mut Localization, 148 ) { 149 let label = format_day_heading(date, today, i18n); 150 ui.add_space(8.0); 151 ui.vertical_centered(|ui| { 152 ui.add( 153 egui::Label::new( 154 RichText::new(label) 155 .strong() 156 .color(ui.visuals().weak_text_color()), 157 ) 158 .wrap(), 159 ); 160 }); 161 ui.add_space(4.0); 162 } 163 164 #[allow(clippy::too_many_arguments)] 165 fn render_chat_msg( 166 ui: &mut egui::Ui, 167 img_cache: &mut Images, 168 jobs: &MediaJobSender, 169 ndb: &Ndb, 170 txn: &Transaction, 171 key: NoteKey, 172 msg_type: MessageType, 173 selected_pk: &Pubkey, 174 ) { 175 let Ok(note) = ndb.get_note_by_key(txn, key) else { 176 tracing::error!("Could not get key {:?}", key); 177 return; 178 }; 179 180 let Some(chat_msg) = parse_chat_message(¬e) else { 181 tracing::error!("Could not parse chat message for note {key:?}"); 182 return; 183 }; 184 185 match msg_type { 186 MessageType::Standalone => { 187 ui.add_space(2.0); 188 render_msg_with_pfp( 189 ui, 190 img_cache, 191 jobs, 192 ndb, 193 txn, 194 selected_pk, 195 msg_type, 196 chat_msg, 197 ); 198 ui.add_space(2.0); 199 } 200 MessageType::FirstInSeries => { 201 ui.add_space(2.0); 202 render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); 203 } 204 MessageType::MiddleInSeries => { 205 render_msg_no_pfp(ui, ndb, txn, selected_pk, msg_type, chat_msg); 206 } 207 MessageType::LastInSeries => { 208 render_msg_with_pfp( 209 ui, 210 img_cache, 211 jobs, 212 ndb, 213 txn, 214 selected_pk, 215 msg_type, 216 chat_msg, 217 ); 218 ui.add_space(2.0); 219 } 220 } 221 } 222 223 #[allow(clippy::too_many_arguments)] 224 fn render_msg_with_pfp( 225 ui: &mut egui::Ui, 226 img_cache: &mut Images, 227 jobs: &MediaJobSender, 228 ndb: &Ndb, 229 txn: &Transaction, 230 selected_pk: &Pubkey, 231 msg_type: MessageType, 232 chat_msg: Nip17ChatMessage, 233 ) { 234 if selected_pk.bytes() == chat_msg.sender { 235 self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); 236 return; 237 } 238 239 let avatar_size = ProfilePic::medium_size() as f32; 240 let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); 241 let mut pic = 242 ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref()).size(avatar_size); 243 ui.horizontal(|ui| { 244 ui.add(&mut pic); 245 ui.add_space(8.0); 246 247 other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); 248 }); 249 } 250 251 fn render_msg_no_pfp( 252 ui: &mut egui::Ui, 253 ndb: &Ndb, 254 txn: &Transaction, 255 selected_pk: &Pubkey, 256 msg_type: MessageType, 257 chat_msg: Nip17ChatMessage, 258 ) { 259 if selected_pk.bytes() == chat_msg.sender { 260 self_chat_bubble(ui, chat_msg.message, msg_type, chat_msg.created_at); 261 return; 262 } 263 264 ui.horizontal(|ui| { 265 ui.add_space(ProfilePic::medium_size() as f32 + ui.spacing().item_spacing.x + 8.0); 266 let profile = ndb.get_profile_by_pubkey(txn, chat_msg.sender).ok(); 267 other_chat_bubble(ui, chat_msg, get_display_name(profile.as_ref()), msg_type); 268 }); 269 } 270 271 fn conversation_composer( 272 ui: &mut egui::Ui, 273 state: &mut ConversationState, 274 conversation_id: ConversationId, 275 i18n: &mut Localization, 276 ) -> ComposerResponse { 277 { 278 let rect = ui.available_rect_before_wrap(); 279 let painter = ui.painter_at(rect); 280 painter.rect_filled(rect, CornerRadius::ZERO, ui.visuals().panel_fill); 281 } 282 let margin = Margin::symmetric(16, 4); 283 let mut action = None; 284 let mut composer_has_focus = false; 285 Frame::new().inner_margin(margin).show(ui, |ui| { 286 ui.with_layout(Layout::left_to_right(Align::Center), |ui| { 287 // TODO(kernelkind): ideally this will be multiline, but the default multiline impl doesn't work the way 288 // signal's multiline works... TBC 289 290 let old = mut_visuals_corner_radius(ui, CornerRadius::same(16)); 291 292 let hint_text = RichText::new(tr!( 293 i18n, 294 "Type a message", 295 "Placeholder text for the message composer in chats" 296 )) 297 .color(ui.visuals().noninteractive().fg_stroke.color); 298 let mut send = false; 299 let is_narrow = is_narrow(ui.ctx()); 300 let send_button_section = if is_narrow { 32.0 } else { 0.0 }; 301 302 StripBuilder::new(ui) 303 .size(Size::remainder()) 304 .size(Size::exact(send_button_section)) 305 .horizontal(|mut strip| { 306 strip.cell(|ui| { 307 let spacing = ui.spacing().item_spacing.x; 308 let text_height = ui.spacing().item_spacing.y * 1.4; 309 let text_width = (ui.available_width() - spacing).max(0.0); 310 let size = vec2(text_width, text_height); 311 312 let text_edit = TextEdit::singleline(&mut state.composer) 313 .margin(Margin::symmetric(16, 8)) 314 .vertical_align(Align::Center) 315 .desired_width(text_width) 316 .hint_text(hint_text) 317 .min_size(size); 318 let text_resp = ui.add(text_edit); 319 restore_widgets_corner_rad(ui, old); 320 send = text_resp.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)); 321 include_input(ui, &text_resp); 322 composer_has_focus = text_resp.has_focus(); 323 }); 324 325 if is_narrow { 326 strip.cell(|ui| { 327 ui.add_space(6.0); 328 if ui 329 .add_enabled( 330 !state.composer.is_empty(), 331 egui::Button::new("Send").frame(false), 332 ) 333 .clicked() 334 { 335 send = true; 336 } 337 }); 338 } else { 339 strip.empty(); 340 } 341 }); 342 if send { 343 action = prepare_send_action(conversation_id, state); 344 } 345 }); 346 }); 347 348 ComposerResponse { 349 action, 350 composer_has_focus, 351 } 352 } 353 354 struct ComposerResponse { 355 action: Option<MessagesAction>, 356 composer_has_focus: bool, 357 } 358 359 fn prepare_send_action( 360 conversation_id: ConversationId, 361 state: &mut ConversationState, 362 ) -> Option<MessagesAction> { 363 if state.composer.trim().is_empty() { 364 return None; 365 } 366 367 let message = std::mem::take(&mut state.composer); 368 Some(MessagesAction::SendMessage { 369 conversation_id, 370 content: message, 371 }) 372 } 373 374 fn chat_bubble<R>( 375 ui: &mut egui::Ui, 376 msg_type: MessageType, 377 is_self: bool, 378 bubble_fill: Color32, 379 contents: impl FnOnce(&mut egui::Ui) -> R, 380 ) -> R { 381 let d = 18; 382 let i = 4; 383 384 let (inner_top, inner_bottom) = match msg_type { 385 MessageType::Standalone => (d, d), 386 MessageType::FirstInSeries => (d, i), 387 MessageType::MiddleInSeries => (i, i), 388 MessageType::LastInSeries => (i, d), 389 }; 390 391 let corner_radius = if is_self { 392 CornerRadius { 393 nw: d, 394 ne: inner_top, 395 sw: d, 396 se: inner_bottom, 397 } 398 } else { 399 CornerRadius { 400 nw: inner_top, 401 ne: d, 402 sw: inner_bottom, 403 se: d, 404 } 405 }; 406 407 Frame::new() 408 .fill(bubble_fill) 409 .corner_radius(corner_radius) 410 .inner_margin(Margin::symmetric(14, 10)) 411 .show(ui, |ui| { 412 ui.set_max_width(ui.available_width() * 0.9); 413 contents(ui) 414 }) 415 .inner 416 } 417 418 fn self_chat_bubble( 419 ui: &mut egui::Ui, 420 message: &str, 421 msg_type: MessageType, 422 timestamp: u64, 423 ) -> egui::Response { 424 let bubble_fill = ui.visuals().selection.bg_fill; 425 ui.with_layout(Layout::right_to_left(Align::Min), |ui| { 426 chat_bubble(ui, msg_type, true, bubble_fill, |ui| { 427 ui.with_layout(Layout::top_down(Align::Max), |ui| { 428 ui.label(RichText::new(message).color(ui.visuals().text_color())); 429 430 if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries { 431 let timestamp_label = 432 format_timestamp_label(&local_datetime_from_nostr(timestamp)); 433 ui.label( 434 RichText::new(timestamp_label) 435 .small() 436 .color(ui.visuals().window_fill), 437 ); 438 } 439 }) 440 }) 441 .inner 442 }) 443 .response 444 } 445 446 fn other_chat_bubble( 447 ui: &mut egui::Ui, 448 chat_msg: Nip17ChatMessage, 449 sender_name: NostrName, 450 msg_type: MessageType, 451 ) -> egui::Response { 452 let message = chat_msg.message; 453 let bubble_fill = ui.visuals().extreme_bg_color; 454 let text_color = ui.visuals().text_color(); 455 let secondary_color = ui.visuals().weak_text_color(); 456 457 chat_bubble(ui, msg_type, false, bubble_fill, |ui| { 458 ui.vertical(|ui| { 459 if msg_type == MessageType::FirstInSeries || msg_type == MessageType::Standalone { 460 ui.label( 461 RichText::new(sender_name.name()) 462 .strong() 463 .color(secondary_color), 464 ); 465 ui.add_space(2.0); 466 } 467 468 ui.with_layout( 469 Layout::left_to_right(Align::Max).with_main_wrap(true), 470 |ui| { 471 ui.label(RichText::new(message).color(text_color)); 472 if msg_type == MessageType::Standalone || msg_type == MessageType::LastInSeries 473 { 474 ui.add_space(6.0); 475 let timestamp_label = 476 format_timestamp_label(&local_datetime_from_nostr(chat_msg.created_at)); 477 ui.add( 478 egui::Label::new( 479 RichText::new(timestamp_label) 480 .small() 481 .color(secondary_color), 482 ) 483 .wrap_mode(egui::TextWrapMode::Extend), 484 ); 485 } 486 }, 487 ); 488 }) 489 .response 490 }) 491 } 492 493 /// An unfortunate hack to change the corner radius of a TextEdit... 494 /// returns old `CornerRadius` 495 fn mut_visuals_corner_radius(ui: &mut egui::Ui, rad: CornerRadius) -> WidgetsCornerRadius { 496 let widgets = &ui.visuals().widgets; 497 let old = WidgetsCornerRadius { 498 active: widgets.active.corner_radius, 499 hovered: widgets.hovered.corner_radius, 500 inactive: widgets.inactive.corner_radius, 501 noninteractive: widgets.noninteractive.corner_radius, 502 open: widgets.open.corner_radius, 503 }; 504 505 let widgets = &mut ui.visuals_mut().widgets; 506 widgets.active.corner_radius = rad; 507 widgets.hovered.corner_radius = rad; 508 widgets.inactive.corner_radius = rad; 509 widgets.noninteractive.corner_radius = rad; 510 widgets.open.corner_radius = rad; 511 512 old 513 } 514 515 fn restore_widgets_corner_rad(ui: &mut egui::Ui, old: WidgetsCornerRadius) { 516 let widgets = &mut ui.visuals_mut().widgets; 517 518 widgets.active.corner_radius = old.active; 519 widgets.hovered.corner_radius = old.hovered; 520 widgets.inactive.corner_radius = old.inactive; 521 widgets.noninteractive.corner_radius = old.noninteractive; 522 widgets.open.corner_radius = old.open; 523 } 524 525 struct WidgetsCornerRadius { 526 active: CornerRadius, 527 hovered: CornerRadius, 528 inactive: CornerRadius, 529 noninteractive: CornerRadius, 530 open: CornerRadius, 531 } 532 533 fn format_day_heading(date: NaiveDate, today: &NaiveDate, i18n: &mut Localization) -> String { 534 if date == *today { 535 tr!( 536 i18n, 537 "Today", 538 "Label shown between chat messages for the current day" 539 ) 540 } else if date == *today - Duration::days(1) { 541 tr!( 542 i18n, 543 "Yesterday", 544 "Label shown between chat messages for the previous day" 545 ) 546 } else { 547 date.format("%A, %B %-d, %Y").to_string() 548 } 549 } 550 551 pub fn format_time_short( 552 today: NaiveDate, 553 time: &DateTime<Local>, 554 i18n: &mut Localization, 555 ) -> String { 556 let d = time.date_naive(); 557 558 if d == today { 559 return format_timestamp_label(time); 560 } else if d == today - Duration::days(1) { 561 return tr!( 562 i18n, 563 "Yest", 564 "Abbreviated version of yesterday used in conversation summaries" 565 ); 566 } 567 568 let days_ago = today.signed_duration_since(d).num_days(); 569 570 if days_ago < 7 { 571 return d.format("%a").to_string(); 572 } 573 574 d.format("%b %-d").to_string() 575 } 576 577 fn format_timestamp_label(dt: &DateTime<Local>) -> String { 578 dt.format("%-I:%M %p").to_string() 579 } 580 581 #[allow(clippy::too_many_arguments)] 582 pub fn conversation_ui( 583 cache: &ConversationCache, 584 states: &mut ConversationStates, 585 jobs: &MediaJobSender, 586 ndb: &Ndb, 587 ui: &mut egui::Ui, 588 img_cache: &mut Images, 589 i18n: &mut Localization, 590 selected_pubkey: &Pubkey, 591 ) -> Option<MessagesAction> { 592 let Some(id) = cache.active else { 593 title_label( 594 ui, 595 &tr!( 596 i18n, 597 "No conversations yet", 598 "label describing that there are no conversations yet", 599 ), 600 ); 601 return None; 602 }; 603 604 let Some(conversation) = cache.get(id) else { 605 tracing::error!("could not find active convo id {id}"); 606 return None; 607 }; 608 609 let state = states.get_or_insert(id); 610 611 ConversationUi::new(conversation, state, ndb, jobs, img_cache, i18n).ui(ui, selected_pubkey) 612 }