notedeck

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

mod.rs (6986B)


      1 use std::borrow::Cow;
      2 
      3 use chrono::{DateTime, Local, Utc};
      4 use egui::{Layout, RichText};
      5 use enostr::Pubkey;
      6 use nostrdb::{Ndb, ProfileRecord, Transaction};
      7 use notedeck::{
      8     name::get_display_name, tr, tr_plural, Images, Localization, MediaJobSender, NoteRef,
      9     NotedeckTextStyle,
     10 };
     11 use notedeck_ui::ProfilePic;
     12 
     13 use crate::cache::{Conversation, ConversationCache, ConversationMetadata};
     14 
     15 pub mod convo;
     16 pub mod convo_list;
     17 pub mod create_convo;
     18 pub mod messages;
     19 pub mod nav;
     20 
     21 #[derive(Clone, Debug)]
     22 pub struct ConversationSummary<'a> {
     23     pub metadata: &'a ConversationMetadata,
     24     pub last_message: Option<&'a NoteRef>,
     25     pub unread: bool,
     26     pub total_messages: usize,
     27 }
     28 
     29 impl<'a> ConversationSummary<'a> {
     30     pub fn new(convo: &'a Conversation, last_read: Option<NoteRef>) -> Self {
     31         Self {
     32             metadata: &convo.metadata,
     33             last_message: convo.messages.latest(),
     34             unread: last_read.is_some_and(|r| {
     35                 let Some(latest) = convo.messages.latest() else {
     36                     return false;
     37                 };
     38 
     39                 r < *latest
     40             }),
     41             total_messages: convo.messages.len(),
     42         }
     43     }
     44 }
     45 
     46 fn fallback_convo_title(
     47     participants: &[Pubkey],
     48     txn: &Transaction,
     49     ndb: &Ndb,
     50     current: &Pubkey,
     51     i18n: &mut Localization,
     52 ) -> String {
     53     let fallback = tr!(
     54         i18n,
     55         "Conversation",
     56         "Fallback title when no direct chat partner is available"
     57     );
     58     if participants.is_empty() {
     59         return fallback;
     60     }
     61 
     62     let others: Vec<&Pubkey> = participants.iter().filter(|pk| *pk != current).collect();
     63 
     64     if let Some(partner) = direct_chat_partner(participants, current) {
     65         return participant_label(ndb, txn, partner);
     66     }
     67 
     68     if others.is_empty() {
     69         return tr!(
     70             i18n,
     71             "Note to Self",
     72             "Conversation title used when a chat only has the current user"
     73         );
     74     }
     75 
     76     let names: Vec<String> = others
     77         .iter()
     78         .map(|pk| participant_label(ndb, txn, pk))
     79         .collect();
     80 
     81     if names.is_empty() {
     82         return fallback;
     83     }
     84 
     85     names.join(", ")
     86 }
     87 
     88 pub fn conversation_title<'a>(
     89     metadata: &'a ConversationMetadata,
     90     txn: &Transaction,
     91     ndb: &Ndb,
     92     current: &Pubkey,
     93     i18n: &mut Localization,
     94 ) -> Cow<'a, str> {
     95     if let Some(title) = metadata.title.as_ref() {
     96         Cow::Borrowed(title.title.as_str())
     97     } else {
     98         Cow::Owned(fallback_convo_title(
     99             &metadata.participants,
    100             txn,
    101             ndb,
    102             current,
    103             i18n,
    104         ))
    105     }
    106 }
    107 
    108 pub fn conversation_meta_line(
    109     summary: &ConversationSummary<'_>,
    110     i18n: &mut Localization,
    111 ) -> String {
    112     let mut parts = Vec::new();
    113     if summary.total_messages > 0 {
    114         parts.push(tr_plural!(
    115             i18n,
    116             "{count} message",
    117             "{count} messages",
    118             "Count of messages shown in a chat summary line",
    119             summary.total_messages,
    120         ));
    121     } else {
    122         parts.push(tr!(
    123             i18n,
    124             "No messages yet",
    125             "Chat summary text when the conversation has no messages"
    126         ));
    127     }
    128 
    129     parts.join(" • ")
    130 }
    131 
    132 pub fn direct_chat_partner<'a>(participants: &'a [Pubkey], current: &Pubkey) -> Option<&'a Pubkey> {
    133     if participants.len() != 2 {
    134         return None;
    135     }
    136 
    137     participants.iter().find(|pk| *pk != current)
    138 }
    139 
    140 pub fn participant_label(ndb: &Ndb, txn: &Transaction, pk: &Pubkey) -> String {
    141     let record = ndb.get_profile_by_pubkey(txn, pk.bytes()).ok();
    142     let name = get_display_name(record.as_ref());
    143 
    144     if name.display_name.is_some() || name.username.is_some() {
    145         name.name().to_owned()
    146     } else {
    147         short_pubkey(pk)
    148     }
    149 }
    150 
    151 fn short_pubkey(pk: &Pubkey) -> String {
    152     let hex = pk.hex();
    153     const START: usize = 8;
    154     const END: usize = 4;
    155     if hex.len() <= START + END {
    156         hex.to_owned()
    157     } else {
    158         format!("{}…{}", &hex[..START], &hex[hex.len() - END..])
    159     }
    160 }
    161 
    162 pub fn local_datetime(day: i64) -> DateTime<Local> {
    163     DateTime::<Utc>::from_timestamp(day, 0)
    164         .unwrap_or_else(|| DateTime::<Utc>::from_timestamp(0, 0).unwrap())
    165         .with_timezone(&Local)
    166 }
    167 
    168 pub fn local_datetime_from_nostr(timestamp: u64) -> DateTime<Local> {
    169     local_datetime(timestamp as i64)
    170 }
    171 
    172 pub fn login_nsec_prompt(ui: &mut egui::Ui, i18n: &mut Localization) {
    173     ui.centered_and_justified(|ui| {
    174         ui.vertical(|ui| {
    175             ui.heading(tr!(
    176                 i18n,
    177                 "Add your private key",
    178                 "Heading shown when prompting the user to add a private key to use messages"
    179             ));
    180             ui.label(tr!(
    181                 i18n,
    182                 "Messages are end-to-end encrypted. Add your nsec in Accounts to read and send chats.",
    183                 "Description shown under the private key prompt in the Messages view"
    184             ));
    185         });
    186     });
    187 }
    188 
    189 pub fn conversation_header_impl(
    190     ui: &mut egui::Ui,
    191     i18n: &mut Localization,
    192     cache: &ConversationCache,
    193     selected_pubkey: &Pubkey,
    194     ndb: &Ndb,
    195     jobs: &MediaJobSender,
    196     img_cache: &mut Images,
    197 ) {
    198     let Some(conversation) = cache.get_active() else {
    199         title_label(
    200             ui,
    201             &tr!(
    202                 i18n,
    203                 "Conversation",
    204                 "Title used when viewing an unknown conversation"
    205             ),
    206         );
    207         return;
    208     };
    209 
    210     let txn = Transaction::new(ndb).expect("txn");
    211 
    212     let title = conversation_title(&conversation.metadata, &txn, ndb, selected_pubkey, i18n);
    213     let summary = ConversationSummary {
    214         metadata: &conversation.metadata,
    215         last_message: conversation.messages.latest(),
    216         unread: false,
    217         total_messages: conversation.messages.len(),
    218     };
    219     let partner = direct_chat_partner(summary.metadata.participants.as_slice(), selected_pubkey);
    220     let partner_profile = partner.and_then(|pk| ndb.get_profile_by_pubkey(&txn, pk.bytes()).ok());
    221 
    222     conversation_header(ui, &title, jobs, img_cache, true, partner_profile.as_ref());
    223 }
    224 
    225 fn title_label(ui: &mut egui::Ui, text: &str) -> egui::Response {
    226     ui.add(
    227         egui::Label::new(RichText::new(text).text_style(NotedeckTextStyle::Heading.text_style()))
    228             .selectable(false),
    229     )
    230 }
    231 
    232 pub fn conversation_header(
    233     ui: &mut egui::Ui,
    234     title: &str,
    235     jobs: &MediaJobSender,
    236     img_cache: &mut Images,
    237     show_partner_avatar: bool,
    238     partner_profile: Option<&ProfileRecord<'_>>,
    239 ) {
    240     ui.with_layout(
    241         Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
    242         |ui| {
    243             if show_partner_avatar {
    244                 let mut pic = ProfilePic::from_profile_or_default(img_cache, jobs, partner_profile)
    245                     .size(ProfilePic::medium_size() as f32);
    246                 ui.add(&mut pic);
    247                 ui.add_space(8.0);
    248             }
    249 
    250             ui.heading(title);
    251         },
    252     );
    253 }