1use 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
14pub const MAX_CHAT_PARTICIPANTS: usize = 256;
16pub const MAX_CHAT_EXTENSION_TAGS: usize = 256;
18pub const MAX_CHAT_EMOJI_TAGS: usize = 128;
20pub const MAX_CHAT_ATTACHMENTS: usize = 32;
22
23#[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 None,
30 Reply,
32 LegacyReply,
34 Forward,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
40pub enum ChatRelation {
41 None,
43 Reply {
45 event_id: NostrEventId,
47 relay_hint: Option<String>,
49 legacy_unmarked: bool,
51 },
52 Forward {
54 event_id: NostrEventId,
56 relay_hint: Option<String>,
58 original_author: NostrPublicKey,
60 },
61}
62
63#[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 pub kind: ChatRelationKind,
70 pub event_id: String,
72 pub relay_hint: String,
74 pub original_author: String,
76}
77
78impl ChatRelationInput {
79 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#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct ChatMessageDraft {
116 pub created_at: u64,
118 pub participants: Vec<NostrPublicKey>,
120 pub content: String,
122 pub relation: ChatRelation,
124 pub attachments: Vec<AttachmentMetadata>,
126 pub emoji_tags: Vec<NostrTag>,
128 pub extension_tags: Vec<NostrTag>,
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
134#[serde(rename_all = "camelCase")]
135#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
136pub struct ChatMessageView {
137 pub rumor: RumorEvent,
139 pub participants: Vec<String>,
141 pub relation_kind: ChatRelationKind,
143 pub related_event_id: String,
145 pub relay_hint: String,
147 pub original_author: String,
149 pub attachments: Vec<AttachmentMetadata>,
151 pub emoji_tags: Vec<Vec<String>>,
153 pub extension_tags: Vec<Vec<String>>,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct SubjectDraft {
160 pub created_at: u64,
162 pub participants: Vec<NostrPublicKey>,
164 pub subject: String,
166 pub emoji_tags: Vec<NostrTag>,
168 pub icon: Option<AttachmentMetadata>,
170 pub extension_tags: Vec<NostrTag>,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
176#[serde(rename_all = "camelCase")]
177#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
178pub struct SubjectView {
179 pub rumor: RumorEvent,
181 pub participants: Vec<String>,
183 pub subject: String,
185 pub icon_url: String,
187 pub icon_dimensions: String,
189 pub emoji_tags: Vec<Vec<String>>,
191 pub extension_tags: Vec<Vec<String>>,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct ReactionDraft {
198 pub participants: Vec<NostrPublicKey>,
200 pub parent_id: NostrEventId,
202 pub parent_author: NostrPublicKey,
204 pub parent_kind: NostrEventKind,
206 pub reaction: String,
208 pub custom_emoji_url: Option<String>,
210 pub created_at: u64,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
216#[serde(rename_all = "camelCase")]
217#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
218pub struct ReactionView {
219 pub rumor: RumorEvent,
221 pub event_id: String,
223 pub event_authors: Vec<String>,
225 pub event_kind: i32,
227 pub reaction: String,
229 pub custom_emoji_url: String,
231}
232
233#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
235#[serde(rename_all = "camelCase")]
236#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
237pub struct DeletionView {
238 pub rumor: RumorEvent,
240 pub event_ids: Vec<String>,
242 pub reason: String,
244}
245
246#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
248#[serde(rename_all = "camelCase")]
249#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
250pub struct EditView {
251 pub rumor: RumorEvent,
253 pub original_event_id: String,
255 pub participants: Vec<String>,
257 pub content: String,
259 pub emoji_tags: Vec<Vec<String>>,
261 pub compatibility_tags: Vec<Vec<String>>,
263}
264
265#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
267#[serde(rename_all = "camelCase")]
268#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
269pub struct TypingView {
270 pub rumor: RumorEvent,
272 pub participants: Vec<String>,
274}
275
276#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
278#[serde(rename_all = "camelCase")]
279#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
280pub struct GenericRepostView {
281 pub repost: SignedEvent,
283 pub embedded_event: SignedEvent,
285}
286
287#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
289#[serde(rename_all = "camelCase")]
290#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
291pub struct MessageProjection {
292 pub original_event_id: String,
294 pub deleted: bool,
296 pub content: String,
298 pub emoji_tags: Vec<Vec<String>>,
300 pub applied_edit_id: String,
302}
303
304impl LocalIdentity {
305 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 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 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 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 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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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}