notedeck

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

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(&note) 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 }