notedeck

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

convo.rs (20143B)


      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     let r = 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     r.response.context_menu(|ui| {
    453         if ui.button("Copy").clicked() {
    454             ui.ctx().copy_text(message.to_owned());
    455             ui.close_menu();
    456         }
    457     });
    458     r.response
    459 }
    460 
    461 fn other_chat_bubble(
    462     ui: &mut egui::Ui,
    463     chat_msg: Nip17ChatMessage,
    464     sender_name: NostrName,
    465     msg_type: MessageType,
    466 ) -> egui::Response {
    467     let message = chat_msg.message;
    468     let message_owned = message.to_owned();
    469     let bubble_fill = ui.visuals().extreme_bg_color;
    470     let text_color = ui.visuals().text_color();
    471     let secondary_color = ui.visuals().weak_text_color();
    472 
    473     let r = ui.scope(|ui| {
    474         chat_bubble(ui, msg_type, false, bubble_fill, |ui| {
    475             ui.vertical(|ui| {
    476                 if msg_type == MessageType::FirstInSeries || msg_type == MessageType::Standalone {
    477                     ui.label(
    478                         RichText::new(sender_name.name())
    479                             .strong()
    480                             .color(secondary_color),
    481                     );
    482                     ui.add_space(2.0);
    483                 }
    484 
    485                 ui.with_layout(
    486                     Layout::left_to_right(Align::Max).with_main_wrap(true),
    487                     |ui| {
    488                         ui.add(
    489                             egui::Label::new(RichText::new(message).color(text_color))
    490                                 .selectable(true),
    491                         );
    492                         if msg_type == MessageType::Standalone
    493                             || msg_type == MessageType::LastInSeries
    494                         {
    495                             ui.add_space(6.0);
    496                             let timestamp_label = format_timestamp_label(
    497                                 &local_datetime_from_nostr(chat_msg.created_at),
    498                             );
    499                             ui.add(
    500                                 egui::Label::new(
    501                                     RichText::new(timestamp_label)
    502                                         .small()
    503                                         .color(secondary_color),
    504                                 )
    505                                 .wrap_mode(egui::TextWrapMode::Extend),
    506                             );
    507                         }
    508                     },
    509                 );
    510             })
    511             .response
    512         })
    513     });
    514     r.response.context_menu(|ui| {
    515         if ui.button("Copy").clicked() {
    516             ui.ctx().copy_text(message_owned.clone());
    517             ui.close_menu();
    518         }
    519     });
    520     r.response
    521 }
    522 
    523 /// An unfortunate hack to change the corner radius of a TextEdit...
    524 /// returns old `CornerRadius`
    525 fn mut_visuals_corner_radius(ui: &mut egui::Ui, rad: CornerRadius) -> WidgetsCornerRadius {
    526     let widgets = &ui.visuals().widgets;
    527     let old = WidgetsCornerRadius {
    528         active: widgets.active.corner_radius,
    529         hovered: widgets.hovered.corner_radius,
    530         inactive: widgets.inactive.corner_radius,
    531         noninteractive: widgets.noninteractive.corner_radius,
    532         open: widgets.open.corner_radius,
    533     };
    534 
    535     let widgets = &mut ui.visuals_mut().widgets;
    536     widgets.active.corner_radius = rad;
    537     widgets.hovered.corner_radius = rad;
    538     widgets.inactive.corner_radius = rad;
    539     widgets.noninteractive.corner_radius = rad;
    540     widgets.open.corner_radius = rad;
    541 
    542     old
    543 }
    544 
    545 fn restore_widgets_corner_rad(ui: &mut egui::Ui, old: WidgetsCornerRadius) {
    546     let widgets = &mut ui.visuals_mut().widgets;
    547 
    548     widgets.active.corner_radius = old.active;
    549     widgets.hovered.corner_radius = old.hovered;
    550     widgets.inactive.corner_radius = old.inactive;
    551     widgets.noninteractive.corner_radius = old.noninteractive;
    552     widgets.open.corner_radius = old.open;
    553 }
    554 
    555 struct WidgetsCornerRadius {
    556     active: CornerRadius,
    557     hovered: CornerRadius,
    558     inactive: CornerRadius,
    559     noninteractive: CornerRadius,
    560     open: CornerRadius,
    561 }
    562 
    563 fn format_day_heading(date: NaiveDate, today: &NaiveDate, i18n: &mut Localization) -> String {
    564     if date == *today {
    565         tr!(
    566             i18n,
    567             "Today",
    568             "Label shown between chat messages for the current day"
    569         )
    570     } else if date == *today - Duration::days(1) {
    571         tr!(
    572             i18n,
    573             "Yesterday",
    574             "Label shown between chat messages for the previous day"
    575         )
    576     } else {
    577         date.format("%A, %B %-d, %Y").to_string()
    578     }
    579 }
    580 
    581 pub fn format_time_short(
    582     today: NaiveDate,
    583     time: &DateTime<Local>,
    584     i18n: &mut Localization,
    585 ) -> String {
    586     let d = time.date_naive();
    587 
    588     if d == today {
    589         return format_timestamp_label(time);
    590     } else if d == today - Duration::days(1) {
    591         return tr!(
    592             i18n,
    593             "Yest",
    594             "Abbreviated version of yesterday used in conversation summaries"
    595         );
    596     }
    597 
    598     let days_ago = today.signed_duration_since(d).num_days();
    599 
    600     if days_ago < 7 {
    601         return d.format("%a").to_string();
    602     }
    603 
    604     d.format("%b %-d").to_string()
    605 }
    606 
    607 fn format_timestamp_label(dt: &DateTime<Local>) -> String {
    608     dt.format("%-I:%M %p").to_string()
    609 }
    610 
    611 #[allow(clippy::too_many_arguments)]
    612 pub fn conversation_ui(
    613     cache: &ConversationCache,
    614     states: &mut ConversationStates,
    615     jobs: &MediaJobSender,
    616     ndb: &Ndb,
    617     ui: &mut egui::Ui,
    618     img_cache: &mut Images,
    619     i18n: &mut Localization,
    620     selected_pubkey: &Pubkey,
    621 ) -> Option<MessagesAction> {
    622     let Some(id) = cache.active else {
    623         title_label(
    624             ui,
    625             &tr!(
    626                 i18n,
    627                 "No conversations yet",
    628                 "label describing that there are no conversations yet",
    629             ),
    630         );
    631         return None;
    632     };
    633 
    634     let Some(conversation) = cache.get(id) else {
    635         tracing::error!("could not find active convo id {id}");
    636         return None;
    637     };
    638 
    639     let state = states.get_or_insert(id);
    640 
    641     ConversationUi::new(conversation, state, ndb, jobs, img_cache, i18n).ui(ui, selected_pubkey)
    642 }