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