Skip to main content

iris_chat_core/
contact_memory.rs

1// Source snapshot: nostr-social-memory at fc3d39b. Keep the portable JSON contract in sync.
2// Use the crate directly after a release containing this API is available.
3//! Private contact memory. Storage and transport are left to the application.
4//!
5//! Keep one record per viewing account and contact, separate from public profile
6//! metadata. A favorite is a private preference and conveys no social trust.
7use serde::{Deserialize, Serialize};
8
9#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(default)]
11pub struct ContactMemory {
12    pub first_seen_name: Option<String>,
13    pub accepted_name: Option<String>,
14    pub favorite: bool,
15    pub name_changes: Vec<AcceptedNameChange>,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
19pub struct AcceptedNameChange {
20    pub previous_name: String,
21    pub accepted_name: String,
22    pub accepted_at_secs: u64,
23}
24
25impl ContactMemory {
26    /// Call after an interaction, not for every profile fetched by the app.
27    /// The first nonblank name is retained; later observations cannot rename it.
28    pub fn observe_name(&mut self, name: &str) {
29        if name.trim().is_empty() {
30            return;
31        }
32        self.first_seen_name.get_or_insert_with(|| name.to_string());
33        self.accepted_name.get_or_insert_with(|| name.to_string());
34    }
35
36    /// The current public name is transient and remains outside this record.
37    pub fn pending_name(&self, current_name: Option<String>) -> Option<String> {
38        current_name.filter(|name| {
39            !name.trim().is_empty()
40                && self
41                    .accepted_name
42                    .as_ref()
43                    .is_some_and(|saved| saved != name)
44        })
45    }
46
47    /// Approve exactly the name shown to the user, only if it still matches the
48    /// latest public profile. A stale or redundant approval leaves no history.
49    pub fn approve_name(
50        &mut self,
51        expected: &str,
52        current: &str,
53        now_secs: u64,
54    ) -> Option<AcceptedNameChange> {
55        if expected != current || expected.trim().is_empty() {
56            return None;
57        }
58        let previous = self
59            .accepted_name
60            .as_ref()
61            .filter(|name| *name != expected)?
62            .clone();
63        let change = AcceptedNameChange {
64            previous_name: previous,
65            accepted_name: expected.to_string(),
66            accepted_at_secs: now_secs,
67        };
68        self.accepted_name = Some(change.accepted_name.clone());
69        self.name_changes.push(change.clone());
70        Some(change)
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn remembers_first_real_name_and_keeps_profile_updates_pending() {
80        let mut memory = ContactMemory::default();
81        memory.observe_name(" ");
82        assert_eq!(memory, ContactMemory::default());
83        assert_eq!(memory.pending_name(Some("Alice".into())), None);
84        memory.observe_name("Alice");
85        memory.observe_name("Bob");
86        assert_eq!(memory.first_seen_name.as_deref(), Some("Alice"));
87        assert_eq!(memory.accepted_name.as_deref(), Some("Alice"));
88        assert_eq!(memory.pending_name(Some("Bob".into())), Some("Bob".into()));
89        assert_eq!(memory.pending_name(Some("Alice".into())), None);
90        assert_eq!(memory.pending_name(Some(" ".into())), None);
91        assert_eq!(memory.pending_name(None), None);
92    }
93
94    #[test]
95    fn requires_current_explicit_approval_and_retains_all_accepted_names() {
96        let mut memory = ContactMemory::default();
97        assert_eq!(memory.approve_name("Bob", "Bob", 100), None);
98        memory.observe_name("Alice");
99        assert_eq!(memory.approve_name("Bob", "Carol", 100), None);
100        assert_eq!(memory.approve_name(" ", " ", 100), None);
101        assert_eq!(memory.approve_name("Alice", "Alice", 100), None);
102        let first = memory.approve_name("Bob", "Bob", 100).unwrap();
103        let second = memory.approve_name("Alice", "Alice", 101).unwrap();
104        assert_eq!(memory.first_seen_name.as_deref(), Some("Alice"));
105        assert_eq!(memory.accepted_name.as_deref(), Some("Alice"));
106        assert_eq!(memory.name_changes, vec![first, second]);
107        let portable: ContactMemory =
108            serde_json::from_str(include_str!("../../test-fixtures/contact-memory.json")).unwrap();
109        assert_eq!(memory, portable);
110        let json = serde_json::to_string(&memory).unwrap();
111        assert_eq!(
112            serde_json::from_str::<ContactMemory>(&json).unwrap(),
113            memory
114        );
115    }
116
117    #[test]
118    fn favorites_are_independent_and_missing_fields_default_for_old_storage() {
119        let mut memory: ContactMemory = serde_json::from_str(r#"{"favorite":true}"#).unwrap();
120        memory.observe_name("Alice");
121        assert!(memory.favorite);
122        memory.favorite = false;
123        assert_eq!(memory.first_seen_name.as_deref(), Some("Alice"));
124        assert_eq!(memory.accepted_name.as_deref(), Some("Alice"));
125        assert!(memory.name_changes.is_empty());
126    }
127}