Skip to main content

softchat/
account.rs

1//! Private user metadata, contact backups, and addressable application data.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use url::Url;
8
9use crate::{
10    LocalIdentity, Nip44EncryptedMessage, Nip44Payload, NostrEventDraft, NostrEventKind,
11    NostrPublicKey, NostrRumor, NostrTag, RumorEvent, SignedEvent, SignedNostrEvent, SoftchatError,
12};
13
14/// Maximum contacts in one private backup.
15pub const MAX_CONTACTS: usize = 10_000;
16/// Maximum decrypted application-data JSON accepted by the shared layer.
17pub const MAX_APP_DATA_JSON_BYTES: usize = 256 * 1024;
18
19const METADATA_KEYS: [&str; 7] = [
20    "name",
21    "display_name",
22    "about",
23    "picture",
24    "website",
25    "banner",
26    "bot",
27];
28
29/// Typed private user metadata with retained unknown JSON.
30#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "camelCase")]
32#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
33pub struct UserMetadataView {
34    /// Complete exact source rumor.
35    pub rumor: RumorEvent,
36    /// Optional short name.
37    pub name: Option<String>,
38    /// Optional display name.
39    pub display_name: Option<String>,
40    /// Optional profile description.
41    pub about: Option<String>,
42    /// Optional absolute HTTPS picture URL.
43    pub picture: Option<String>,
44    /// Optional absolute HTTPS website URL.
45    pub website: Option<String>,
46    /// Optional absolute HTTPS banner URL.
47    pub banner: Option<String>,
48    /// Optional bot marker.
49    pub bot: Option<bool>,
50    /// Compact JSON object containing every unknown field.
51    pub unknown_json: String,
52}
53
54/// One canonical contact-backup entry.
55#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
58pub struct Contact {
59    /// Canonical contact public key.
60    pub public_key: String,
61    /// Optional relay hint represented as an empty string.
62    pub relay_hint: String,
63    /// Optional local name represented as an empty string.
64    pub local_name: String,
65}
66
67/// Typed private kind-3 contact backup.
68#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "camelCase")]
70#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
71pub struct FollowListView {
72    /// Complete exact source rumor.
73    pub rumor: RumorEvent,
74    /// First occurrence of each public key, sorted by public key.
75    pub contacts: Vec<Contact>,
76    /// Whether duplicate received contacts were ignored for the typed view.
77    pub contained_duplicates: bool,
78}
79
80/// Generic direct NIP-78 application data.
81#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "camelCase")]
83#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
84pub struct ApplicationDataView {
85    /// Complete verified signed event.
86    pub event: SignedEvent,
87    /// Addressable `d` identity.
88    pub identifier: String,
89    /// Opaque application-owned content.
90    pub content: String,
91}
92
93/// Accepted Softchat kind-30079 contexts.
94#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
95#[serde(rename_all = "camelCase")]
96#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
97pub enum AppDataContext {
98    /// Cross-device message read state.
99    ReadState,
100    /// Cross-device application settings.
101    AppSettings,
102}
103
104impl AppDataContext {
105    /// Return the exact `d`-tag value.
106    #[must_use]
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::ReadState => "read-state",
110            Self::AppSettings => "app-settings",
111        }
112    }
113
114    fn parse(value: &str) -> Result<Self, SoftchatError> {
115        match value {
116            "read-state" => Ok(Self::ReadState),
117            "app-settings" => Ok(Self::AppSettings),
118            _ => Err(SoftchatError::InvalidApplicationData),
119        }
120    }
121}
122
123/// Authenticated and self-decrypted Softchat kind-30079 data.
124#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "camelCase")]
126#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
127pub struct AppDataSyncView {
128    /// Complete verified direct event.
129    pub event: SignedEvent,
130    /// Accepted application context.
131    pub context: AppDataContext,
132    /// Complete validated JSON, including unknown fields.
133    pub json: String,
134}
135
136impl LocalIdentity {
137    /// Create canonical private kind-0 user metadata.
138    ///
139    /// Unknown fields are supplied as a JSON object and retained unless they
140    /// collide with the seven known fields.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`SoftchatError::InvalidUserMetadata`] for malformed values.
145    #[allow(clippy::too_many_arguments)]
146    pub fn create_user_metadata(
147        &self,
148        created_at: u64,
149        name: Option<String>,
150        display_name: Option<String>,
151        about: Option<String>,
152        picture: Option<String>,
153        website: Option<String>,
154        banner: Option<String>,
155        bot: Option<bool>,
156        unknown_json: &str,
157    ) -> Result<UserMetadataView, SoftchatError> {
158        for value in [&picture, &website, &banner].into_iter().flatten() {
159            validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
160        }
161        let mut object = parse_unknown_object(unknown_json)?;
162        for key in METADATA_KEYS {
163            if object.contains_key(key) {
164                return Err(SoftchatError::InvalidUserMetadata);
165            }
166        }
167        insert_optional(&mut object, "name", name);
168        insert_optional(&mut object, "display_name", display_name);
169        insert_optional(&mut object, "about", about);
170        insert_optional(&mut object, "picture", picture);
171        insert_optional(&mut object, "website", website);
172        insert_optional(&mut object, "banner", banner);
173        if let Some(value) = bot {
174            object.insert("bot".to_owned(), Value::Bool(value));
175        }
176        let content =
177            serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
178        if content.len() > MAX_APP_DATA_JSON_BYTES {
179            return Err(SoftchatError::InvalidUserMetadata);
180        }
181        let rumor = self
182            .create_rumor(
183                NostrEventDraft::new(
184                    created_at,
185                    NostrEventKind::USER_METADATA,
186                    Vec::new(),
187                    content,
188                )
189                .map_err(|_| SoftchatError::InvalidUserMetadata)?,
190            )
191            .map_err(|_| SoftchatError::InvalidUserMetadata)?;
192        parse_user_metadata(&rumor)
193    }
194
195    /// Create a canonical private author-copy kind-3 contact backup.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`SoftchatError::InvalidContactList`] for duplicate contacts,
200    /// malformed relay hints, or excessive list size.
201    pub fn create_follow_list(
202        &self,
203        created_at: u64,
204        contacts: Vec<Contact>,
205    ) -> Result<FollowListView, SoftchatError> {
206        if contacts.len() > MAX_CONTACTS {
207            return Err(SoftchatError::InvalidContactList);
208        }
209        let mut canonical = contacts
210            .into_iter()
211            .map(validate_contact)
212            .collect::<Result<Vec<_>, _>>()?;
213        canonical.sort();
214        let original_len = canonical.len();
215        canonical.dedup_by(|left, right| left.public_key == right.public_key);
216        if canonical.len() != original_len {
217            return Err(SoftchatError::InvalidContactList);
218        }
219        let tags = canonical
220            .iter()
221            .map(|contact| {
222                NostrTag::new(vec![
223                    "p".to_owned(),
224                    contact.public_key.clone(),
225                    contact.relay_hint.clone(),
226                    contact.local_name.clone(),
227                ])
228                .map_err(|_| SoftchatError::InvalidContactList)
229            })
230            .collect::<Result<Vec<_>, _>>()?;
231        let rumor = self
232            .create_rumor(
233                NostrEventDraft::new(created_at, NostrEventKind::FOLLOW_LIST, tags, "")
234                    .map_err(|_| SoftchatError::InvalidContactList)?,
235            )
236            .map_err(|_| SoftchatError::InvalidContactList)?;
237        parse_follow_list(&rumor)
238    }
239
240    /// Create a direct signed generic NIP-78 event.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`SoftchatError::InvalidApplicationData`] for an empty
245    /// identifier or an oversized event.
246    pub fn create_application_data(
247        &self,
248        created_at: u64,
249        identifier: String,
250        content: String,
251    ) -> Result<ApplicationDataView, SoftchatError> {
252        validate_identifier(&identifier)?;
253        let event = self
254            .sign_event(
255                NostrEventDraft::new(
256                    created_at,
257                    NostrEventKind::APPLICATION_DATA,
258                    vec![NostrTag::new(vec!["d".to_owned(), identifier])?],
259                    content,
260                )
261                .map_err(|_| SoftchatError::InvalidApplicationData)?,
262            )
263            .map_err(|_| SoftchatError::InvalidApplicationData)?;
264        parse_application_data(&event)
265    }
266
267    /// Create direct signed, self-encrypted Softchat application data.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`SoftchatError::InvalidApplicationData`] for invalid JSON,
272    /// bounds, encryption, or signing failure.
273    pub fn create_app_data_sync(
274        &self,
275        created_at: u64,
276        context: AppDataContext,
277        json: &str,
278    ) -> Result<AppDataSyncView, SoftchatError> {
279        let json = validate_json_object(json)?;
280        let encrypted = self
281            .encrypt_utf8(&self.public_key(), &json)
282            .map_err(|_| SoftchatError::InvalidApplicationData)?;
283        let event = self
284            .sign_event(
285                NostrEventDraft::new(
286                    created_at,
287                    NostrEventKind::APPLICATION_DATA_SYNC,
288                    vec![NostrTag::new(vec!["d", context.as_str()])?],
289                    encrypted.payload().as_str(),
290                )
291                .map_err(|_| SoftchatError::InvalidApplicationData)?,
292            )
293            .map_err(|_| SoftchatError::InvalidApplicationData)?;
294        self.decrypt_app_data_sync(&event)
295    }
296
297    /// Validate and self-decrypt one kind-30079 event.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`SoftchatError::InvalidApplicationData`] unless the event is
302    /// authored by this identity and all encrypted JSON authenticates.
303    pub fn decrypt_app_data_sync(
304        &self,
305        event: &SignedNostrEvent,
306    ) -> Result<AppDataSyncView, SoftchatError> {
307        if event.kind() != NostrEventKind::APPLICATION_DATA_SYNC
308            || event.public_key() != self.public_key()
309        {
310            return Err(SoftchatError::InvalidApplicationData);
311        }
312        let identifier = exactly_one_d(event.tags())?;
313        let context = AppDataContext::parse(&identifier)?;
314        let encrypted = Nip44EncryptedMessage::new(
315            self.public_key(),
316            Nip44Payload::from_encoded(event.content())
317                .map_err(|_| SoftchatError::InvalidApplicationData)?,
318        );
319        let json = self
320            .decrypt_utf8(&encrypted)
321            .map_err(|_| SoftchatError::InvalidApplicationData)?;
322        let json = validate_json_object(&json)?;
323        Ok(AppDataSyncView {
324            event: SignedEvent::from(event),
325            context,
326            json,
327        })
328    }
329}
330
331/// Validate a binding-owned private user-metadata rumor.
332#[cfg_attr(feature = "native-bindings", uniffi::export)]
333pub fn classify_user_metadata(rumor: RumorEvent) -> Result<UserMetadataView, SoftchatError> {
334    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidUserMetadata)?;
335    parse_user_metadata(&rumor)
336}
337
338/// Select the deterministic latest private metadata rumor.
339///
340/// # Errors
341///
342/// All candidates must have the same author and valid kind-0 profile.
343#[cfg_attr(feature = "native-bindings", uniffi::export)]
344pub fn latest_user_metadata(
345    candidates: Vec<RumorEvent>,
346) -> Result<UserMetadataView, SoftchatError> {
347    let candidates = candidates
348        .into_iter()
349        .map(|candidate| {
350            let rumor =
351                NostrRumor::try_from(candidate).map_err(|_| SoftchatError::InvalidUserMetadata)?;
352            let view = parse_user_metadata(&rumor)?;
353            Ok((rumor.public_key(), rumor.created_at(), rumor.id(), view))
354        })
355        .collect::<Result<Vec<_>, SoftchatError>>()?;
356    let author = candidates
357        .first()
358        .map(|(author, _, _, _)| author.clone())
359        .ok_or(SoftchatError::InvalidUserMetadata)?;
360    if candidates
361        .iter()
362        .any(|(candidate, _, _, _)| *candidate != author)
363    {
364        return Err(SoftchatError::InvalidUserMetadata);
365    }
366    candidates
367        .into_iter()
368        .max_by_key(|(_, created_at, id, _)| (*created_at, *id))
369        .map(|(_, _, _, view)| view)
370        .ok_or(SoftchatError::InvalidUserMetadata)
371}
372
373/// Validate a binding-owned private contact-backup rumor.
374#[cfg_attr(feature = "native-bindings", uniffi::export)]
375pub fn classify_follow_list(rumor: RumorEvent) -> Result<FollowListView, SoftchatError> {
376    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidContactList)?;
377    parse_follow_list(&rumor)
378}
379
380/// Validate a direct generic NIP-78 event.
381#[cfg_attr(feature = "native-bindings", uniffi::export)]
382pub fn classify_application_data(event: SignedEvent) -> Result<ApplicationDataView, SoftchatError> {
383    let event =
384        SignedNostrEvent::try_from(event).map_err(|_| SoftchatError::InvalidApplicationData)?;
385    parse_application_data(&event)
386}
387
388/// Select the deterministic latest addressable application-data event.
389///
390/// # Errors
391///
392/// All events must share `(author, kind, d)`.
393#[cfg_attr(feature = "native-bindings", uniffi::export)]
394pub fn latest_application_data(
395    candidates: Vec<SignedEvent>,
396) -> Result<ApplicationDataView, SoftchatError> {
397    let candidates = candidates
398        .into_iter()
399        .map(|candidate| {
400            let event = SignedNostrEvent::try_from(candidate)
401                .map_err(|_| SoftchatError::InvalidApplicationData)?;
402            let view = parse_application_data(&event)?;
403            Ok((
404                event.public_key(),
405                event.kind(),
406                view.identifier.clone(),
407                event.created_at(),
408                event.id(),
409                view,
410            ))
411        })
412        .collect::<Result<Vec<_>, SoftchatError>>()?;
413    let identity = candidates
414        .first()
415        .map(|(author, kind, identifier, _, _, _)| (author.clone(), *kind, identifier.clone()))
416        .ok_or(SoftchatError::InvalidApplicationData)?;
417    if candidates
418        .iter()
419        .any(|candidate| (candidate.0.clone(), candidate.1, candidate.2.clone()) != identity)
420    {
421        return Err(SoftchatError::InvalidApplicationData);
422    }
423    candidates
424        .into_iter()
425        .max_by_key(|(_, _, _, created_at, id, _)| (*created_at, *id))
426        .map(|(_, _, _, _, _, view)| view)
427        .ok_or(SoftchatError::InvalidApplicationData)
428}
429
430fn parse_user_metadata(rumor: &NostrRumor) -> Result<UserMetadataView, SoftchatError> {
431    if rumor.kind() != NostrEventKind::USER_METADATA || rumor.tags().len() != 0 {
432        return Err(SoftchatError::InvalidUserMetadata);
433    }
434    if rumor.content().len() > MAX_APP_DATA_JSON_BYTES {
435        return Err(SoftchatError::InvalidUserMetadata);
436    }
437    let mut object: Map<String, Value> =
438        serde_json::from_str(rumor.content()).map_err(|_| SoftchatError::InvalidUserMetadata)?;
439    let name = take_string(&mut object, "name")?;
440    let display_name = take_string(&mut object, "display_name")?;
441    let about = take_string(&mut object, "about")?;
442    let picture = take_string(&mut object, "picture")?;
443    let website = take_string(&mut object, "website")?;
444    let banner = take_string(&mut object, "banner")?;
445    for value in [&picture, &website, &banner].into_iter().flatten() {
446        validate_https(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
447    }
448    let bot = match object.remove("bot") {
449        Some(Value::Bool(value)) => Some(value),
450        Some(_) => return Err(SoftchatError::InvalidUserMetadata),
451        None => None,
452    };
453    let unknown_json =
454        serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidUserMetadata)?;
455    Ok(UserMetadataView {
456        rumor: RumorEvent::from(rumor),
457        name,
458        display_name,
459        about,
460        picture,
461        website,
462        banner,
463        bot,
464        unknown_json,
465    })
466}
467
468fn parse_follow_list(rumor: &NostrRumor) -> Result<FollowListView, SoftchatError> {
469    if rumor.kind() != NostrEventKind::FOLLOW_LIST
470        || !rumor.content().is_empty()
471        || rumor.tags().len() > MAX_CONTACTS
472    {
473        return Err(SoftchatError::InvalidContactList);
474    }
475    let mut contacts = BTreeMap::new();
476    let mut seen = BTreeSet::new();
477    let mut contained_duplicates = false;
478    for tag in rumor.tags() {
479        if tag.first().map(String::as_str) != Some("p") {
480            continue;
481        }
482        let contact = validate_contact(Contact {
483            public_key: tag
484                .get(1)
485                .cloned()
486                .ok_or(SoftchatError::InvalidContactList)?,
487            relay_hint: tag.get(2).cloned().unwrap_or_default(),
488            local_name: tag.get(3).cloned().unwrap_or_default(),
489        })?;
490        if !seen.insert(contact.public_key.clone()) {
491            contained_duplicates = true;
492            continue;
493        }
494        contacts.insert(contact.public_key.clone(), contact);
495    }
496    Ok(FollowListView {
497        rumor: RumorEvent::from(rumor),
498        contacts: contacts.into_values().collect(),
499        contained_duplicates,
500    })
501}
502
503fn parse_application_data(event: &SignedNostrEvent) -> Result<ApplicationDataView, SoftchatError> {
504    if event.kind() != NostrEventKind::APPLICATION_DATA {
505        return Err(SoftchatError::InvalidApplicationData);
506    }
507    let identifier = exactly_one_d(event.tags())?;
508    validate_identifier(&identifier)?;
509    Ok(ApplicationDataView {
510        event: SignedEvent::from(event),
511        identifier,
512        content: event.content().to_owned(),
513    })
514}
515
516fn exactly_one_d<'a>(tags: impl Iterator<Item = &'a [String]>) -> Result<String, SoftchatError> {
517    let identifiers = tags
518        .filter(|tag| tag.first().map(String::as_str) == Some("d"))
519        .map(|tag| {
520            tag.get(1)
521                .cloned()
522                .ok_or(SoftchatError::InvalidApplicationData)
523        })
524        .collect::<Result<Vec<_>, _>>()?;
525    match identifiers.as_slice() {
526        [identifier] => Ok(identifier.clone()),
527        _ => Err(SoftchatError::InvalidApplicationData),
528    }
529}
530
531fn parse_unknown_object(json: &str) -> Result<Map<String, Value>, SoftchatError> {
532    if json.len() > MAX_APP_DATA_JSON_BYTES {
533        return Err(SoftchatError::InvalidUserMetadata);
534    }
535    serde_json::from_str(json).map_err(|_| SoftchatError::InvalidUserMetadata)
536}
537
538fn validate_json_object(json: &str) -> Result<String, SoftchatError> {
539    if json.len() > MAX_APP_DATA_JSON_BYTES {
540        return Err(SoftchatError::InvalidApplicationData);
541    }
542    let object: Map<String, Value> =
543        serde_json::from_str(json).map_err(|_| SoftchatError::InvalidApplicationData)?;
544    serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidApplicationData)
545}
546
547fn take_string(
548    object: &mut Map<String, Value>,
549    key: &str,
550) -> Result<Option<String>, SoftchatError> {
551    match object.remove(key) {
552        Some(Value::String(value)) => Ok(Some(value)),
553        Some(_) => Err(SoftchatError::InvalidUserMetadata),
554        None => Ok(None),
555    }
556}
557
558fn insert_optional(object: &mut Map<String, Value>, key: &str, value: Option<String>) {
559    if let Some(value) = value {
560        object.insert(key.to_owned(), Value::String(value));
561    }
562}
563
564fn validate_contact(mut contact: Contact) -> Result<Contact, SoftchatError> {
565    contact.public_key = NostrPublicKey::from_hex(&contact.public_key)
566        .map_err(|_| SoftchatError::InvalidContactList)?
567        .to_hex();
568    if !contact.relay_hint.is_empty() {
569        let url = Url::parse(&contact.relay_hint).map_err(|_| SoftchatError::InvalidContactList)?;
570        if !matches!(url.scheme(), "ws" | "wss")
571            || url.host_str().is_none()
572            || url.fragment().is_some()
573        {
574            return Err(SoftchatError::InvalidContactList);
575        }
576    }
577    if contact.local_name.chars().count() > 256 {
578        return Err(SoftchatError::InvalidContactList);
579    }
580    Ok(contact)
581}
582
583fn validate_identifier(identifier: &str) -> Result<(), SoftchatError> {
584    if identifier.is_empty() || identifier.chars().count() > 256 {
585        return Err(SoftchatError::InvalidApplicationData);
586    }
587    Ok(())
588}
589
590fn validate_https(value: &str) -> Result<(), SoftchatError> {
591    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidUserMetadata)?;
592    if url.scheme() != "https" || url.host_str().is_none() || url.fragment().is_some() {
593        return Err(SoftchatError::InvalidUserMetadata);
594    }
595    Ok(())
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
603    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
604
605    #[test]
606    fn metadata_retains_unknown_fields_and_orders_replacements() -> Result<(), SoftchatError> {
607        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
608        let first = alice.create_user_metadata(
609            1,
610            Some("Alice".to_owned()),
611            None,
612            None,
613            Some("https://cdn.example/a.webp".to_owned()),
614            None,
615            None,
616            Some(false),
617            r#"{"future":{"retained":true}}"#,
618        )?;
619        assert_eq!(first.name.as_deref(), Some("Alice"));
620        assert_eq!(first.unknown_json, r#"{"future":{"retained":true}}"#);
621        let second =
622            alice.create_user_metadata(2, None, None, None, None, None, None, None, "{}")?;
623        assert_eq!(
624            latest_user_metadata(vec![first.rumor, second.rumor.clone()])?
625                .rumor
626                .id,
627            second.rumor.id
628        );
629        Ok(())
630    }
631
632    #[test]
633    fn contact_writer_is_four_field_sorted_and_reader_keeps_first_duplicate()
634    -> Result<(), SoftchatError> {
635        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
636        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
637        let bob_hex = bob.public_key().to_hex();
638        let contact = Contact {
639            public_key: bob_hex.clone(),
640            relay_hint: String::new(),
641            local_name: "Bob".to_owned(),
642        };
643        let view = alice.create_follow_list(1, vec![contact.clone()])?;
644        assert_eq!(view.rumor.tags[0].len(), 4);
645        assert_eq!(view.contacts, vec![contact]);
646
647        let duplicate = alice.create_rumor(NostrEventDraft::new(
648            2,
649            NostrEventKind::FOLLOW_LIST,
650            vec![
651                NostrTag::new(vec!["p", &bob_hex, "", "First"])?,
652                NostrTag::new(vec!["p", &bob_hex, "", "Second"])?,
653            ],
654            "",
655        )?)?;
656        let read = parse_follow_list(&duplicate)?;
657        assert!(read.contained_duplicates);
658        assert_eq!(read.contacts[0].local_name, "First");
659        Ok(())
660    }
661
662    #[test]
663    fn app_data_sync_is_direct_self_encrypted_and_deterministically_replaceable()
664    -> Result<(), SoftchatError> {
665        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
666        let first = alice.create_app_data_sync(
667            1,
668            AppDataContext::ReadState,
669            r#"{"version":1,"read":1}"#,
670        )?;
671        let second = alice.create_app_data_sync(
672            2,
673            AppDataContext::ReadState,
674            r#"{"version":1,"read":2}"#,
675        )?;
676        assert_ne!(first.event.content, first.json);
677        assert_eq!(
678            alice
679                .decrypt_app_data_sync(&SignedNostrEvent::try_from(second.event.clone())?)?
680                .json,
681            r#"{"read":2,"version":1}"#
682        );
683
684        let generic_first =
685            alice.create_application_data(1, "drafts".to_owned(), "one".to_owned())?;
686        let generic_second =
687            alice.create_application_data(2, "drafts".to_owned(), "two".to_owned())?;
688        assert_eq!(
689            latest_application_data(vec![generic_first.event, generic_second.event.clone()])?
690                .content,
691            "two"
692        );
693        Ok(())
694    }
695}