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 }