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