notedeck

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

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