conversation.rs (7818B)
1 use std::cmp::Ordering; 2 3 use crate::{ 4 cache::{ 5 message_store::NotePkg, 6 registry::{ 7 ConversationId, ConversationIdentifierUnowned, ConversationRegistry, 8 ParticipantSetUnowned, 9 }, 10 }, 11 convo_renderable::ConversationRenderable, 12 nip17::get_participants, 13 relay_ensure::DmListState, 14 }; 15 16 use super::message_store::MessageStore; 17 use enostr::Pubkey; 18 use hashbrown::HashMap; 19 use nostrdb::{Ndb, Note, NoteKey, Subscription, Transaction}; 20 use notedeck::{note::event_tag, NoteCache, NoteRef, UnknownIds}; 21 22 pub struct ConversationCache { 23 pub registry: ConversationRegistry, 24 conversations: HashMap<ConversationId, Conversation>, 25 order: Vec<ConversationOrder>, 26 pub state: ConversationListState, 27 dm_relay_list_ensure: DmListState, 28 pub active: Option<ConversationId>, 29 } 30 31 impl ConversationCache { 32 pub fn new() -> Self { 33 Self::default() 34 } 35 36 pub fn len(&self) -> usize { 37 self.conversations.len() 38 } 39 40 pub fn is_empty(&self) -> bool { 41 self.conversations.is_empty() 42 } 43 44 pub fn get(&self, id: ConversationId) -> Option<&Conversation> { 45 self.conversations.get(&id) 46 } 47 48 pub fn get_id_by_index(&self, i: usize) -> Option<&ConversationId> { 49 Some(&self.order.get(i)?.id) 50 } 51 52 pub fn get_active(&self) -> Option<&Conversation> { 53 self.conversations.get(&self.active?) 54 } 55 56 #[profiling::function] 57 pub fn ingest_chatroom_msg( 58 &mut self, 59 note: Note, 60 key: NoteKey, 61 ndb: &Ndb, 62 txn: &Transaction, 63 note_cache: &mut NoteCache, 64 unknown_ids: &mut UnknownIds, 65 ) { 66 let participants = get_participants(¬e); 67 68 let id = self 69 .registry 70 .get_or_insert(ConversationIdentifierUnowned::Nip17( 71 ParticipantSetUnowned::new(participants.clone()), 72 )); 73 74 let conversation = self.conversations.entry(id).or_insert_with(|| { 75 let participants: Vec<Pubkey> = 76 participants.into_iter().map(|p| Pubkey::new(*p)).collect(); 77 78 Conversation::new(id, participants) 79 }); 80 81 tracing::trace!("ingesting into conversation id {id}: {:?}", note.json()); 82 UnknownIds::update_from_note(txn, ndb, unknown_ids, note_cache, ¬e); 83 if conversation.ingest_kind_14(note, key) { 84 let latest = conversation.last_activity(); 85 refresh_order(&mut self.order, id, LatestMessage::Latest(latest)); 86 } 87 } 88 89 pub fn initialize_conversation(&mut self, id: ConversationId, participants: Vec<Pubkey>) { 90 if self.conversations.contains_key(&id) { 91 return; 92 } 93 94 self.conversations 95 .insert(id, Conversation::new(id, participants)); 96 97 refresh_order(&mut self.order, id, LatestMessage::NoMessages); 98 } 99 100 pub fn first_convo_id(&self) -> Option<ConversationId> { 101 Some(self.order.first()?.id) 102 } 103 104 /// Mutable access to the selected-account DM relay-list ensure state. 105 pub fn dm_relay_list_ensure_mut(&mut self) -> &mut DmListState { 106 &mut self.dm_relay_list_ensure 107 } 108 } 109 110 fn refresh_order(order: &mut Vec<ConversationOrder>, id: ConversationId, latest: LatestMessage) { 111 if let Some(pos) = order.iter().position(|entry| entry.id == id) { 112 order.remove(pos); 113 } 114 115 let entry = ConversationOrder { id, latest }; 116 let idx = match order.binary_search(&entry) { 117 Ok(idx) | Err(idx) => idx, 118 }; 119 order.insert(idx, entry); 120 } 121 122 #[derive(Clone, Copy, Debug)] 123 struct ConversationOrder { 124 id: ConversationId, 125 latest: LatestMessage, 126 } 127 128 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 129 enum LatestMessage { 130 NoMessages, 131 Latest(u64), 132 } 133 134 impl PartialOrd for LatestMessage { 135 fn partial_cmp(&self, other: &Self) -> Option<Ordering> { 136 Some(self.cmp(other)) 137 } 138 } 139 140 impl Ord for LatestMessage { 141 fn cmp(&self, other: &Self) -> Ordering { 142 match (self, other) { 143 (LatestMessage::Latest(a), LatestMessage::Latest(b)) => a.cmp(b), 144 (LatestMessage::NoMessages, LatestMessage::NoMessages) => Ordering::Equal, 145 (LatestMessage::NoMessages, _) => Ordering::Greater, 146 (_, LatestMessage::NoMessages) => Ordering::Less, 147 } 148 } 149 } 150 151 impl PartialEq for ConversationOrder { 152 fn eq(&self, other: &Self) -> bool { 153 self.id == other.id 154 } 155 } 156 157 impl Eq for ConversationOrder {} 158 159 impl PartialOrd for ConversationOrder { 160 fn partial_cmp(&self, other: &Self) -> Option<Ordering> { 161 Some(self.cmp(other)) 162 } 163 } 164 165 impl Ord for ConversationOrder { 166 fn cmp(&self, other: &Self) -> Ordering { 167 // newer first 168 match other.latest.cmp(&self.latest) { 169 Ordering::Equal => self.id.cmp(&other.id), 170 non_eq => non_eq, 171 } 172 } 173 } 174 175 pub struct Conversation { 176 pub id: ConversationId, 177 pub messages: MessageStore, 178 pub metadata: ConversationMetadata, 179 pub renderable: ConversationRenderable, 180 } 181 182 impl Conversation { 183 pub fn new(id: ConversationId, participants: Vec<Pubkey>) -> Self { 184 Self { 185 id, 186 messages: MessageStore::default(), 187 metadata: ConversationMetadata::new(participants), 188 renderable: ConversationRenderable::new(&[]), 189 } 190 } 191 192 fn last_activity(&self) -> u64 { 193 self.messages.newest_timestamp().unwrap_or(0) 194 } 195 196 pub fn ingest_kind_14(&mut self, note: Note, key: NoteKey) -> bool { 197 if note.kind() != 14 { 198 tracing::error!("tried to ingest a non-kind 14 note..."); 199 return false; 200 } 201 202 if let Some(title) = event_tag(¬e, "subject") { 203 let created = note.created_at(); 204 205 if self 206 .metadata 207 .title 208 .as_ref() 209 .is_none_or(|cur| created > cur.last_modified) 210 { 211 self.metadata.title = Some(TitleMetadata { 212 title: title.to_string(), 213 last_modified: created, 214 }); 215 } 216 } 217 218 let inserted = self.messages.insert(NotePkg { 219 note_ref: NoteRef { 220 key, 221 created_at: note.created_at(), 222 }, 223 author: Pubkey::new(*note.pubkey()), 224 }); 225 226 if inserted { 227 self.renderable = ConversationRenderable::new(&self.messages.messages_ordered); 228 } 229 230 inserted 231 } 232 } 233 234 impl Default for ConversationCache { 235 fn default() -> Self { 236 Self { 237 registry: ConversationRegistry::default(), 238 conversations: HashMap::new(), 239 order: Vec::new(), 240 state: Default::default(), 241 dm_relay_list_ensure: Default::default(), 242 active: None, 243 } 244 } 245 } 246 247 #[derive(Clone, Debug, Default)] 248 pub struct ConversationMetadata { 249 pub title: Option<TitleMetadata>, 250 pub participants: Vec<Pubkey>, 251 } 252 253 #[derive(Clone, Debug)] 254 pub struct TitleMetadata { 255 pub title: String, 256 pub last_modified: u64, 257 } 258 259 impl ConversationMetadata { 260 pub fn new(participants: Vec<Pubkey>) -> Self { 261 Self { 262 title: None, 263 participants, 264 } 265 } 266 } 267 268 /// Tracks the conversation list initialization and subscription lifecycle. 269 #[derive(Default)] 270 pub enum ConversationListState { 271 /// No loader request has been issued yet. 272 #[default] 273 Initializing, 274 /// Loader is streaming the initial conversation list. 275 Loading { 276 /// Optional live subscription for incoming conversation updates. 277 subscription: Option<Subscription>, 278 }, 279 /// Initial load completed; subscription remains active if available. 280 Initialized(Option<Subscription>), // conversation list filter 281 }