notedeck

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

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