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