Skip to main content

softchat/
chat.rs

1//! Typed Softchat chat-event views, canonical writers, and pure projections.
2
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::{
9    AttachmentMetadata, LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind,
10    NostrPublicKey, NostrRumor, NostrTag, RumorEvent, SignedEvent, SignedNostrEvent, SoftchatError,
11};
12
13/// Maximum participant count in one Softchat rumor.
14pub const MAX_CHAT_PARTICIPANTS: usize = 256;
15/// Maximum typed extension tags accepted from a writer.
16pub const MAX_CHAT_EXTENSION_TAGS: usize = 256;
17/// Maximum custom emoji definitions on one event.
18pub const MAX_CHAT_EMOJI_TAGS: usize = 128;
19
20/// Relation semantics exposed by a kind-14 message view.
21#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(rename_all = "camelCase")]
23#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
24pub enum ChatRelationKind {
25    /// No reply or forward.
26    None,
27    /// Canonical marked NIP-10 reply.
28    Reply,
29    /// Released-Android unmarked `e` compatibility reply.
30    LegacyReply,
31    /// Softchat `q`-tag forward.
32    Forward,
33}
34
35/// One validated message relation.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum ChatRelation {
38    /// No relation.
39    None,
40    /// Reply to another event.
41    Reply {
42        /// Parent event ID.
43        event_id: NostrEventId,
44        /// Optional validated relay hint.
45        relay_hint: Option<String>,
46        /// Whether the reader recovered the released-Android unmarked shape.
47        legacy_unmarked: bool,
48    },
49    /// Forward another event.
50    Forward {
51        /// Forwarded event ID.
52        event_id: NostrEventId,
53        /// Optional validated relay hint.
54        relay_hint: Option<String>,
55        /// Original event author.
56        original_author: NostrPublicKey,
57    },
58}
59
60/// Flat relation input for generated native bindings.
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
64pub struct ChatRelationInput {
65    /// Relation type. Writers reject `LegacyReply`.
66    pub kind: ChatRelationKind,
67    /// Related event ID, empty only for `None`.
68    pub event_id: String,
69    /// Optional relay hint represented by an empty string when absent.
70    pub relay_hint: String,
71    /// Forwarded event author, empty except for `Forward`.
72    pub original_author: String,
73}
74
75impl ChatRelationInput {
76    /// Validate this binding-friendly value as a canonical writer relation.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`SoftchatError::SoftchatEventCreationFailed`] for incomplete
81    /// values, invalid IDs, invalid relay hints, or receive-only legacy input.
82    pub fn into_relation(self) -> Result<ChatRelation, SoftchatError> {
83        let relay_hint = optional_relay_hint(&self.relay_hint)?;
84        match self.kind {
85            ChatRelationKind::None
86                if self.event_id.is_empty()
87                    && self.relay_hint.is_empty()
88                    && self.original_author.is_empty() =>
89            {
90                Ok(ChatRelation::None)
91            }
92            ChatRelationKind::Reply if self.original_author.is_empty() => Ok(ChatRelation::Reply {
93                event_id: NostrEventId::from_hex(&self.event_id)
94                    .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
95                relay_hint,
96                legacy_unmarked: false,
97            }),
98            ChatRelationKind::Forward => Ok(ChatRelation::Forward {
99                event_id: NostrEventId::from_hex(&self.event_id)
100                    .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
101                relay_hint,
102                original_author: NostrPublicKey::from_hex(&self.original_author)
103                    .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
104            }),
105            _ => Err(SoftchatError::SoftchatEventCreationFailed),
106        }
107    }
108}
109
110/// Canonical complete kind-14 message writer input.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct ChatMessageDraft {
113    /// Explicit application timestamp.
114    pub created_at: u64,
115    /// Every conversation participant except the author.
116    pub participants: Vec<NostrPublicKey>,
117    /// Text content; may be empty only when at least one attachment is present.
118    pub content: String,
119    /// At most one reply or forward.
120    pub relation: ChatRelation,
121    /// Canonical inline attachments.
122    pub attachments: Vec<AttachmentMetadata>,
123    /// Exact canonical `emoji` tags.
124    pub emoji_tags: Vec<NostrTag>,
125    /// Caller-owned future tags retained after known tags.
126    pub extension_tags: Vec<NostrTag>,
127}
128
129/// Flat complete kind-14 message view.
130#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
131#[serde(rename_all = "camelCase")]
132#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
133pub struct ChatMessageView {
134    /// Complete exact source rumor.
135    pub rumor: RumorEvent,
136    /// Sorted unique participant public keys, excluding the author.
137    pub participants: Vec<String>,
138    /// Parsed relation type.
139    pub relation_kind: ChatRelationKind,
140    /// Related event ID, or empty.
141    pub related_event_id: String,
142    /// Valid relay hint, or empty.
143    pub relay_hint: String,
144    /// Forwarded author, or empty.
145    pub original_author: String,
146    /// Parsed attachment metadata.
147    pub attachments: Vec<AttachmentMetadata>,
148    /// Exact custom-emoji tags.
149    pub emoji_tags: Vec<Vec<String>>,
150    /// Unknown or compatibility tags retained exactly.
151    pub extension_tags: Vec<Vec<String>>,
152}
153
154/// Canonical group-subject writer input.
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct SubjectDraft {
157    /// Explicit application timestamp.
158    pub created_at: u64,
159    /// Every conversation participant except the author.
160    pub participants: Vec<NostrPublicKey>,
161    /// Subject text; empty explicitly clears text.
162    pub subject: String,
163    /// Exact custom-emoji tags referenced by the subject.
164    pub emoji_tags: Vec<NostrTag>,
165    /// Optional icon metadata. Its URL and dimensions are mirrored to `image`.
166    pub icon: Option<AttachmentMetadata>,
167    /// Caller-owned future tags.
168    pub extension_tags: Vec<NostrTag>,
169}
170
171/// Typed group-subject view.
172#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
173#[serde(rename_all = "camelCase")]
174#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
175pub struct SubjectView {
176    /// Complete exact source rumor.
177    pub rumor: RumorEvent,
178    /// Sorted unique participants.
179    pub participants: Vec<String>,
180    /// Subject text, including an explicit empty clear.
181    pub subject: String,
182    /// Icon URL, or empty when unchanged.
183    pub icon_url: String,
184    /// Icon dimensions, or empty.
185    pub icon_dimensions: String,
186    /// Exact emoji tags.
187    pub emoji_tags: Vec<Vec<String>>,
188    /// Unknown tags retained exactly.
189    pub extension_tags: Vec<Vec<String>>,
190}
191
192/// Canonical private-reaction writer input.
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct ReactionDraft {
195    /// Every conversation participant except the author.
196    pub participants: Vec<NostrPublicKey>,
197    /// Referenced event ID.
198    pub parent_id: NostrEventId,
199    /// Referenced event author.
200    pub parent_author: NostrPublicKey,
201    /// Referenced event kind.
202    pub parent_kind: NostrEventKind,
203    /// Unicode reaction or `:shortcode:`.
204    pub reaction: String,
205    /// HTTPS custom-emoji URL when `reaction` is a shortcode.
206    pub custom_emoji_url: Option<String>,
207    /// Explicit application timestamp.
208    pub created_at: u64,
209}
210
211/// Typed NIP-25 reaction view.
212#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
213#[serde(rename_all = "camelCase")]
214#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
215pub struct ReactionView {
216    /// Complete exact source rumor.
217    pub rumor: RumorEvent,
218    /// Referenced event ID.
219    pub event_id: String,
220    /// Referenced participant/author `p` tags retained in canonical order.
221    pub event_authors: Vec<String>,
222    /// Referenced event kind.
223    pub event_kind: i32,
224    /// Unicode reaction or `:shortcode:`.
225    pub reaction: String,
226    /// Custom emoji URL, or empty.
227    pub custom_emoji_url: String,
228}
229
230/// Typed NIP-09 deletion view.
231#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
232#[serde(rename_all = "camelCase")]
233#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
234pub struct DeletionView {
235    /// Complete exact source rumor.
236    pub rumor: RumorEvent,
237    /// Referenced event IDs.
238    pub event_ids: Vec<String>,
239    /// Optional reason.
240    pub reason: String,
241}
242
243/// Typed Softchat kind-1010 edit view.
244#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
245#[serde(rename_all = "camelCase")]
246#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
247pub struct EditView {
248    /// Complete exact source rumor.
249    pub rumor: RumorEvent,
250    /// Original message ID.
251    pub original_event_id: String,
252    /// Sorted conversation participants.
253    pub participants: Vec<String>,
254    /// Complete replacement text.
255    pub content: String,
256    /// Replacement custom-emoji tags.
257    pub emoji_tags: Vec<Vec<String>>,
258    /// Ignored released-client compatibility tags.
259    pub compatibility_tags: Vec<Vec<String>>,
260}
261
262/// Typed ephemeral typing view.
263#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
264#[serde(rename_all = "camelCase")]
265#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
266pub struct TypingView {
267    /// Complete exact source rumor.
268    pub rumor: RumorEvent,
269    /// Sorted conversation participants.
270    pub participants: Vec<String>,
271}
272
273/// Validated receive-only NIP-18 generic repost.
274#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
275#[serde(rename_all = "camelCase")]
276#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
277pub struct GenericRepostView {
278    /// Complete exact repost event.
279    pub repost: SignedEvent,
280    /// Complete verified embedded event.
281    pub embedded_event: SignedEvent,
282}
283
284/// Pure text/deletion projection over one original and its event history.
285#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
286#[serde(rename_all = "camelCase")]
287#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
288pub struct MessageProjection {
289    /// Original event ID.
290    pub original_event_id: String,
291    /// Whether a valid deletion of the original dominates the result.
292    pub deleted: bool,
293    /// Effective text, empty when deleted.
294    pub content: String,
295    /// Effective emoji tags.
296    pub emoji_tags: Vec<Vec<String>>,
297    /// Winning edit ID, or empty when the original wins.
298    pub applied_edit_id: String,
299}
300
301impl LocalIdentity {
302    /// Create a canonical one-to-one or group kind-14 message rumor.
303    ///
304    /// # Errors
305    ///
306    /// Returns [`SoftchatError::SoftchatEventCreationFailed`] for invalid
307    /// participants, relations, attachment metadata, or extension tags.
308    pub fn create_chat_message(
309        &self,
310        draft: ChatMessageDraft,
311    ) -> Result<ChatMessageView, SoftchatError> {
312        let participants = canonical_participants(self.public_key(), draft.participants)?;
313        validate_writer_tags(&draft.emoji_tags, "emoji", MAX_CHAT_EMOJI_TAGS)?;
314        validate_extension_tags(&draft.extension_tags)?;
315        if draft.content.is_empty() && draft.attachments.is_empty() {
316            return Err(SoftchatError::SoftchatEventCreationFailed);
317        }
318
319        let mut tags = participant_tags(&participants)?;
320        match draft.relation {
321            ChatRelation::None => {}
322            ChatRelation::Reply {
323                event_id,
324                relay_hint,
325                legacy_unmarked,
326            } => {
327                if legacy_unmarked {
328                    return Err(SoftchatError::SoftchatEventCreationFailed);
329                }
330                tags.push(NostrTag::new(vec![
331                    "e".to_owned(),
332                    event_id.to_hex(),
333                    relay_hint.unwrap_or_default(),
334                    "reply".to_owned(),
335                ])?);
336            }
337            ChatRelation::Forward {
338                event_id,
339                relay_hint,
340                original_author,
341            } => tags.push(NostrTag::new(vec![
342                "q".to_owned(),
343                event_id.to_hex(),
344                relay_hint.unwrap_or_default(),
345                original_author.to_hex(),
346            ])?),
347        }
348        for attachment in draft.attachments {
349            tags.push(attachment.to_tag()?);
350        }
351        tags.extend(draft.emoji_tags);
352        tags.extend(draft.extension_tags);
353        let rumor = self
354            .create_rumor(NostrEventDraft::new(
355                draft.created_at,
356                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
357                tags,
358                draft.content,
359            )?)
360            .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?;
361        parse_chat_message(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
362    }
363
364    /// Create a canonical subject update with dual Android/iOS icon tags.
365    ///
366    /// # Errors
367    ///
368    /// Returns [`SoftchatError::SoftchatEventCreationFailed`] for invalid input.
369    pub fn create_subject(&self, draft: SubjectDraft) -> Result<SubjectView, SoftchatError> {
370        let participants = canonical_participants(self.public_key(), draft.participants)?;
371        validate_writer_tags(&draft.emoji_tags, "emoji", MAX_CHAT_EMOJI_TAGS)?;
372        validate_extension_tags(&draft.extension_tags)?;
373        let mut tags = participant_tags(&participants)?;
374        tags.push(NostrTag::new(vec!["subject".to_owned(), draft.subject])?);
375        tags.extend(draft.emoji_tags);
376        if let Some(icon) = draft.icon {
377            let icon_tag = icon.to_tag()?;
378            let dimensions = icon.dimensions.unwrap_or_default();
379            tags.push(icon_tag);
380            tags.push(NostrTag::new(vec![
381                "image".to_owned(),
382                icon.url,
383                dimensions,
384            ])?);
385        }
386        tags.extend(draft.extension_tags);
387        let rumor = self
388            .create_rumor(NostrEventDraft::new(
389                draft.created_at,
390                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
391                tags,
392                "",
393            )?)
394            .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?;
395        parse_subject(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
396    }
397
398    /// Create a canonical private reaction rumor.
399    ///
400    /// # Errors
401    ///
402    /// Returns a stable creation failure for malformed references or reaction.
403    pub fn create_reaction(&self, draft: ReactionDraft) -> Result<ReactionView, SoftchatError> {
404        let participants = canonical_participants(self.public_key(), draft.participants)?;
405        if !participants.contains(&draft.parent_author) {
406            return Err(SoftchatError::SoftchatEventCreationFailed);
407        }
408        if draft.reaction.is_empty() || draft.reaction.chars().count() > 64 {
409            return Err(SoftchatError::SoftchatEventCreationFailed);
410        }
411        let mut tags = participant_tags(&participants)?;
412        tags.extend([
413            NostrTag::new(vec!["e".to_owned(), draft.parent_id.to_hex()])?,
414            NostrTag::new(vec!["k".to_owned(), draft.parent_kind.as_u16().to_string()])?,
415        ]);
416        if let Some(url) = draft.custom_emoji_url {
417            let shortcode = draft
418                .reaction
419                .strip_prefix(':')
420                .and_then(|value| value.strip_suffix(':'))
421                .filter(|value| !value.is_empty())
422                .ok_or(SoftchatError::SoftchatEventCreationFailed)?;
423            validate_https(&url)?;
424            tags.push(NostrTag::new(vec!["emoji", shortcode, &url])?);
425        }
426        let rumor = self.create_rumor(NostrEventDraft::new(
427            draft.created_at,
428            NostrEventKind::REACTION,
429            tags,
430            draft.reaction,
431        )?)?;
432        parse_reaction(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
433    }
434
435    /// Create a canonical private deletion request.
436    ///
437    /// # Errors
438    ///
439    /// Returns a stable creation failure for empty, duplicate, or oversized IDs.
440    pub fn create_deletion(
441        &self,
442        participants: Vec<NostrPublicKey>,
443        event_ids: Vec<NostrEventId>,
444        reason: String,
445        created_at: u64,
446    ) -> Result<DeletionView, SoftchatError> {
447        let participants = canonical_participants(self.public_key(), participants)?;
448        if event_ids.is_empty() || event_ids.len() > 256 {
449            return Err(SoftchatError::SoftchatEventCreationFailed);
450        }
451        let mut unique_ids = BTreeSet::new();
452        if event_ids
453            .iter()
454            .any(|event_id| !unique_ids.insert(*event_id))
455        {
456            return Err(SoftchatError::SoftchatEventCreationFailed);
457        }
458        let mut tags = participant_tags(&participants)?;
459        for event_id in event_ids {
460            tags.push(NostrTag::new(vec!["e".to_owned(), event_id.to_hex()])?);
461        }
462        let rumor = self.create_rumor(NostrEventDraft::new(
463            created_at,
464            NostrEventKind::EVENT_DELETION_REQUEST,
465            tags,
466            reason,
467        )?)?;
468        parse_deletion(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
469    }
470
471    /// Create a canonical text-and-emoji-only kind-1010 edit.
472    ///
473    /// # Errors
474    ///
475    /// Returns a stable creation failure for invalid participants or emoji tags.
476    pub fn create_edit(
477        &self,
478        participants: Vec<NostrPublicKey>,
479        original_event_id: NostrEventId,
480        content: String,
481        emoji_tags: Vec<NostrTag>,
482        created_at: u64,
483    ) -> Result<EditView, SoftchatError> {
484        let participants = canonical_participants(self.public_key(), participants)?;
485        validate_writer_tags(&emoji_tags, "emoji", MAX_CHAT_EMOJI_TAGS)?;
486        let mut tags = participant_tags(&participants)?;
487        tags.push(NostrTag::new(vec![
488            "e".to_owned(),
489            original_event_id.to_hex(),
490        ])?);
491        tags.extend(emoji_tags);
492        let rumor = self.create_rumor(NostrEventDraft::new(
493            created_at,
494            NostrEventKind::UPDATED_CONTENT,
495            tags,
496            content,
497        )?)?;
498        parse_edit(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
499    }
500
501    /// Create one ephemeral kind-21234 typing rumor.
502    ///
503    /// # Errors
504    ///
505    /// Returns a stable creation failure for an invalid participant set.
506    pub fn create_typing(
507        &self,
508        participants: Vec<NostrPublicKey>,
509        created_at: u64,
510    ) -> Result<TypingView, SoftchatError> {
511        let participants = canonical_participants(self.public_key(), participants)?;
512        let rumor = self.create_rumor(NostrEventDraft::new(
513            created_at,
514            NostrEventKind::TYPING,
515            participant_tags(&participants)?,
516            "",
517        )?)?;
518        parse_typing(&rumor).map_err(|_| SoftchatError::SoftchatEventCreationFailed)
519    }
520}
521
522/// Validate a binding-owned rumor as a complete kind-14 message.
523#[cfg_attr(feature = "native-bindings", uniffi::export)]
524pub fn classify_chat_message(rumor: RumorEvent) -> Result<ChatMessageView, SoftchatError> {
525    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
526    parse_chat_message(&rumor)
527}
528
529/// Validate a binding-owned rumor as a subject update.
530#[cfg_attr(feature = "native-bindings", uniffi::export)]
531pub fn classify_subject(rumor: RumorEvent) -> Result<SubjectView, SoftchatError> {
532    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
533    parse_subject(&rumor)
534}
535
536/// Validate a binding-owned rumor as a reaction.
537#[cfg_attr(feature = "native-bindings", uniffi::export)]
538pub fn classify_reaction(rumor: RumorEvent) -> Result<ReactionView, SoftchatError> {
539    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
540    parse_reaction(&rumor)
541}
542
543/// Validate a binding-owned rumor as a deletion request.
544#[cfg_attr(feature = "native-bindings", uniffi::export)]
545pub fn classify_deletion(rumor: RumorEvent) -> Result<DeletionView, SoftchatError> {
546    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
547    parse_deletion(&rumor)
548}
549
550/// Validate a binding-owned rumor as a kind-1010 edit.
551#[cfg_attr(feature = "native-bindings", uniffi::export)]
552pub fn classify_edit(rumor: RumorEvent) -> Result<EditView, SoftchatError> {
553    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
554    parse_edit(&rumor)
555}
556
557/// Validate a binding-owned rumor as a typing event.
558#[cfg_attr(feature = "native-bindings", uniffi::export)]
559pub fn classify_typing(rumor: RumorEvent) -> Result<TypingView, SoftchatError> {
560    let rumor = NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
561    parse_typing(&rumor)
562}
563
564/// Validate a signed receive-only NIP-18 generic repost.
565#[cfg_attr(feature = "native-bindings", uniffi::export)]
566pub fn classify_generic_repost(repost: SignedEvent) -> Result<GenericRepostView, SoftchatError> {
567    let repost =
568        SignedNostrEvent::try_from(repost).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
569    parse_generic_repost(&repost)
570}
571
572/// Apply authorized edit and deletion history without owning persistence.
573///
574/// # Errors
575///
576/// Returns [`SoftchatError::InvalidSoftchatProjection`] when any supplied
577/// history event is malformed, targets another original, or has an
578/// unauthorized author.
579#[cfg_attr(feature = "native-bindings", uniffi::export)]
580pub fn project_chat_message(
581    original: RumorEvent,
582    edits: Vec<RumorEvent>,
583    deletions: Vec<RumorEvent>,
584) -> Result<MessageProjection, SoftchatError> {
585    let original =
586        NostrRumor::try_from(original).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
587    let original_view =
588        parse_chat_message(&original).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
589    let original_id = original.id();
590    let author = original.public_key();
591
592    let mut deleted_ids = BTreeSet::new();
593    for deletion in deletions {
594        let deletion =
595            NostrRumor::try_from(deletion).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
596        if deletion.public_key() != author {
597            return Err(SoftchatError::InvalidSoftchatProjection);
598        }
599        let view =
600            parse_deletion(&deletion).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
601        for event_id in view.event_ids {
602            deleted_ids.insert(
603                NostrEventId::from_hex(&event_id)
604                    .map_err(|_| SoftchatError::InvalidSoftchatProjection)?,
605            );
606        }
607    }
608
609    if deleted_ids.contains(&original_id) {
610        return Ok(MessageProjection {
611            original_event_id: original_id.to_hex(),
612            deleted: true,
613            content: String::new(),
614            emoji_tags: Vec::new(),
615            applied_edit_id: String::new(),
616        });
617    }
618
619    let mut eligible = Vec::new();
620    for edit in edits {
621        let edit =
622            NostrRumor::try_from(edit).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
623        if edit.public_key() != author {
624            return Err(SoftchatError::InvalidSoftchatProjection);
625        }
626        let view = parse_edit(&edit).map_err(|_| SoftchatError::InvalidSoftchatProjection)?;
627        if view.original_event_id != original_id.to_hex() {
628            return Err(SoftchatError::InvalidSoftchatProjection);
629        }
630        if !deleted_ids.contains(&edit.id()) {
631            eligible.push((edit.created_at(), edit.id(), view));
632        }
633    }
634    eligible.sort_by_key(|(created_at, event_id, _)| (*created_at, *event_id));
635    if let Some((_, event_id, edit)) = eligible.pop() {
636        Ok(MessageProjection {
637            original_event_id: original_id.to_hex(),
638            deleted: false,
639            content: edit.content,
640            emoji_tags: edit.emoji_tags,
641            applied_edit_id: event_id.to_hex(),
642        })
643    } else {
644        Ok(MessageProjection {
645            original_event_id: original_id.to_hex(),
646            deleted: false,
647            content: original.content().to_owned(),
648            emoji_tags: original_view.emoji_tags,
649            applied_edit_id: String::new(),
650        })
651    }
652}
653
654fn parse_chat_message(rumor: &NostrRumor) -> Result<ChatMessageView, SoftchatError> {
655    if rumor.kind() != NostrEventKind::PRIVATE_DIRECT_MESSAGE {
656        return Err(SoftchatError::InvalidSoftchatEvent);
657    }
658    let mut participants = Vec::new();
659    let mut relation = ChatRelation::None;
660    let mut legacy_unmarked_e = None;
661    let mut attachments = Vec::new();
662    let mut emoji_tags = Vec::new();
663    let mut extension_tags = Vec::new();
664    let mut has_subject = false;
665
666    for tag in rumor.tags() {
667        match tag.first().map(String::as_str) {
668            Some("p") => participants.push(parse_participant(tag, rumor.public_key())?),
669            Some("subject") => {
670                has_subject = true;
671                extension_tags.push(tag.to_vec());
672            }
673            Some("e") if tag.len() >= 2 && tag.get(3).map(String::as_str) == Some("reply") => {
674                if !matches!(relation, ChatRelation::None) {
675                    return Err(SoftchatError::InvalidSoftchatEvent);
676                }
677                relation = ChatRelation::Reply {
678                    event_id: NostrEventId::from_hex(&tag[1])
679                        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?,
680                    relay_hint: tag
681                        .get(2)
682                        .and_then(|value| optional_relay_hint(value).ok().flatten()),
683                    legacy_unmarked: false,
684                };
685            }
686            Some("e") if tag.len() >= 2 && tag.len() <= 3 => {
687                if legacy_unmarked_e.is_some() {
688                    return Err(SoftchatError::InvalidSoftchatEvent);
689                }
690                legacy_unmarked_e = Some((
691                    NostrEventId::from_hex(&tag[1])
692                        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?,
693                    tag.get(2)
694                        .and_then(|value| optional_relay_hint(value).ok().flatten()),
695                    tag.to_vec(),
696                ));
697            }
698            Some("q") if tag.len() >= 4 => {
699                if !matches!(relation, ChatRelation::None) {
700                    return Err(SoftchatError::InvalidSoftchatEvent);
701                }
702                relation = ChatRelation::Forward {
703                    event_id: NostrEventId::from_hex(&tag[1])
704                        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?,
705                    relay_hint: optional_relay_hint(&tag[2]).ok().flatten(),
706                    original_author: NostrPublicKey::from_hex(&tag[3])
707                        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?,
708                };
709            }
710            Some("imeta") => attachments.push(AttachmentMetadata::from_tag(tag)?),
711            Some("emoji") => {
712                validate_emoji_tag(tag)?;
713                emoji_tags.push(tag.to_vec());
714            }
715            _ => extension_tags.push(tag.to_vec()),
716        }
717    }
718    participants = validate_read_participants(participants)?;
719    if has_subject || (rumor.content().is_empty() && attachments.is_empty()) {
720        return Err(SoftchatError::InvalidSoftchatEvent);
721    }
722    if matches!(relation, ChatRelation::Forward { .. }) {
723        if let Some((legacy_id, _, raw)) = legacy_unmarked_e {
724            let forward_id = match &relation {
725                ChatRelation::Forward { event_id, .. } => event_id,
726                _ => return Err(SoftchatError::InvalidSoftchatEvent),
727            };
728            if &legacy_id != forward_id {
729                return Err(SoftchatError::InvalidSoftchatEvent);
730            }
731            extension_tags.push(raw);
732        }
733    } else if let Some((event_id, relay_hint, _)) = legacy_unmarked_e {
734        if !matches!(relation, ChatRelation::None) {
735            return Err(SoftchatError::InvalidSoftchatEvent);
736        }
737        relation = ChatRelation::Reply {
738            event_id,
739            relay_hint,
740            legacy_unmarked: true,
741        };
742    }
743
744    let (relation_kind, related_event_id, relay_hint, original_author) =
745        flatten_relation(&relation);
746    Ok(ChatMessageView {
747        rumor: RumorEvent::from(rumor),
748        participants: participants
749            .into_iter()
750            .map(|public_key| public_key.to_hex())
751            .collect(),
752        relation_kind,
753        related_event_id,
754        relay_hint,
755        original_author,
756        attachments,
757        emoji_tags,
758        extension_tags,
759    })
760}
761
762fn parse_subject(rumor: &NostrRumor) -> Result<SubjectView, SoftchatError> {
763    if rumor.kind() != NostrEventKind::PRIVATE_DIRECT_MESSAGE || !rumor.content().is_empty() {
764        return Err(SoftchatError::InvalidSoftchatEvent);
765    }
766    let mut participants = Vec::new();
767    let mut subject = None;
768    let mut emoji_tags = Vec::new();
769    let mut imeta_icon = None;
770    let mut image_icon = None;
771    let mut extension_tags = Vec::new();
772    for tag in rumor.tags() {
773        match tag.first().map(String::as_str) {
774            Some("p") => participants.push(parse_participant(tag, rumor.public_key())?),
775            Some("subject") if tag.len() == 2 && subject.is_none() => {
776                subject = Some(tag[1].clone());
777            }
778            Some("subject") => return Err(SoftchatError::InvalidSoftchatEvent),
779            Some("emoji") => {
780                validate_emoji_tag(tag)?;
781                emoji_tags.push(tag.to_vec());
782            }
783            Some("imeta") if imeta_icon.is_none() => {
784                let metadata = AttachmentMetadata::from_tag(tag)?;
785                imeta_icon = Some((metadata.url, metadata.dimensions.unwrap_or_default()));
786            }
787            Some("image") if image_icon.is_none() && (tag.len() == 2 || tag.len() == 3) => {
788                validate_https(&tag[1])?;
789                let dimensions = tag.get(2).cloned().unwrap_or_default();
790                if !dimensions.is_empty() {
791                    validate_dimensions(&dimensions)?;
792                }
793                image_icon = Some((tag[1].clone(), dimensions));
794            }
795            Some("image" | "imeta") => return Err(SoftchatError::InvalidSoftchatEvent),
796            _ => extension_tags.push(tag.to_vec()),
797        }
798    }
799    let participants = validate_read_participants(participants)?;
800    let subject = subject.ok_or(SoftchatError::InvalidSoftchatEvent)?;
801    if imeta_icon.is_some() && image_icon.is_some() && imeta_icon != image_icon {
802        return Err(SoftchatError::InvalidSoftchatEvent);
803    }
804    let (icon_url, icon_dimensions) = imeta_icon.or(image_icon).unwrap_or_default();
805    Ok(SubjectView {
806        rumor: RumorEvent::from(rumor),
807        participants: participants
808            .into_iter()
809            .map(|public_key| public_key.to_hex())
810            .collect(),
811        subject,
812        icon_url,
813        icon_dimensions,
814        emoji_tags,
815        extension_tags,
816    })
817}
818
819fn parse_reaction(rumor: &NostrRumor) -> Result<ReactionView, SoftchatError> {
820    if rumor.kind() != NostrEventKind::REACTION
821        || rumor.content().is_empty()
822        || rumor.content().chars().count() > 64
823    {
824        return Err(SoftchatError::InvalidSoftchatEvent);
825    }
826    let event_id = exactly_one_tag_value(rumor, "e")?;
827    let mut event_authors = rumor
828        .tags()
829        .filter(|tag| tag.first().map(String::as_str) == Some("p"))
830        .map(|tag| {
831            NostrPublicKey::from_hex(tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?)
832                .map(|key| key.to_hex())
833                .map_err(|_| SoftchatError::InvalidSoftchatEvent)
834        })
835        .collect::<Result<Vec<_>, _>>()?;
836    event_authors.sort();
837    event_authors.dedup();
838    if event_authors.is_empty() {
839        return Err(SoftchatError::InvalidSoftchatEvent);
840    }
841    let event_kind = exactly_one_tag_value(rumor, "k")?;
842    NostrEventId::from_hex(&event_id).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
843    let event_kind = event_kind
844        .parse::<u32>()
845        .ok()
846        .and_then(|kind| NostrEventKind::try_from_u32(kind).ok())
847        .ok_or(SoftchatError::InvalidSoftchatEvent)?;
848    let emoji_tags = rumor
849        .tags()
850        .filter(|tag| tag.first().map(String::as_str) == Some("emoji"))
851        .collect::<Vec<_>>();
852    let custom_emoji_url = match emoji_tags.as_slice() {
853        [] => String::new(),
854        [tag] => {
855            validate_emoji_tag(tag)?;
856            let expected = format!(":{}:", tag[1]);
857            if rumor.content() != expected {
858                return Err(SoftchatError::InvalidSoftchatEvent);
859            }
860            tag[2].clone()
861        }
862        _ => return Err(SoftchatError::InvalidSoftchatEvent),
863    };
864    Ok(ReactionView {
865        rumor: RumorEvent::from(rumor),
866        event_id,
867        event_authors,
868        event_kind: i32::from(event_kind.as_u16()),
869        reaction: rumor.content().to_owned(),
870        custom_emoji_url,
871    })
872}
873
874fn parse_deletion(rumor: &NostrRumor) -> Result<DeletionView, SoftchatError> {
875    if rumor.kind() != NostrEventKind::EVENT_DELETION_REQUEST {
876        return Err(SoftchatError::InvalidSoftchatEvent);
877    }
878    let mut ids = Vec::new();
879    let mut unique = BTreeSet::new();
880    for tag in rumor.tags() {
881        if tag.first().map(String::as_str) == Some("e") {
882            let value = tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?;
883            let id =
884                NostrEventId::from_hex(value).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
885            if !unique.insert(id) {
886                return Err(SoftchatError::InvalidSoftchatEvent);
887            }
888            ids.push(id.to_hex());
889        }
890    }
891    if ids.is_empty() {
892        return Err(SoftchatError::InvalidSoftchatEvent);
893    }
894    Ok(DeletionView {
895        rumor: RumorEvent::from(rumor),
896        event_ids: ids,
897        reason: rumor.content().to_owned(),
898    })
899}
900
901fn parse_edit(rumor: &NostrRumor) -> Result<EditView, SoftchatError> {
902    if rumor.kind() != NostrEventKind::UPDATED_CONTENT {
903        return Err(SoftchatError::InvalidSoftchatEvent);
904    }
905    let mut participants = Vec::new();
906    let mut original_event_id = None;
907    let mut emoji_tags = Vec::new();
908    let mut compatibility_tags = Vec::new();
909    for tag in rumor.tags() {
910        match tag.first().map(String::as_str) {
911            Some("p") => participants.push(parse_participant(tag, rumor.public_key())?),
912            Some("e") if original_event_id.is_none() => {
913                original_event_id = Some(
914                    NostrEventId::from_hex(tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?)
915                        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?,
916                );
917            }
918            Some("e") => return Err(SoftchatError::InvalidSoftchatEvent),
919            Some("emoji") => {
920                validate_emoji_tag(tag)?;
921                emoji_tags.push(tag.to_vec());
922            }
923            _ => compatibility_tags.push(tag.to_vec()),
924        }
925    }
926    let participants = if participants.is_empty() {
927        Vec::new()
928    } else {
929        validate_read_participants(participants)?
930    };
931    Ok(EditView {
932        rumor: RumorEvent::from(rumor),
933        original_event_id: original_event_id
934            .ok_or(SoftchatError::InvalidSoftchatEvent)?
935            .to_hex(),
936        participants: participants
937            .into_iter()
938            .map(|public_key| public_key.to_hex())
939            .collect(),
940        content: rumor.content().to_owned(),
941        emoji_tags,
942        compatibility_tags,
943    })
944}
945
946fn parse_typing(rumor: &NostrRumor) -> Result<TypingView, SoftchatError> {
947    if rumor.kind() != NostrEventKind::TYPING || !rumor.content().is_empty() {
948        return Err(SoftchatError::InvalidSoftchatEvent);
949    }
950    let mut participants = Vec::new();
951    for tag in rumor.tags() {
952        if tag.first().map(String::as_str) != Some("p") {
953            return Err(SoftchatError::InvalidSoftchatEvent);
954        }
955        participants.push(parse_participant(tag, rumor.public_key())?);
956    }
957    Ok(TypingView {
958        rumor: RumorEvent::from(rumor),
959        participants: validate_read_participants(participants)?
960            .into_iter()
961            .map(|public_key| public_key.to_hex())
962            .collect(),
963    })
964}
965
966fn parse_generic_repost(repost: &SignedNostrEvent) -> Result<GenericRepostView, SoftchatError> {
967    if repost.kind() != NostrEventKind::GENERIC_REPOST || repost.content().is_empty() {
968        return Err(SoftchatError::InvalidSoftchatEvent);
969    }
970    let embedded = SignedNostrEvent::from_json(repost.content())
971        .map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
972    let event_id = exactly_one_signed_tag_value(repost, "e")?;
973    let author = exactly_one_signed_tag_value(repost, "p")?;
974    let kind = exactly_one_signed_tag_value(repost, "k")?;
975    if event_id != embedded.id().to_hex()
976        || author != embedded.public_key().to_hex()
977        || kind != embedded.kind().as_u16().to_string()
978    {
979        return Err(SoftchatError::InvalidSoftchatEvent);
980    }
981    Ok(GenericRepostView {
982        repost: SignedEvent::from(repost),
983        embedded_event: SignedEvent::from(&embedded),
984    })
985}
986
987fn canonical_participants(
988    author: NostrPublicKey,
989    participants: Vec<NostrPublicKey>,
990) -> Result<Vec<NostrPublicKey>, SoftchatError> {
991    if participants.is_empty() || participants.len() > MAX_CHAT_PARTICIPANTS {
992        return Err(SoftchatError::SoftchatEventCreationFailed);
993    }
994    let mut participants = participants
995        .into_iter()
996        .filter(|participant| participant != &author)
997        .collect::<Vec<_>>();
998    participants.sort_by_key(NostrPublicKey::to_hex);
999    let original_len = participants.len();
1000    participants.dedup();
1001    if participants.is_empty() || participants.len() != original_len {
1002        return Err(SoftchatError::SoftchatEventCreationFailed);
1003    }
1004    Ok(participants)
1005}
1006
1007fn validate_read_participants(
1008    mut participants: Vec<NostrPublicKey>,
1009) -> Result<Vec<NostrPublicKey>, SoftchatError> {
1010    if participants.is_empty() || participants.len() > MAX_CHAT_PARTICIPANTS {
1011        return Err(SoftchatError::InvalidSoftchatEvent);
1012    }
1013    participants.sort_by_key(NostrPublicKey::to_hex);
1014    let original_len = participants.len();
1015    participants.dedup();
1016    if participants.len() != original_len {
1017        return Err(SoftchatError::InvalidSoftchatEvent);
1018    }
1019    Ok(participants)
1020}
1021
1022fn participant_tags(participants: &[NostrPublicKey]) -> Result<Vec<NostrTag>, SoftchatError> {
1023    participants
1024        .iter()
1025        .map(|participant| NostrTag::new(vec!["p".to_owned(), participant.to_hex()]))
1026        .collect()
1027}
1028
1029fn parse_participant(
1030    tag: &[String],
1031    author: NostrPublicKey,
1032) -> Result<NostrPublicKey, SoftchatError> {
1033    let public_key =
1034        NostrPublicKey::from_hex(tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?)
1035            .map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
1036    if public_key == author {
1037        return Err(SoftchatError::InvalidSoftchatEvent);
1038    }
1039    Ok(public_key)
1040}
1041
1042fn flatten_relation(relation: &ChatRelation) -> (ChatRelationKind, String, String, String) {
1043    match relation {
1044        ChatRelation::None => (
1045            ChatRelationKind::None,
1046            String::new(),
1047            String::new(),
1048            String::new(),
1049        ),
1050        ChatRelation::Reply {
1051            event_id,
1052            relay_hint,
1053            legacy_unmarked,
1054        } => (
1055            if *legacy_unmarked {
1056                ChatRelationKind::LegacyReply
1057            } else {
1058                ChatRelationKind::Reply
1059            },
1060            event_id.to_hex(),
1061            relay_hint.clone().unwrap_or_default(),
1062            String::new(),
1063        ),
1064        ChatRelation::Forward {
1065            event_id,
1066            relay_hint,
1067            original_author,
1068        } => (
1069            ChatRelationKind::Forward,
1070            event_id.to_hex(),
1071            relay_hint.clone().unwrap_or_default(),
1072            original_author.to_hex(),
1073        ),
1074    }
1075}
1076
1077fn optional_relay_hint(value: &str) -> Result<Option<String>, SoftchatError> {
1078    if value.is_empty() {
1079        return Ok(None);
1080    }
1081    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
1082    if !matches!(url.scheme(), "ws" | "wss") || url.host_str().is_none() || url.fragment().is_some()
1083    {
1084        return Err(SoftchatError::InvalidSoftchatEvent);
1085    }
1086    Ok(Some(value.to_owned()))
1087}
1088
1089fn validate_writer_tags(
1090    tags: &[NostrTag],
1091    expected_name: &str,
1092    maximum: usize,
1093) -> Result<(), SoftchatError> {
1094    if tags.len() > maximum
1095        || tags
1096            .iter()
1097            .any(|tag| tag.values().first().map(String::as_str) != Some(expected_name))
1098    {
1099        return Err(SoftchatError::SoftchatEventCreationFailed);
1100    }
1101    if expected_name == "emoji" {
1102        for tag in tags {
1103            validate_emoji_tag(tag.values())?;
1104        }
1105    }
1106    Ok(())
1107}
1108
1109fn validate_extension_tags(tags: &[NostrTag]) -> Result<(), SoftchatError> {
1110    if tags.len() > MAX_CHAT_EXTENSION_TAGS
1111        || tags.iter().any(|tag| {
1112            matches!(
1113                tag.values().first().map(String::as_str),
1114                Some("p" | "e" | "q" | "subject" | "imeta" | "image" | "emoji")
1115            )
1116        })
1117    {
1118        return Err(SoftchatError::SoftchatEventCreationFailed);
1119    }
1120    Ok(())
1121}
1122
1123fn validate_emoji_tag(tag: &[String]) -> Result<(), SoftchatError> {
1124    if tag.len() != 3 || tag[1].is_empty() || tag[1].contains(':') {
1125        return Err(SoftchatError::InvalidSoftchatEvent);
1126    }
1127    validate_https(&tag[2])
1128}
1129
1130fn validate_https(value: &str) -> Result<(), SoftchatError> {
1131    let url = Url::parse(value).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
1132    if url.scheme() != "https" || url.host_str().is_none() || url.fragment().is_some() {
1133        return Err(SoftchatError::InvalidSoftchatEvent);
1134    }
1135    Ok(())
1136}
1137
1138fn validate_dimensions(value: &str) -> Result<(), SoftchatError> {
1139    let (width, height) = value
1140        .split_once('x')
1141        .ok_or(SoftchatError::InvalidSoftchatEvent)?;
1142    if width
1143        .parse::<u32>()
1144        .ok()
1145        .filter(|value| *value > 0)
1146        .is_none()
1147        || height
1148            .parse::<u32>()
1149            .ok()
1150            .filter(|value| *value > 0)
1151            .is_none()
1152    {
1153        return Err(SoftchatError::InvalidSoftchatEvent);
1154    }
1155    Ok(())
1156}
1157
1158fn exactly_one_tag_value(rumor: &NostrRumor, name: &str) -> Result<String, SoftchatError> {
1159    exactly_one_value(rumor.tags(), name)
1160}
1161
1162fn exactly_one_signed_tag_value(
1163    event: &SignedNostrEvent,
1164    name: &str,
1165) -> Result<String, SoftchatError> {
1166    exactly_one_value(event.tags(), name)
1167}
1168
1169fn exactly_one_value<'a>(
1170    tags: impl Iterator<Item = &'a [String]>,
1171    name: &str,
1172) -> Result<String, SoftchatError> {
1173    let values = tags
1174        .filter(|tag| tag.first().map(String::as_str) == Some(name))
1175        .map(|tag| {
1176            tag.get(1)
1177                .cloned()
1178                .ok_or(SoftchatError::InvalidSoftchatEvent)
1179        })
1180        .collect::<Result<Vec<_>, _>>()?;
1181    match values.as_slice() {
1182        [value] => Ok(value.clone()),
1183        _ => Err(SoftchatError::InvalidSoftchatEvent),
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use std::collections::BTreeMap;
1190
1191    use serde::Deserialize;
1192
1193    use super::*;
1194
1195    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1196    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
1197    const ANDROID_SHAPES: &str =
1198        include_str!("../../../fixtures/softchat/released/android-event-shapes.json");
1199    const IOS_SHAPES: &str =
1200        include_str!("../../../fixtures/softchat/released/ios-event-shapes.json");
1201
1202    #[derive(Deserialize)]
1203    struct Shape {
1204        kind: u16,
1205        content: String,
1206        tags: Vec<Vec<String>>,
1207    }
1208
1209    #[derive(Deserialize)]
1210    struct ReleasedShapes {
1211        author: String,
1212        shapes: BTreeMap<String, Shape>,
1213    }
1214
1215    fn identity_pair() -> Result<(LocalIdentity, LocalIdentity), SoftchatError> {
1216        Ok((
1217            LocalIdentity::from_secret_hex(ALICE_SECRET)?,
1218            LocalIdentity::from_secret_hex(BOB_SECRET)?,
1219        ))
1220    }
1221
1222    fn rumor_from_shape(
1223        fixture: &str,
1224        name: &str,
1225    ) -> Result<NostrRumor, Box<dyn std::error::Error>> {
1226        let fixture: ReleasedShapes = serde_json::from_str(fixture)?;
1227        let shape = fixture
1228            .shapes
1229            .get(name)
1230            .ok_or(SoftchatError::InvalidSoftchatEvent)?;
1231        let (alice, bob) = identity_pair()?;
1232        let carol = LocalIdentity::from_secret_hex(
1233            "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
1234        )?;
1235        let replacements = BTreeMap::from([
1236            (fixture.author.clone(), alice.public_key().to_hex()),
1237            (
1238                "2886780f7349afc1344047524540ee716f7bdc1b64191699855662330bf235d8".to_owned(),
1239                bob.public_key().to_hex(),
1240            ),
1241            (
1242                "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788".to_owned(),
1243                carol.public_key().to_hex(),
1244            ),
1245        ]);
1246        let tags = shape
1247            .tags
1248            .iter()
1249            .cloned()
1250            .map(|tag| {
1251                tag.into_iter()
1252                    .map(|value| replacements.get(&value).cloned().unwrap_or(value))
1253                    .collect::<Vec<_>>()
1254            })
1255            .map(NostrTag::new)
1256            .collect::<Result<Vec<_>, _>>()?;
1257        Ok(NostrRumor::new(
1258            alice.public_key(),
1259            NostrEventDraft::new(
1260                1_700_000_000,
1261                NostrEventKind::from_u16(shape.kind),
1262                tags,
1263                shape.content.clone(),
1264            )?,
1265        )?)
1266    }
1267
1268    #[test]
1269    fn creates_group_reply_and_sender_copy_inputs_in_canonical_order() -> Result<(), SoftchatError>
1270    {
1271        let (alice, bob) = identity_pair()?;
1272        let carol = LocalIdentity::from_secret_hex(
1273            "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
1274        )?;
1275        let parent = NostrEventId::from_hex(
1276            "44900586091b284416a0c001f677f9c49f7639a55c3f1e2ec130a8e1a7998e1b",
1277        )?;
1278        let view = alice.create_chat_message(ChatMessageDraft {
1279            created_at: 1,
1280            participants: vec![carol.public_key(), bob.public_key()],
1281            content: "reply".to_owned(),
1282            relation: ChatRelation::Reply {
1283                event_id: parent,
1284                relay_hint: Some("wss://relay.example".to_owned()),
1285                legacy_unmarked: false,
1286            },
1287            attachments: Vec::new(),
1288            emoji_tags: Vec::new(),
1289            extension_tags: Vec::new(),
1290        })?;
1291        assert_eq!(view.participants.len(), 2);
1292        assert_eq!(view.relation_kind, ChatRelationKind::Reply);
1293        assert_eq!(view.rumor.tags[2][3], "reply");
1294        Ok(())
1295    }
1296
1297    #[test]
1298    fn reads_released_android_and_ios_subject_reply_forward_and_edit_shapes()
1299    -> Result<(), Box<dyn std::error::Error>> {
1300        let android_subject = parse_subject(&rumor_from_shape(ANDROID_SHAPES, "subject")?)?;
1301        let ios_subject = parse_subject(&rumor_from_shape(IOS_SHAPES, "subject")?)?;
1302        assert_eq!(android_subject.subject, ios_subject.subject);
1303        assert_eq!(android_subject.icon_url, ios_subject.icon_url);
1304
1305        let android_reply = parse_chat_message(&rumor_from_shape(ANDROID_SHAPES, "reply")?)?;
1306        let ios_reply = parse_chat_message(&rumor_from_shape(IOS_SHAPES, "reply")?)?;
1307        assert_eq!(android_reply.relation_kind, ChatRelationKind::LegacyReply);
1308        assert_eq!(ios_reply.relation_kind, ChatRelationKind::Reply);
1309        assert_eq!(android_reply.related_event_id, ios_reply.related_event_id);
1310
1311        let android_forward = parse_chat_message(&rumor_from_shape(ANDROID_SHAPES, "forward")?)?;
1312        let ios_forward = parse_chat_message(&rumor_from_shape(IOS_SHAPES, "forward")?)?;
1313        assert_eq!(android_forward.relation_kind, ChatRelationKind::Forward);
1314        assert_eq!(
1315            android_forward.related_event_id,
1316            ios_forward.related_event_id
1317        );
1318        assert_eq!(android_forward.original_author, ios_forward.original_author);
1319
1320        let android_edit = parse_edit(&rumor_from_shape(ANDROID_SHAPES, "edit")?)?;
1321        let ios_edit = parse_edit(&rumor_from_shape(IOS_SHAPES, "edit")?)?;
1322        assert_eq!(android_edit.content, ios_edit.content);
1323        assert!(!android_edit.compatibility_tags.is_empty());
1324        assert!(ios_edit.compatibility_tags.is_empty());
1325        Ok(())
1326    }
1327
1328    #[test]
1329    fn reaction_deletion_typing_and_projection_enforce_authorization() -> Result<(), SoftchatError>
1330    {
1331        let (alice, bob) = identity_pair()?;
1332        let original = alice.create_chat_message(ChatMessageDraft {
1333            created_at: 1,
1334            participants: vec![bob.public_key()],
1335            content: "before".to_owned(),
1336            relation: ChatRelation::None,
1337            attachments: Vec::new(),
1338            emoji_tags: Vec::new(),
1339            extension_tags: Vec::new(),
1340        })?;
1341        let original_id = NostrEventId::from_hex(&original.rumor.id)?;
1342        let edit = alice.create_edit(
1343            vec![bob.public_key()],
1344            original_id,
1345            "after".to_owned(),
1346            Vec::new(),
1347            2,
1348        )?;
1349        let projected =
1350            project_chat_message(original.rumor.clone(), vec![edit.rumor.clone()], Vec::new())?;
1351        assert_eq!(projected.content, "after");
1352        assert!(!projected.applied_edit_id.is_empty());
1353
1354        let delete =
1355            alice.create_deletion(vec![bob.public_key()], vec![original_id], String::new(), 3)?;
1356        assert!(
1357            project_chat_message(original.rumor, vec![edit.rumor], vec![delete.rumor])?.deleted
1358        );
1359
1360        let reaction = bob.create_reaction(ReactionDraft {
1361            participants: vec![alice.public_key()],
1362            parent_id: original_id,
1363            parent_author: alice.public_key(),
1364            parent_kind: NostrEventKind::PRIVATE_DIRECT_MESSAGE,
1365            reaction: "👍".to_owned(),
1366            custom_emoji_url: None,
1367            created_at: 2,
1368        })?;
1369        assert_eq!(reaction.reaction, "👍");
1370        assert_eq!(
1371            alice.create_typing(vec![bob.public_key()], 4)?.participants,
1372            vec![bob.public_key().to_hex()]
1373        );
1374        Ok(())
1375    }
1376
1377    #[test]
1378    fn generic_repost_requires_verified_matching_embedded_event() -> Result<(), SoftchatError> {
1379        let (alice, _) = identity_pair()?;
1380        let embedded = alice.sign_event(NostrEventDraft::new(
1381            1,
1382            NostrEventKind::SHORT_TEXT_NOTE,
1383            Vec::new(),
1384            "embedded",
1385        )?)?;
1386        let repost = alice.sign_event(NostrEventDraft::new(
1387            2,
1388            NostrEventKind::GENERIC_REPOST,
1389            vec![
1390                NostrTag::new(vec!["e".to_owned(), embedded.id().to_hex()])?,
1391                NostrTag::new(vec!["p".to_owned(), embedded.public_key().to_hex()])?,
1392                NostrTag::new(vec!["k".to_owned(), embedded.kind().as_u16().to_string()])?,
1393            ],
1394            embedded.to_json()?,
1395        )?)?;
1396        assert_eq!(
1397            parse_generic_repost(&repost)?.embedded_event.id,
1398            embedded.id().to_hex()
1399        );
1400        Ok(())
1401    }
1402}