1use std::collections::{BTreeMap, BTreeSet};
8
9use base64::Engine;
10use base64::engine::general_purpose::STANDARD;
11use rusqlite::{
12 OptionalExtension, TransactionBehavior, params, params_from_iter, types::Value as SqlValue,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value, json};
16use sha2::{Digest, Sha256};
17use url::Url;
18
19use crate::account_diagnostics::{AccountLogCategory, Diagnostic};
20use crate::chat::subject_icon_metadata_from_validated;
21use crate::event::RumorEvent;
22use crate::storage::{bump_revision, map_sqlite_error};
23use crate::{
24 AccountDatabase, AccountMutationResult, AttachmentMetadata, ChatRelationKind, NostrEventId,
25 NostrPublicKey, OperationSnapshot, ProjectionKind, SoftchatError, classify_chat_message,
26 classify_edit, classify_follow_list, classify_reaction, classify_subject,
27 classify_user_metadata, conversation_id,
28};
29
30pub const MAX_PRODUCT_QUERY_PAGE: u32 = 100;
34pub const MAX_PRODUCT_PROFILE_LOOKUPS: usize = 512;
36pub const MAX_PRODUCT_MESSAGE_LOOKUPS: usize = 512;
38pub const MAX_PRODUCT_MEDIA_OPERATION_LOOKUPS: usize = 512;
40pub const MAX_PRODUCT_MESSAGE_BYTES: usize = crate::MAX_NIP44_WRITER_PLAINTEXT_BYTES;
46pub const MAX_PRODUCT_SEARCH_BYTES: usize = 512;
48pub const MAX_PRODUCT_DRAFT_SPANS: usize = 256;
50pub const MAX_PRODUCT_DRAFT_ATTACHMENTS: usize = crate::MAX_CHAT_ATTACHMENTS;
52pub const MAX_PRODUCT_ATTACHMENT_TAG_BYTES: usize = 2 * 1024;
57pub const MAX_PRODUCT_TRANSCRIPT_BYTES: usize = 64 * 1024;
59pub const MAX_PRODUCT_WAVEFORM_SAMPLES: usize = 8_192;
61pub const MAX_ACCOUNT_SETTINGS_BYTES: usize = 64 * 1024;
63pub const ACCOUNT_SETTINGS_SCHEMA_VERSION: u32 = 1;
65pub const ACCOUNT_READ_STATE_SCHEMA_VERSION: u32 = 1;
67pub const MAX_ACCOUNT_READ_STATE_BYTES: usize = 256 * 1024;
69pub const MAX_ACCOUNT_READ_STATE_ENTRIES: usize = 4_096;
71
72const SWIFT_REFERENCE_DATE_UNIX_SECONDS: i64 = 978_307_200;
73
74#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
78pub struct MessageCursor {
79 pub created_at: i64,
81 pub message_id: String,
83}
84
85#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
89pub struct AccountReadState {
90 pub conversation_id: String,
92 pub boundary: Option<MessageCursor>,
94 pub forced_unread: bool,
96 pub updated_at: i64,
98 pub update_id: String,
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
106pub struct ConversationCursor {
107 pub pinned: bool,
109 pub last_activity_at: i64,
111 pub conversation_id: String,
113}
114
115#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(rename_all = "camelCase", deny_unknown_fields)]
118#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
119pub struct MessageContentInput {
120 pub text: String,
122 pub attachments: Vec<AttachmentMetadata>,
124 pub emoji_tags: Vec<Vec<String>>,
126}
127
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub(crate) struct ProfileUpdateInput {
132 pub name: Option<String>,
134 pub display_name: Option<String>,
136 pub about: Option<String>,
138 pub picture: Option<String>,
140 pub website: Option<String>,
142 pub banner: Option<String>,
144 pub bot: Option<bool>,
146 pub unknown_json: String,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
154pub struct MessageReactionInput {
155 pub value: String,
157 pub custom_emoji_url: Option<String>,
159}
160
161#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
163#[serde(rename_all = "camelCase")]
164#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
165pub enum PushPlatform {
166 Fcm,
168 Apns,
170}
171
172impl MessageContentInput {
173 pub(crate) fn validate(&self) -> Result<(), SoftchatError> {
174 self.validate_bounds()?;
175 if self.text.is_empty() && self.attachments.is_empty() {
176 return Err(SoftchatError::InvalidAccountOperation);
177 }
178 Ok(())
179 }
180
181 pub(crate) fn validate_edit(&self) -> Result<(), SoftchatError> {
182 self.validate_bounds()?;
183 if !self.attachments.is_empty() {
184 return Err(SoftchatError::InvalidAccountOperation);
185 }
186 Ok(())
187 }
188
189 pub(crate) fn validate_bounds(&self) -> Result<(), SoftchatError> {
190 if self.text.len() > MAX_PRODUCT_MESSAGE_BYTES {
191 return Err(SoftchatError::InvalidAccountOperation);
192 }
193 if self.attachments.len() > MAX_PRODUCT_DRAFT_ATTACHMENTS
194 || self.emoji_tags.len() > crate::MAX_CHAT_EMOJI_TAGS
195 {
196 return Err(SoftchatError::InvalidAccountOperation);
197 }
198 for attachment in &self.attachments {
199 validate_product_attachment_wire_size(attachment)?;
200 }
201 Ok(())
202 }
203}
204
205pub(crate) fn reserved_product_attachments(
206 count: usize,
207) -> Result<Vec<AttachmentMetadata>, SoftchatError> {
208 if count > MAX_PRODUCT_DRAFT_ATTACHMENTS {
209 return Err(SoftchatError::InvalidMediaOperation);
210 }
211 let mut reservation = AttachmentMetadata {
212 url: "https://attachment.invalid/".to_owned(),
213 unknown_fields: vec!["reserved x".to_owned()],
214 ..AttachmentMetadata::default()
215 };
216 let current = attachment_tag_json_bytes(&reservation)?;
217 let padding = MAX_PRODUCT_ATTACHMENT_TAG_BYTES
218 .checked_sub(current)
219 .ok_or(SoftchatError::InternalFailure)?;
220 reservation.unknown_fields[0].push_str(&"x".repeat(padding));
221 if attachment_tag_json_bytes(&reservation)? != MAX_PRODUCT_ATTACHMENT_TAG_BYTES {
222 return Err(SoftchatError::InternalFailure);
223 }
224 Ok(vec![reservation; count])
225}
226
227fn validate_product_attachment_wire_size(
228 attachment: &AttachmentMetadata,
229) -> Result<(), SoftchatError> {
230 if attachment_tag_json_bytes(attachment)? > MAX_PRODUCT_ATTACHMENT_TAG_BYTES {
231 return Err(SoftchatError::InvalidAccountOperation);
232 }
233 Ok(())
234}
235
236fn attachment_tag_json_bytes(attachment: &AttachmentMetadata) -> Result<usize, SoftchatError> {
237 let tag = attachment
238 .to_tag()
239 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
240 serde_json::to_string(tag.values())
241 .map(|json| json.len())
242 .map_err(|_| SoftchatError::InternalFailure)
243}
244
245#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
247#[serde(rename_all = "camelCase", deny_unknown_fields)]
248#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
249pub struct MessageReactionView {
250 pub id: String,
252 pub author_public_key: String,
254 pub value: String,
256 pub custom_emoji_url: String,
258 pub created_at: i64,
260}
261
262#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
264#[serde(rename_all = "camelCase", deny_unknown_fields)]
265#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
266pub struct AccountMessageView {
267 pub id: String,
269 pub conversation_id: String,
271 pub author_public_key: String,
273 pub created_at: i64,
275 pub text: String,
277 pub attachments: Vec<AttachmentMetadata>,
279 pub emoji_tags: Vec<Vec<String>>,
281 pub relation: ChatRelationKind,
283 pub related_message_id: String,
285 pub forwarded_author_public_key: String,
287 pub edited: bool,
289 pub deleted: bool,
291 pub reply_count: u32,
293 pub reactions: Vec<MessageReactionView>,
296}
297
298#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
300#[serde(rename_all = "camelCase", deny_unknown_fields)]
301#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
302pub struct MessagePage {
303 pub messages: Vec<AccountMessageView>,
305 pub next_cursor: Option<MessageCursor>,
307}
308
309#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
311#[serde(rename_all = "camelCase")]
312#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
313pub enum DraftSpanKind {
314 Bold,
316 Italic,
318 Code,
320 Link,
322 Mention,
324 CustomEmoji,
326}
327
328#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
330#[serde(rename_all = "camelCase", deny_unknown_fields)]
331#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
332pub struct DraftSpan {
333 pub start_utf16: u32,
335 pub end_utf16: u32,
337 pub kind: DraftSpanKind,
339 pub value: String,
341}
342
343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
345#[serde(rename_all = "camelCase", deny_unknown_fields)]
346#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
347pub struct AccountDraft {
348 pub text: String,
350 pub emoji_tags: Vec<Vec<String>>,
352 pub spans: Vec<DraftSpan>,
354 pub reply_to_message_id: String,
356 pub attachment_operation_ids: Vec<String>,
358 pub updated_at: i64,
360}
361
362#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
364#[serde(rename_all = "camelCase", deny_unknown_fields)]
365#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
366pub struct MessageLocalExtras {
367 pub message_id: String,
369 pub transcript: Option<String>,
371 pub waveform: Vec<u8>,
373 pub updated_at: i64,
375}
376
377#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
381pub struct ConversationRecord {
382 pub id: String,
384 pub member_public_keys: Vec<String>,
386 pub subject: String,
388 pub subject_icon: Option<AttachmentMetadata>,
390 pub last_message: Option<AccountMessageView>,
392 pub message_count: u32,
394 pub unread_count: u32,
396 pub archived: bool,
398 pub pinned: bool,
400 pub read_cursor: Option<MessageCursor>,
402 pub forced_unread: bool,
404 pub draft: Option<AccountDraft>,
406 pub last_activity_at: i64,
408}
409
410#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
412#[serde(rename_all = "camelCase", deny_unknown_fields)]
413#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
414pub struct ConversationPage {
415 pub conversations: Vec<ConversationRecord>,
417 pub next_cursor: Option<ConversationCursor>,
419}
420
421#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
423#[serde(rename_all = "camelCase", deny_unknown_fields)]
424#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
425pub struct AccountProfile {
426 pub public_key: String,
428 pub name: Option<String>,
430 pub display_name: Option<String>,
432 pub about: Option<String>,
434 pub picture: Option<String>,
436 pub website: Option<String>,
438 pub banner: Option<String>,
440 pub bot: Option<bool>,
442 pub unknown_json: String,
444 pub local_override: bool,
446 pub updated_at: i64,
448 pub first_seen_at: Option<i64>,
450}
451
452#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454#[serde(rename_all = "camelCase", deny_unknown_fields)]
455#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
456pub struct CustomEmojiReference {
457 pub shortcode: String,
459 pub url: String,
461}
462
463#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
465#[serde(rename_all = "camelCase", deny_unknown_fields)]
466#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
467pub struct AccountSettings {
468 pub schema_version: u32,
470 pub quick_reaction: Option<String>,
472 pub typing_indicators_enabled: bool,
474 pub notification_content_privacy: String,
476 pub media_service: Option<String>,
478 pub image_upload_quality: String,
480 pub video_upload_quality: String,
482 pub preserve_hdr: bool,
484 pub transcription_locale: Option<String>,
486 pub domain_replacement_rules_json: String,
488 pub chat_wallpaper: Option<String>,
490 pub custom_emoji_references: Vec<CustomEmojiReference>,
492 pub saved_sticker_references: Vec<String>,
494 pub seasonal_appearance: Option<String>,
496 pub unknown_json: String,
498}
499
500#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
504pub struct AccountSettingsMutation {
505 pub settings: AccountSettings,
507 pub canonical_json: String,
509 pub revision: i64,
511}
512
513#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
515#[serde(rename_all = "camelCase")]
516#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
517pub enum AccountMediaOperationState {
518 Prepared,
520 Running,
522 Completed,
524 Retryable,
526 Failed,
528 Cancelled,
530}
531
532#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
534#[serde(rename_all = "camelCase")]
535#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
536pub enum AccountMediaProtection {
537 Encrypted,
539 Public,
541}
542
543#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
545#[serde(rename_all = "camelCase", deny_unknown_fields)]
546#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
547pub struct AccountMediaOperation {
548 pub id: String,
550 pub command_id: String,
552 pub conversation_id: String,
554 pub source_message_id: String,
556 pub direction: String,
558 pub protection: AccountMediaProtection,
560 pub state: AccountMediaOperationState,
562 pub source_fingerprint: String,
564 pub attachment: Option<AttachmentMetadata>,
566 pub byte_count: Option<u64>,
568 pub attempt_count: u32,
570 pub last_error_category: String,
572 pub updated_at: i64,
574}
575
576#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
578#[serde(rename_all = "camelCase", deny_unknown_fields)]
579#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
580pub struct AccountMediaOperationPage {
581 pub operations: Vec<AccountMediaOperation>,
583 pub next_cursor: Option<String>,
585}
586
587#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
589#[serde(rename_all = "camelCase", deny_unknown_fields)]
590#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
591pub struct AccountMediaLease {
592 pub lease_id: String,
594 pub expires_at: i64,
596 pub operation: AccountMediaOperation,
598}
599
600#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
602#[serde(rename_all = "camelCase")]
603#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
604pub enum PendingMediaMessageState {
605 WaitingForUploads,
607 ReadyToPublish,
609 Published,
611 Failed,
613 Cancelled,
615}
616
617#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
619#[serde(rename_all = "camelCase", deny_unknown_fields)]
620#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
621pub struct MediaPreparationInput {
622 pub source_fingerprint: String,
624}
625
626pub(crate) struct ProductMediaMessagePreparation<'a> {
627 pub(crate) command_id: &'a str,
628 pub(crate) conversation_id: &'a str,
629 pub(crate) content: &'a MessageContentInput,
630 pub(crate) reply_to_message_id: &'a str,
631 pub(crate) sources: Vec<MediaPreparationInput>,
632 pub(crate) command_hash: &'a str,
633 pub(crate) draft_match: Option<ProductDraftMatch>,
634 pub(crate) now: i64,
635}
636
637#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
638pub(crate) struct ProductDraftMatch {
639 pub(crate) draft_json: String,
640 pub(crate) reply_to_message_id: String,
641}
642
643struct ProductMediaOperationPreparation<'a> {
644 command_id: &'a str,
645 conversation_id: Option<String>,
646 source_message_id: Option<&'a str>,
647 kind: ProductMediaOperationKind,
648 direction: &'a str,
649 protection: AccountMediaProtection,
650 source_fingerprint: &'a str,
651 attachment: Option<AttachmentMetadata>,
652 now: i64,
653}
654
655#[derive(Clone, Copy, Debug, Eq, PartialEq)]
656enum ProductMediaOperationKind {
657 Transfer,
658 ConversationIcon,
659}
660
661impl ProductMediaOperationKind {
662 const fn as_str(self) -> &'static str {
663 match self {
664 Self::Transfer => "transfer",
665 Self::ConversationIcon => "conversation_icon",
666 }
667 }
668
669 fn from_str(value: &str) -> Result<Self, SoftchatError> {
670 match value {
671 "transfer" => Ok(Self::Transfer),
672 "conversation_icon" => Ok(Self::ConversationIcon),
673 _ => Err(SoftchatError::InvalidPersistenceResult),
674 }
675 }
676}
677
678pub(crate) struct ProductAssetReplacementPreparation<'a> {
679 pub(crate) command_id: &'a str,
680 pub(crate) target: AssetReplacementTarget,
681 pub(crate) conversation_id: &'a str,
682 pub(crate) source_fingerprint: &'a str,
683 pub(crate) command_hash: &'a str,
684 pub(crate) now: i64,
685}
686
687#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
689#[serde(rename_all = "camelCase", deny_unknown_fields)]
690#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
691pub struct PendingMediaMessage {
692 pub command_id: String,
694 pub conversation_id: String,
696 pub text: String,
698 pub emoji_tags: Vec<Vec<String>>,
700 pub reply_to_message_id: String,
702 pub state: PendingMediaMessageState,
704 pub attachments: Vec<AccountMediaOperation>,
706 pub result_message_id: String,
708 pub updated_at: i64,
710}
711
712#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
714#[serde(rename_all = "camelCase")]
715#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
716pub enum AssetReplacementTarget {
717 ProfilePicture,
719 ProfileBanner,
721 ConversationIcon,
723}
724
725#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
727#[serde(rename_all = "camelCase")]
728#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
729pub enum PendingAssetReplacementState {
730 WaitingForUpload,
732 ReadyToPublish,
734 Published,
736 Failed,
738 Cancelled,
740}
741
742#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
744#[serde(rename_all = "camelCase", deny_unknown_fields)]
745#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
746pub struct PendingAssetReplacement {
747 pub command_id: String,
749 pub target: AssetReplacementTarget,
751 pub conversation_id: String,
753 pub state: PendingAssetReplacementState,
755 pub operation: AccountMediaOperation,
757 pub result_operation_id: String,
759 pub updated_at: i64,
761}
762
763#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
765#[serde(rename_all = "camelCase", deny_unknown_fields)]
766#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
767pub struct MediaCompletionResult {
768 pub operation: AccountMediaOperation,
770 pub completed_operation: Option<ProductOperationResult>,
772}
773
774#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
776#[serde(rename_all = "camelCase", deny_unknown_fields)]
777#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
778pub struct AccountMediaItem {
779 pub message_id: String,
781 pub conversation_id: String,
783 pub author_public_key: String,
785 pub created_at: i64,
787 pub attachment: AttachmentMetadata,
789}
790
791#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
793#[serde(rename_all = "camelCase", deny_unknown_fields)]
794#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
795pub struct ProductOperationResult {
796 pub operation: OperationSnapshot,
798 pub revision: i64,
800 pub inserted: bool,
802 pub message: Option<AccountMessageView>,
804}
805
806#[derive(Clone, Debug, Deserialize, Serialize)]
807#[serde(rename_all = "camelCase", deny_unknown_fields)]
808struct StoredDraft {
809 text: String,
810 emoji_tags: Vec<Vec<String>>,
811 #[serde(default)]
812 spans: Vec<DraftSpan>,
813 #[serde(default)]
814 attachment_operation_ids: Vec<String>,
815}
816
817#[derive(Clone, Debug)]
818struct ProductMessageRow {
819 id: String,
820 conversation_id: String,
821 author_public_key: String,
822 created_at: i64,
823 rumor_json: String,
824 deleted: bool,
825 edit_id: Option<String>,
826 edit_rumor_json: Option<String>,
827 reply_count: u32,
828}
829
830enum ProductMessageQuery<'a> {
831 ConversationPage {
832 conversation_id: &'a str,
833 reply_to: Option<&'a str>,
834 cursor: Option<&'a MessageCursor>,
835 limit: u32,
836 },
837 Ids(&'a [String]),
838}
839
840#[derive(Clone, Debug)]
841struct ProductConversationRow {
842 id: String,
843 archived: bool,
844 pinned: bool,
845 read_at: Option<i64>,
846 read_message_id: String,
847 forced_unread: bool,
848 draft_json: Option<String>,
849 draft_reply_to: Option<String>,
850 draft_updated_at: Option<i64>,
851 member_public_keys: Vec<String>,
852 subject_rumor_json: Option<String>,
853 subject_icon_event_id: Option<String>,
854 subject_icon_json: Option<String>,
855 last_message_id: Option<String>,
856 message_count: u32,
857 unread_count: u32,
858 last_activity_at: i64,
859}
860
861#[derive(Clone, Debug)]
862struct RawProductConversationRow {
863 id: String,
864 archived: bool,
865 pinned: bool,
866 read_at: Option<i64>,
867 read_message_id: String,
868 forced_unread: bool,
869 draft_json: Option<String>,
870 draft_reply_to: Option<String>,
871 draft_updated_at: Option<i64>,
872 member_public_keys_csv: Option<String>,
873 subject_rumor_json: Option<String>,
874 subject_icon_event_id: Option<String>,
875 subject_icon_json: Option<String>,
876 last_message_id: Option<String>,
877 message_count: u32,
878 unread_count: u32,
879 last_activity_at: i64,
880}
881
882#[derive(Clone, Debug, Deserialize, Serialize)]
883#[serde(rename_all = "camelCase")]
884struct StoredReadStateSnapshot {
885 #[serde(default = "default_read_state_schema")]
886 schema_version: u32,
887 chats: Vec<StoredReadStateEntry>,
888 #[serde(flatten)]
889 unknown: BTreeMap<String, Value>,
890}
891
892#[derive(Clone, Debug, Deserialize, Serialize)]
893#[serde(rename_all = "camelCase")]
894struct StoredReadStateEntry {
895 chat_key: String,
896 #[serde(default)]
897 conversation_id: String,
898 #[serde(default)]
899 read_at: Option<Option<f64>>,
900 #[serde(default)]
901 read_at_unix: Option<i64>,
902 #[serde(default)]
903 read_message_id: String,
904 #[serde(default)]
905 forced_unread: Option<bool>,
906 #[serde(default)]
907 updated_at: Option<i64>,
908 #[serde(default)]
909 update_id: String,
910 #[serde(flatten)]
911 unknown: BTreeMap<String, Value>,
912}
913
914#[derive(Clone, Debug)]
915pub(crate) struct ReadStateMutationPreview {
916 pub(crate) state: AccountReadState,
917 pub(crate) canonical_json: String,
918}
919
920#[derive(Clone, Debug)]
921pub(crate) struct ParsedReadStateSnapshot {
922 pub(crate) entries: Vec<ParsedReadStateEntry>,
923 pub(crate) unknown_json: String,
924}
925
926#[derive(Clone, Debug)]
927pub(crate) struct ParsedReadStateEntry {
928 pub(crate) state: AccountReadState,
929 pub(crate) unknown_json: String,
930}
931
932#[derive(Clone, Debug)]
933struct StoredReadStateRow {
934 state: AccountReadState,
935 unknown_json: String,
936}
937
938struct ProductSubject {
939 icon_event_id: String,
940 text: String,
941 icon: Option<AttachmentMetadata>,
942 emoji_tags: Vec<Vec<String>>,
943}
944
945fn classify_product_subject(rumor: RumorEvent) -> Result<ProductSubject, SoftchatError> {
946 let event_id = rumor.id.clone();
947 let parsed = classify_subject(rumor.clone())?;
948 let icon = subject_icon_metadata_from_validated(&rumor, &parsed)?;
949 Ok(ProductSubject {
950 icon_event_id: if icon.is_some() {
951 event_id
952 } else {
953 String::new()
954 },
955 text: parsed.subject,
956 icon,
957 emoji_tags: parsed.emoji_tags,
958 })
959}
960
961pub(crate) fn conversation_icon_source_fingerprint(
962 conversation_id: &str,
963 subject_event_id: &str,
964 attachment: &AttachmentMetadata,
965) -> Result<String, SoftchatError> {
966 command_hash(&(
967 "conversation_icon_download",
968 conversation_id,
969 subject_event_id,
970 attachment,
971 ))
972}
973
974fn has_conversation_icon_integrity(metadata: &AttachmentMetadata) -> bool {
975 metadata.encryption.is_some() || metadata.sha256.is_some()
976}
977
978fn parse_stored_subject_icon(
979 event_id: Option<String>,
980 attachment_json: Option<String>,
981) -> Result<Option<(String, AttachmentMetadata)>, SoftchatError> {
982 match (event_id, attachment_json) {
983 (None, None) => Ok(None),
984 (Some(event_id), Some(attachment_json)) => {
985 NostrEventId::from_hex(&event_id)
986 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
987 let attachment = serde_json::from_str::<AttachmentMetadata>(&attachment_json)
988 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
989 attachment
990 .to_tag()
991 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
992 Ok(Some((event_id, attachment)))
993 }
994 _ => Err(SoftchatError::InvalidPersistenceResult),
995 }
996}
997
998impl AccountDatabase {
999 pub(crate) fn get_or_create_product_conversation(
1000 &mut self,
1001 own_public_key: &str,
1002 member_public_keys: Vec<String>,
1003 now: i64,
1004 ) -> Result<ConversationRecord, SoftchatError> {
1005 validate_timestamp(now)?;
1006 let members = canonical_members(own_public_key, member_public_keys)?;
1007 let id = conversation_id(members.clone())?;
1008 let transaction = self
1009 .connection
1010 .transaction_with_behavior(TransactionBehavior::Immediate)
1011 .map_err(map_sqlite_error)?;
1012 let inserted = transaction
1013 .execute(
1014 "INSERT OR IGNORE INTO conversation_state
1015 (conversation_id, created_at, updated_at)
1016 VALUES (?1, ?2, ?2)",
1017 params![id, now],
1018 )
1019 .map_err(map_sqlite_error)?
1020 != 0;
1021 for member in &members {
1022 transaction
1023 .execute(
1024 "INSERT OR IGNORE INTO conversation_members
1025 (conversation_id, public_key) VALUES (?1, ?2)",
1026 params![id, member],
1027 )
1028 .map_err(map_sqlite_error)?;
1029 }
1030 if inserted {
1031 bump_revision(&transaction)?;
1032 }
1033 transaction.commit().map_err(map_sqlite_error)?;
1034 self.product_conversation(&id, own_public_key)?
1035 .ok_or(SoftchatError::InvalidPersistenceResult)
1036 }
1037
1038 pub(crate) fn product_conversation(
1039 &self,
1040 conversation_id: &str,
1041 own_public_key: &str,
1042 ) -> Result<Option<ConversationRecord>, SoftchatError> {
1043 validate_conversation_id(conversation_id)?;
1044 NostrPublicKey::from_hex(own_public_key)?;
1045 let state = self
1046 .connection
1047 .query_row(
1048 "SELECT cs.archived, cs.pinned, rs.read_at,
1049 COALESCE(rs.read_message_id, ''),
1050 COALESCE(rs.forced_unread, 0),
1051 cs.draft_json, cs.draft_reply_to, cs.draft_updated_at,
1052 cs.created_at, cs.updated_at
1053 FROM conversation_state cs
1054 LEFT JOIN conversation_read_state rs
1055 ON rs.conversation_id = cs.conversation_id
1056 WHERE cs.conversation_id = ?1",
1057 [conversation_id],
1058 |row| {
1059 Ok((
1060 row.get::<_, i64>(0)? != 0,
1061 row.get::<_, i64>(1)? != 0,
1062 row.get::<_, Option<i64>>(2)?,
1063 row.get::<_, String>(3)?,
1064 row.get::<_, i64>(4)? != 0,
1065 row.get::<_, Option<String>>(5)?,
1066 row.get::<_, Option<String>>(6)?,
1067 row.get::<_, Option<i64>>(7)?,
1068 row.get::<_, i64>(8)?,
1069 row.get::<_, i64>(9)?,
1070 ))
1071 },
1072 )
1073 .optional()
1074 .map_err(map_sqlite_error)?;
1075 let Some((
1076 archived,
1077 pinned,
1078 read_at,
1079 read_message_id,
1080 forced_unread,
1081 draft_json,
1082 draft_reply_to,
1083 draft_updated_at,
1084 created_at,
1085 updated_at,
1086 )) = state
1087 else {
1088 return Ok(None);
1089 };
1090 let members = self.product_conversation_members(conversation_id)?;
1091 let last_message_id = self
1092 .connection
1093 .query_row(
1094 "SELECT p.logical_event_id
1095 FROM projections p
1096 WHERE p.conversation_id = ?1 AND p.kind = 'chat_message'
1097 AND p.ephemeral = 0
1098 AND NOT EXISTS (
1099 SELECT 1 FROM projection_targets dt
1100 JOIN projections d ON d.logical_event_id = dt.logical_event_id
1101 WHERE dt.target_event_id = p.logical_event_id
1102 AND d.kind = 'deletion'
1103 AND d.author_public_key = p.author_public_key
1104 )
1105 ORDER BY p.created_at DESC, p.logical_event_id ASC LIMIT 1",
1106 [conversation_id],
1107 |row| row.get::<_, String>(0),
1108 )
1109 .optional()
1110 .map_err(map_sqlite_error)?;
1111 let last_message = last_message_id
1112 .as_deref()
1113 .map(|id| self.product_message(id))
1114 .transpose()?
1115 .flatten();
1116 let message_count = self
1117 .connection
1118 .query_row(
1119 "SELECT COUNT(*)
1120 FROM projections p
1121 WHERE p.conversation_id = ?1 AND p.kind = 'chat_message'
1122 AND p.ephemeral = 0
1123 AND NOT EXISTS (
1124 SELECT 1 FROM projection_targets dt
1125 JOIN projections d ON d.logical_event_id = dt.logical_event_id
1126 WHERE dt.target_event_id = p.logical_event_id
1127 AND d.kind = 'deletion'
1128 AND d.author_public_key = p.author_public_key
1129 )",
1130 [conversation_id],
1131 |row| row.get::<_, u32>(0),
1132 )
1133 .map_err(map_sqlite_error)?;
1134 let unread_count = self
1135 .connection
1136 .query_row(
1137 "SELECT COUNT(*)
1138 FROM projections p
1139 WHERE p.conversation_id = ?1 AND p.kind = 'chat_message'
1140 AND p.ephemeral = 0 AND p.author_public_key <> ?2
1141 AND (
1142 ?3 = 1
1143 OR ?4 IS NULL
1144 OR p.created_at > ?4
1145 OR (
1146 p.created_at = ?4 AND ?5 <> ''
1147 AND p.logical_event_id < ?5
1148 )
1149 )
1150 AND NOT EXISTS (
1151 SELECT 1 FROM projection_targets dt
1152 JOIN projections d ON d.logical_event_id = dt.logical_event_id
1153 WHERE dt.target_event_id = p.logical_event_id
1154 AND d.kind = 'deletion'
1155 AND d.author_public_key = p.author_public_key
1156 )",
1157 params![
1158 conversation_id,
1159 own_public_key,
1160 i64::from(forced_unread),
1161 read_at,
1162 read_message_id
1163 ],
1164 |row| row.get::<_, u32>(0),
1165 )
1166 .map_err(map_sqlite_error)?;
1167 let subject = self.product_subject(conversation_id)?;
1168 let draft = match (draft_json, draft_updated_at) {
1169 (Some(value), Some(updated_at)) => {
1170 let stored: StoredDraft = serde_json::from_str(&value)
1171 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
1172 Some(AccountDraft {
1173 text: stored.text,
1174 emoji_tags: stored.emoji_tags,
1175 spans: stored.spans,
1176 reply_to_message_id: draft_reply_to.unwrap_or_default(),
1177 attachment_operation_ids: stored.attachment_operation_ids,
1178 updated_at,
1179 })
1180 }
1181 (None, None) => None,
1182 _ => return Err(SoftchatError::InvalidPersistenceResult),
1183 };
1184 let last_activity_at = last_message
1185 .as_ref()
1186 .map_or(updated_at.max(created_at), |message| message.created_at);
1187 let read_cursor = match read_at {
1188 Some(created_at) if !read_message_id.is_empty() => Some(MessageCursor {
1189 created_at,
1190 message_id: read_message_id,
1191 }),
1192 Some(created_at) => self
1193 .connection
1194 .query_row(
1195 "SELECT logical_event_id
1196 FROM projections
1197 WHERE conversation_id = ?1 AND kind = 'chat_message'
1198 AND created_at = ?2
1199 ORDER BY logical_event_id ASC LIMIT 1",
1200 params![conversation_id, created_at],
1201 |row| row.get::<_, String>(0),
1202 )
1203 .optional()
1204 .map_err(map_sqlite_error)?
1205 .map(|message_id| MessageCursor {
1206 created_at,
1207 message_id,
1208 }),
1209 None => None,
1210 };
1211 Ok(Some(ConversationRecord {
1212 id: conversation_id.to_owned(),
1213 member_public_keys: members,
1214 subject: subject.text,
1215 subject_icon: subject.icon,
1216 last_message,
1217 message_count,
1218 unread_count,
1219 archived,
1220 pinned,
1221 read_cursor,
1222 forced_unread,
1223 draft,
1224 last_activity_at,
1225 }))
1226 }
1227
1228 pub(crate) fn product_conversations(
1229 &self,
1230 own_public_key: &str,
1231 archived: bool,
1232 cursor: Option<ConversationCursor>,
1233 limit: u32,
1234 ) -> Result<ConversationPage, SoftchatError> {
1235 validate_limit(limit)?;
1236 NostrPublicKey::from_hex(own_public_key)?;
1237 if let Some(value) = &cursor {
1238 validate_conversation_id(&value.conversation_id)?;
1239 validate_timestamp(value.last_activity_at)?;
1240 }
1241 let fetch_limit = limit
1242 .checked_add(1)
1243 .ok_or(SoftchatError::InvalidAccountOperation)?;
1244 let (cursor_pinned, cursor_activity, cursor_id, has_cursor) = cursor
1245 .map(|value| {
1246 (
1247 i64::from(value.pinned),
1248 value.last_activity_at,
1249 value.conversation_id,
1250 1_i64,
1251 )
1252 })
1253 .unwrap_or((0, 0, String::new(), 0));
1254 let mut statement = self
1255 .connection
1256 .prepare(
1257 "WITH live_messages AS (
1258 SELECT p.*
1259 FROM projections p
1260 WHERE p.kind = 'chat_message' AND p.ephemeral = 0
1261 AND NOT EXISTS (
1262 SELECT 1 FROM projection_targets dt
1263 JOIN projections deletion
1264 ON deletion.logical_event_id = dt.logical_event_id
1265 WHERE dt.target_event_id = p.logical_event_id
1266 AND deletion.kind = 'deletion'
1267 AND deletion.author_public_key = p.author_public_key
1268 )
1269 )
1270 , message_stats AS (
1271 SELECT
1272 message.conversation_id,
1273 MAX(message.created_at) AS last_activity,
1274 COUNT(*) AS message_count,
1275 SUM(
1276 CASE
1277 WHEN message.author_public_key <> ?1
1278 AND (
1279 COALESCE(read_state.forced_unread, 0) = 1
1280 OR read_state.read_at IS NULL
1281 OR message.created_at > read_state.read_at
1282 OR (
1283 message.created_at = read_state.read_at
1284 AND COALESCE(read_state.read_message_id, '') <> ''
1285 AND message.logical_event_id
1286 < read_state.read_message_id
1287 )
1288 )
1289 THEN 1 ELSE 0
1290 END
1291 ) AS unread_count
1292 FROM live_messages message
1293 LEFT JOIN conversation_read_state read_state
1294 ON read_state.conversation_id = message.conversation_id
1295 GROUP BY message.conversation_id
1296 )
1297 , ranked_messages AS (
1298 SELECT
1299 message.conversation_id,
1300 message.logical_event_id,
1301 ROW_NUMBER() OVER (
1302 PARTITION BY message.conversation_id
1303 ORDER BY message.created_at DESC,
1304 message.logical_event_id ASC
1305 ) AS row_number
1306 FROM live_messages message
1307 )
1308 SELECT
1309 cs.conversation_id,
1310 cs.archived,
1311 cs.pinned,
1312 read_state.read_at,
1313 CASE
1314 WHEN read_state.read_at IS NOT NULL
1315 AND COALESCE(read_state.read_message_id, '') = ''
1316 THEN COALESCE(
1317 (
1318 SELECT boundary.logical_event_id
1319 FROM projections boundary
1320 WHERE boundary.conversation_id = cs.conversation_id
1321 AND boundary.kind = 'chat_message'
1322 AND boundary.created_at = read_state.read_at
1323 ORDER BY boundary.logical_event_id ASC
1324 LIMIT 1
1325 ),
1326 ''
1327 )
1328 ELSE COALESCE(read_state.read_message_id, '')
1329 END,
1330 COALESCE(read_state.forced_unread, 0),
1331 cs.draft_json,
1332 cs.draft_reply_to,
1333 cs.draft_updated_at,
1334 (
1335 SELECT group_concat(
1336 member.public_key,
1337 ',' ORDER BY member.public_key ASC
1338 )
1339 FROM conversation_members member
1340 WHERE member.conversation_id = cs.conversation_id
1341 ),
1342 (
1343 SELECT COALESCE(subject_rumor.canonical_json, '')
1344 FROM projections subject
1345 LEFT JOIN authenticated_rumors subject_rumor
1346 ON subject_rumor.rumor_id = subject.logical_event_id
1347 WHERE subject.conversation_id = cs.conversation_id
1348 AND subject.kind = 'subject'
1349 ORDER BY subject.created_at DESC,
1350 subject.logical_event_id ASC
1351 LIMIT 1
1352 ),
1353 subject_icon.subject_event_id,
1354 subject_icon.attachment_json,
1355 latest_message.logical_event_id,
1356 COALESCE(message_stats.message_count, 0),
1357 COALESCE(message_stats.unread_count, 0),
1358 COALESCE(
1359 message_stats.last_activity,
1360 MAX(cs.updated_at, cs.created_at)
1361 ) AS activity
1362 FROM conversation_state cs
1363 LEFT JOIN conversation_read_state read_state
1364 ON read_state.conversation_id = cs.conversation_id
1365 LEFT JOIN message_stats
1366 ON message_stats.conversation_id = cs.conversation_id
1367 LEFT JOIN ranked_messages latest_message
1368 ON latest_message.conversation_id = cs.conversation_id
1369 AND latest_message.row_number = 1
1370 LEFT JOIN conversation_subject_icons subject_icon
1371 ON subject_icon.conversation_id = cs.conversation_id
1372 WHERE cs.archived = ?2
1373 AND (
1374 ?3 = 0
1375 OR cs.pinned < ?4
1376 OR (
1377 cs.pinned = ?4
1378 AND COALESCE(
1379 message_stats.last_activity,
1380 MAX(cs.updated_at, cs.created_at)
1381 ) < ?5
1382 )
1383 OR (
1384 cs.pinned = ?4
1385 AND COALESCE(
1386 message_stats.last_activity,
1387 MAX(cs.updated_at, cs.created_at)
1388 ) = ?5
1389 AND cs.conversation_id > ?6
1390 )
1391 )
1392 ORDER BY cs.pinned DESC, activity DESC, cs.conversation_id ASC
1393 LIMIT ?7",
1394 )
1395 .map_err(map_sqlite_error)?;
1396 let rows = statement
1397 .query_map(
1398 params![
1399 own_public_key,
1400 i64::from(archived),
1401 has_cursor,
1402 cursor_pinned,
1403 cursor_activity,
1404 cursor_id,
1405 fetch_limit
1406 ],
1407 |row| {
1408 Ok(RawProductConversationRow {
1409 id: row.get(0)?,
1410 archived: row.get::<_, i64>(1)? != 0,
1411 pinned: row.get::<_, i64>(2)? != 0,
1412 read_at: row.get(3)?,
1413 read_message_id: row.get(4)?,
1414 forced_unread: row.get::<_, i64>(5)? != 0,
1415 draft_json: row.get(6)?,
1416 draft_reply_to: row.get(7)?,
1417 draft_updated_at: row.get(8)?,
1418 member_public_keys_csv: row.get(9)?,
1419 subject_rumor_json: row.get(10)?,
1420 subject_icon_event_id: row.get(11)?,
1421 subject_icon_json: row.get(12)?,
1422 last_message_id: row.get(13)?,
1423 message_count: row.get(14)?,
1424 unread_count: row.get(15)?,
1425 last_activity_at: row.get(16)?,
1426 })
1427 },
1428 )
1429 .map_err(map_sqlite_error)?;
1430 let mut page_rows = Vec::new();
1431 for row in rows {
1432 let row = row.map_err(map_sqlite_error)?;
1433 page_rows.push(ProductConversationRow {
1434 id: row.id,
1435 archived: row.archived,
1436 pinned: row.pinned,
1437 read_at: row.read_at,
1438 read_message_id: row.read_message_id,
1439 forced_unread: row.forced_unread,
1440 draft_json: row.draft_json,
1441 draft_reply_to: row.draft_reply_to,
1442 draft_updated_at: row.draft_updated_at,
1443 member_public_keys: parse_public_key_csv(row.member_public_keys_csv)?,
1444 subject_rumor_json: row.subject_rumor_json,
1445 subject_icon_event_id: row.subject_icon_event_id,
1446 subject_icon_json: row.subject_icon_json,
1447 last_message_id: row.last_message_id,
1448 message_count: row.message_count,
1449 unread_count: row.unread_count,
1450 last_activity_at: row.last_activity_at,
1451 });
1452 }
1453 let has_more = page_rows.len()
1454 > usize::try_from(limit).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1455 page_rows
1456 .truncate(usize::try_from(limit).map_err(|_| SoftchatError::InvalidAccountOperation)?);
1457 let message_ids = page_rows
1458 .iter()
1459 .filter_map(|row| row.last_message_id.clone())
1460 .collect::<Vec<_>>();
1461 let messages = self
1462 .product_message_views(ProductMessageQuery::Ids(&message_ids))?
1463 .into_iter()
1464 .map(|message| (message.id.clone(), message))
1465 .collect::<BTreeMap<_, _>>();
1466 let mut conversations = Vec::with_capacity(page_rows.len());
1467 for row in page_rows {
1468 let subject = row
1469 .subject_rumor_json
1470 .map(|json| {
1471 serde_json::from_str::<RumorEvent>(&json)
1472 .map_err(|_| SoftchatError::InvalidPersistenceResult)
1473 .and_then(classify_product_subject)
1474 })
1475 .transpose()?;
1476 let subject_icon =
1477 parse_stored_subject_icon(row.subject_icon_event_id, row.subject_icon_json)?
1478 .map(|(_, attachment)| attachment);
1479 let draft = product_draft(row.draft_json, row.draft_reply_to, row.draft_updated_at)?;
1480 let read_cursor = row.read_at.map(|created_at| MessageCursor {
1481 created_at,
1482 message_id: row.read_message_id,
1483 });
1484 let last_message = row
1485 .last_message_id
1486 .as_ref()
1487 .map(|id| {
1488 messages
1489 .get(id)
1490 .cloned()
1491 .ok_or(SoftchatError::InvalidPersistenceResult)
1492 })
1493 .transpose()?;
1494 conversations.push(ConversationRecord {
1495 id: row.id,
1496 member_public_keys: row.member_public_keys,
1497 subject: subject.map_or_else(String::new, |value| value.text),
1498 subject_icon,
1499 last_message,
1500 message_count: row.message_count,
1501 unread_count: row.unread_count,
1502 archived: row.archived,
1503 pinned: row.pinned,
1504 read_cursor,
1505 forced_unread: row.forced_unread,
1506 draft,
1507 last_activity_at: row.last_activity_at,
1508 });
1509 }
1510 let next_cursor = if has_more {
1511 conversations.last().map(|value| ConversationCursor {
1512 pinned: value.pinned,
1513 last_activity_at: value.last_activity_at,
1514 conversation_id: value.id.clone(),
1515 })
1516 } else {
1517 None
1518 };
1519 Ok(ConversationPage {
1520 conversations,
1521 next_cursor,
1522 })
1523 }
1524
1525 pub(crate) fn product_conversation_count(&self, archived: bool) -> Result<u32, SoftchatError> {
1526 self.connection
1527 .query_row(
1528 "SELECT COUNT(*) FROM conversation_state WHERE archived = ?1",
1529 [i64::from(archived)],
1530 |row| row.get(0),
1531 )
1532 .map_err(map_sqlite_error)
1533 }
1534
1535 pub(crate) fn product_has_unread(
1536 &self,
1537 own_public_key: &str,
1538 include_archived: bool,
1539 ) -> Result<bool, SoftchatError> {
1540 NostrPublicKey::from_hex(own_public_key)?;
1541 self.connection
1542 .query_row(
1543 "SELECT EXISTS (
1544 SELECT 1
1545 FROM conversation_state cs
1546 LEFT JOIN conversation_read_state rs
1547 ON rs.conversation_id = cs.conversation_id
1548 JOIN projections p ON p.conversation_id = cs.conversation_id
1549 WHERE (?1 = 1 OR cs.archived = 0)
1550 AND p.kind = 'chat_message'
1551 AND p.ephemeral = 0
1552 AND p.author_public_key <> ?2
1553 AND (
1554 COALESCE(rs.forced_unread, 0) = 1
1555 OR rs.read_at IS NULL
1556 OR p.created_at > rs.read_at
1557 OR (
1558 p.created_at = rs.read_at
1559 AND rs.read_message_id <> ''
1560 AND p.logical_event_id < rs.read_message_id
1561 )
1562 )
1563 AND NOT EXISTS (
1564 SELECT 1
1565 FROM projection_targets dt
1566 JOIN projections d ON d.logical_event_id = dt.logical_event_id
1567 WHERE dt.target_event_id = p.logical_event_id
1568 AND d.kind = 'deletion'
1569 AND d.author_public_key = p.author_public_key
1570 )
1571 )",
1572 params![i64::from(include_archived), own_public_key],
1573 |row| row.get::<_, i64>(0).map(|value| value != 0),
1574 )
1575 .map_err(map_sqlite_error)
1576 }
1577
1578 pub(crate) fn search_product_conversations(
1579 &self,
1580 own_public_key: &str,
1581 query: &str,
1582 archived: Option<bool>,
1583 limit: u32,
1584 ) -> Result<Vec<ConversationRecord>, SoftchatError> {
1585 NostrPublicKey::from_hex(own_public_key)?;
1586 validate_limit(limit)?;
1587 let pattern = escaped_like_pattern(query)?;
1588 let archived = archived.map(i64::from);
1589 let mut statement = self
1590 .connection
1591 .prepare(
1592 r"SELECT cs.conversation_id
1593 FROM conversation_state cs
1594 WHERE (?1 IS NULL OR cs.archived = ?1)
1595 AND (
1596 lower(cs.conversation_id) LIKE ?2 ESCAPE '\'
1597 OR lower((
1598 SELECT subject.content
1599 FROM projections subject
1600 WHERE subject.conversation_id = cs.conversation_id
1601 AND subject.kind = 'subject'
1602 ORDER BY subject.created_at DESC, subject.logical_event_id ASC
1603 LIMIT 1
1604 )) LIKE ?2 ESCAPE '\'
1605 OR EXISTS (
1606 SELECT 1
1607 FROM conversation_members member
1608 LEFT JOIN local_contacts local
1609 ON local.public_key = member.public_key
1610 WHERE member.conversation_id = cs.conversation_id
1611 AND (
1612 lower(member.public_key) LIKE ?2 ESCAPE '\'
1613 OR lower(COALESCE(local.name, '')) LIKE ?2 ESCAPE '\'
1614 OR EXISTS (
1615 SELECT 1 FROM json_tree((
1616 SELECT profile.content
1617 FROM projections profile
1618 WHERE profile.kind = 'user_metadata'
1619 AND profile.author_public_key = member.public_key
1620 ORDER BY profile.created_at DESC, profile.logical_event_id ASC
1621 LIMIT 1
1622 )) metadata
1623 WHERE metadata.type = 'text'
1624 AND lower(metadata.atom) LIKE ?2 ESCAPE '\'
1625 )
1626 )
1627 )
1628 )
1629 ORDER BY cs.pinned DESC, cs.updated_at DESC, cs.conversation_id ASC
1630 LIMIT ?3",
1631 )
1632 .map_err(map_sqlite_error)?;
1633 let rows = statement
1634 .query_map(params![archived, pattern, limit], |row| {
1635 row.get::<_, String>(0)
1636 })
1637 .map_err(map_sqlite_error)?;
1638 let mut conversations = Vec::new();
1639 for row in rows {
1640 let id = row.map_err(map_sqlite_error)?;
1641 conversations.push(
1642 self.product_conversation(&id, own_public_key)?
1643 .ok_or(SoftchatError::InvalidPersistenceResult)?,
1644 );
1645 }
1646 Ok(conversations)
1647 }
1648
1649 pub(crate) fn product_message(
1650 &self,
1651 message_id: &str,
1652 ) -> Result<Option<AccountMessageView>, SoftchatError> {
1653 NostrEventId::from_hex(message_id)?;
1654 let ids = vec![message_id.to_owned()];
1655 Ok(self
1656 .product_message_views(ProductMessageQuery::Ids(&ids))?
1657 .into_iter()
1658 .next())
1659 }
1660
1661 pub(crate) fn product_messages(
1662 &self,
1663 conversation_id: &str,
1664 cursor: Option<MessageCursor>,
1665 limit: u32,
1666 ) -> Result<MessagePage, SoftchatError> {
1667 self.product_messages_matching(conversation_id, None, cursor, limit)
1668 }
1669
1670 pub(crate) fn product_message_replies(
1671 &self,
1672 message_id: &str,
1673 cursor: Option<MessageCursor>,
1674 limit: u32,
1675 ) -> Result<MessagePage, SoftchatError> {
1676 validate_limit(limit)?;
1677 if let Some(value) = &cursor {
1678 validate_timestamp(value.created_at)?;
1679 NostrEventId::from_hex(&value.message_id)?;
1680 }
1681 let Some(root) = self.product_message(message_id)? else {
1682 return Ok(MessagePage {
1683 messages: Vec::new(),
1684 next_cursor: None,
1685 });
1686 };
1687 self.product_messages_matching(&root.conversation_id, Some(message_id), cursor, limit)
1688 }
1689
1690 fn product_messages_matching(
1691 &self,
1692 conversation_id: &str,
1693 reply_to: Option<&str>,
1694 cursor: Option<MessageCursor>,
1695 limit: u32,
1696 ) -> Result<MessagePage, SoftchatError> {
1697 validate_conversation_id(conversation_id)?;
1698 validate_limit(limit)?;
1699 if let Some(value) = &cursor {
1700 validate_timestamp(value.created_at)?;
1701 NostrEventId::from_hex(&value.message_id)?;
1702 }
1703 let fetch_limit = limit
1704 .checked_add(1)
1705 .ok_or(SoftchatError::InvalidAccountOperation)?;
1706 let mut messages = self.product_message_views(ProductMessageQuery::ConversationPage {
1707 conversation_id,
1708 reply_to,
1709 cursor: cursor.as_ref(),
1710 limit: fetch_limit,
1711 })?;
1712 let requested =
1713 usize::try_from(limit).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1714 let has_more = messages.len() > requested;
1715 messages.truncate(requested);
1716 let next_cursor = if has_more {
1717 messages.last().map(|message| MessageCursor {
1718 created_at: message.created_at,
1719 message_id: message.id.clone(),
1720 })
1721 } else {
1722 None
1723 };
1724 Ok(MessagePage {
1725 messages,
1726 next_cursor,
1727 })
1728 }
1729
1730 pub(crate) fn product_messages_by_ids(
1731 &self,
1732 message_ids: &[String],
1733 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
1734 if message_ids.len() > MAX_PRODUCT_MESSAGE_LOOKUPS {
1735 return Err(SoftchatError::InvalidAccountOperation);
1736 }
1737 let mut distinct = BTreeSet::new();
1738 let mut canonical_ids = Vec::with_capacity(message_ids.len());
1739 for message_id in message_ids {
1740 let parsed = NostrEventId::from_hex(message_id)?;
1741 if !distinct.insert(parsed) {
1742 return Err(SoftchatError::InvalidAccountOperation);
1743 }
1744 canonical_ids.push(parsed.to_hex());
1745 }
1746 let mut messages: BTreeMap<_, _> = self
1747 .product_message_views(ProductMessageQuery::Ids(&canonical_ids))?
1748 .into_iter()
1749 .map(|message| (message.id.clone(), message))
1750 .collect();
1751 Ok(canonical_ids
1752 .iter()
1753 .filter_map(|id| messages.remove(id))
1754 .collect())
1755 }
1756
1757 pub(crate) fn product_message_context(
1758 &self,
1759 message_id: &str,
1760 before: u32,
1761 after: u32,
1762 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
1763 if before > MAX_PRODUCT_QUERY_PAGE || after > MAX_PRODUCT_QUERY_PAGE {
1764 return Err(SoftchatError::InvalidAccountOperation);
1765 }
1766 let target = self
1767 .product_message(message_id)?
1768 .ok_or(SoftchatError::InvalidAccountOperation)?;
1769 let mut before_statement = self
1770 .connection
1771 .prepare(
1772 "SELECT logical_event_id FROM projections
1773 WHERE kind = 'chat_message' AND ephemeral = 0
1774 AND conversation_id = ?1
1775 AND (
1776 created_at > ?2
1777 OR (created_at = ?2 AND logical_event_id < ?3)
1778 )
1779 ORDER BY created_at ASC, logical_event_id DESC LIMIT ?4",
1780 )
1781 .map_err(map_sqlite_error)?;
1782 let before_rows = before_statement
1783 .query_map(
1784 params![target.conversation_id, target.created_at, target.id, before],
1785 |row| row.get::<_, String>(0),
1786 )
1787 .map_err(map_sqlite_error)?;
1788 let mut ids = Vec::new();
1789 for row in before_rows {
1790 ids.push(row.map_err(map_sqlite_error)?);
1791 }
1792 ids.reverse();
1793 ids.push(target.id.clone());
1794 let mut after_statement = self
1795 .connection
1796 .prepare(
1797 "SELECT logical_event_id FROM projections
1798 WHERE kind = 'chat_message' AND ephemeral = 0
1799 AND conversation_id = ?1
1800 AND (
1801 created_at < ?2
1802 OR (created_at = ?2 AND logical_event_id > ?3)
1803 )
1804 ORDER BY created_at DESC, logical_event_id ASC LIMIT ?4",
1805 )
1806 .map_err(map_sqlite_error)?;
1807 let after_rows = after_statement
1808 .query_map(
1809 params![target.conversation_id, target.created_at, target.id, after],
1810 |row| row.get::<_, String>(0),
1811 )
1812 .map_err(map_sqlite_error)?;
1813 for row in after_rows {
1814 ids.push(row.map_err(map_sqlite_error)?);
1815 }
1816 self.product_message_views(ProductMessageQuery::Ids(&ids))
1817 }
1818
1819 pub(crate) fn search_product_messages(
1820 &self,
1821 conversation_id: Option<String>,
1822 query: &str,
1823 limit: u32,
1824 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
1825 validate_limit(limit)?;
1826 if query.is_empty() || query.len() > MAX_PRODUCT_SEARCH_BYTES {
1827 return Err(SoftchatError::InvalidAccountOperation);
1828 }
1829 if let Some(value) = &conversation_id {
1830 validate_conversation_id(value)?;
1831 }
1832 let fts_query = escaped_fts_query(query)?;
1833 let mut statement = self
1834 .connection
1835 .prepare(
1836 "SELECT f.message_id
1837 FROM message_fts f
1838 JOIN projections p ON p.logical_event_id = f.message_id
1839 WHERE message_fts MATCH ?1
1840 AND (?2 IS NULL OR f.conversation_id = ?2)
1841 AND NOT EXISTS (
1842 SELECT 1 FROM projection_targets dt
1843 JOIN projections d ON d.logical_event_id = dt.logical_event_id
1844 WHERE dt.target_event_id = p.logical_event_id
1845 AND d.kind = 'deletion'
1846 AND d.author_public_key = p.author_public_key
1847 )
1848 ORDER BY p.created_at DESC, p.logical_event_id ASC LIMIT ?3",
1849 )
1850 .map_err(map_sqlite_error)?;
1851 let rows = statement
1852 .query_map(params![fts_query, conversation_id, limit], |row| {
1853 row.get::<_, String>(0)
1854 })
1855 .map_err(map_sqlite_error)?;
1856 let mut ids = Vec::new();
1857 for row in rows {
1858 ids.push(row.map_err(map_sqlite_error)?);
1859 }
1860 self.product_message_views(ProductMessageQuery::Ids(&ids))
1861 }
1862
1863 pub(crate) fn set_product_conversation_flag(
1864 &mut self,
1865 conversation_id: &str,
1866 column: &str,
1867 value: bool,
1868 now: i64,
1869 ) -> Result<AccountMutationResult, SoftchatError> {
1870 validate_conversation_id(conversation_id)?;
1871 validate_timestamp(now)?;
1872 let sql = match column {
1873 "archived" => {
1874 "UPDATE conversation_state
1875 SET archived = ?1, updated_at = MAX(updated_at, ?2)
1876 WHERE conversation_id = ?3"
1877 }
1878 "pinned" => {
1879 "UPDATE conversation_state
1880 SET pinned = ?1, updated_at = MAX(updated_at, ?2)
1881 WHERE conversation_id = ?3"
1882 }
1883 _ => return Err(SoftchatError::InvalidAccountOperation),
1884 };
1885 let diagnostic_changed = self.local_state_differs(
1886 "SELECT CASE ?2 WHEN 'archived' THEN archived ELSE pinned END != ?3
1887 FROM conversation_state WHERE conversation_id = ?1",
1888 params![conversation_id, column, i64::from(value)],
1889 );
1890 let result = self
1891 .mutate_existing_conversation(sql, params![i64::from(value), now, conversation_id])?;
1892 self.record_local_change(diagnostic_changed, result.revision);
1893 Ok(result)
1894 }
1895
1896 pub(crate) fn product_read_states(&self) -> Result<Vec<AccountReadState>, SoftchatError> {
1897 Ok(self
1898 .load_product_read_state_rows()?
1899 .into_iter()
1900 .map(|row| row.state)
1901 .collect())
1902 }
1903
1904 pub(crate) fn preview_product_read_state_update(
1905 &self,
1906 conversation_id: &str,
1907 boundary: Option<MessageCursor>,
1908 forced_unread: bool,
1909 now: i64,
1910 update_id: &str,
1911 ) -> Result<ReadStateMutationPreview, SoftchatError> {
1912 validate_conversation_id(conversation_id)?;
1913 validate_timestamp(now)?;
1914 validate_command_id(update_id)?;
1915 if self
1916 .connection
1917 .query_row(
1918 "SELECT 1 FROM conversation_state WHERE conversation_id = ?1",
1919 [conversation_id],
1920 |row| row.get::<_, i64>(0),
1921 )
1922 .optional()
1923 .map_err(map_sqlite_error)?
1924 .is_none()
1925 {
1926 return Err(SoftchatError::InvalidAccountOperation);
1927 }
1928 if let Some(value) = &boundary {
1929 validate_timestamp(value.created_at)?;
1930 NostrEventId::from_hex(&value.message_id)?;
1931 let belongs = self
1932 .connection
1933 .query_row(
1934 "SELECT 1 FROM projections
1935 WHERE logical_event_id = ?1 AND conversation_id = ?2
1936 AND kind = 'chat_message' AND created_at = ?3",
1937 params![value.message_id, conversation_id, value.created_at],
1938 |row| row.get::<_, i64>(0),
1939 )
1940 .optional()
1941 .map_err(map_sqlite_error)?
1942 .is_some();
1943 if !belongs {
1944 return Err(SoftchatError::InvalidAccountOperation);
1945 }
1946 }
1947
1948 let mut rows = self.load_product_read_state_rows()?;
1949 let existing = rows
1950 .iter()
1951 .find(|row| row.state.conversation_id == conversation_id);
1952 let updated_at = existing
1953 .map(|row| row.state.updated_at)
1954 .filter(|updated_at| *updated_at >= now)
1955 .map_or(Ok(now), |updated_at| {
1956 updated_at
1957 .checked_add(1)
1958 .ok_or(SoftchatError::InvalidAccountOperation)
1959 })?;
1960 let boundary = if forced_unread {
1961 existing.and_then(|row| row.state.boundary.clone())
1962 } else {
1963 Some(advance_read_boundary(
1964 existing.and_then(|row| row.state.boundary.clone()),
1965 boundary.ok_or(SoftchatError::InvalidAccountOperation)?,
1966 ))
1967 };
1968 let state = AccountReadState {
1969 conversation_id: conversation_id.to_owned(),
1970 boundary,
1971 forced_unread,
1972 updated_at,
1973 update_id: update_id.to_owned(),
1974 };
1975 if let Some(row) = rows
1976 .iter_mut()
1977 .find(|row| row.state.conversation_id == conversation_id)
1978 {
1979 row.state = state.clone();
1980 } else {
1981 rows.push(StoredReadStateRow {
1982 state: state.clone(),
1983 unknown_json: "{}".to_owned(),
1984 });
1985 }
1986 rows.sort_by(|left, right| left.state.conversation_id.cmp(&right.state.conversation_id));
1987 let canonical_json = self.product_read_state_wire_json(&rows)?;
1988 Ok(ReadStateMutationPreview {
1989 state,
1990 canonical_json,
1991 })
1992 }
1993
1994 pub(crate) fn save_product_draft(
1995 &mut self,
1996 conversation_id: &str,
1997 draft: Option<AccountDraft>,
1998 now: i64,
1999 ) -> Result<AccountMutationResult, SoftchatError> {
2000 validate_conversation_id(conversation_id)?;
2001 validate_timestamp(now)?;
2002 let (draft_json, reply_to, updated_at) = if let Some(value) = draft {
2003 validate_product_draft(&value)?;
2004 if !value.reply_to_message_id.is_empty() {
2005 NostrEventId::from_hex(&value.reply_to_message_id)?;
2006 }
2007 for operation_id in &value.attachment_operation_ids {
2008 let belongs = self
2009 .connection
2010 .query_row(
2011 "SELECT 1 FROM media_operations
2012 WHERE operation_id = ?1 AND conversation_id = ?2",
2013 params![operation_id, conversation_id],
2014 |row| row.get::<_, i64>(0),
2015 )
2016 .optional()
2017 .map_err(map_sqlite_error)?
2018 .is_some();
2019 if !belongs {
2020 return Err(SoftchatError::InvalidAccountOperation);
2021 }
2022 }
2023 let json = serde_json::to_string(&StoredDraft {
2024 text: value.text,
2025 emoji_tags: value.emoji_tags,
2026 spans: value.spans,
2027 attachment_operation_ids: value.attachment_operation_ids,
2028 })
2029 .map_err(|_| SoftchatError::InternalFailure)?;
2030 (Some(json), Some(value.reply_to_message_id), Some(now))
2031 } else {
2032 (None, None, None)
2033 };
2034 let diagnostic_changed = self.local_state_differs(
2035 "SELECT draft_json IS NOT ?2 OR draft_reply_to IS NOT ?3
2036 FROM conversation_state WHERE conversation_id = ?1",
2037 params![conversation_id, draft_json, reply_to],
2038 );
2039 let result = self.mutate_existing_conversation(
2040 "UPDATE conversation_state
2041 SET draft_json = ?1, draft_reply_to = ?2, draft_updated_at = ?3,
2042 updated_at = MAX(updated_at, ?4)
2043 WHERE conversation_id = ?5",
2044 params![draft_json, reply_to, updated_at, now, conversation_id],
2045 )?;
2046 self.record_local_change(diagnostic_changed, result.revision);
2047 Ok(result)
2048 }
2049
2050 pub(crate) fn product_message_local_extras(
2051 &self,
2052 message_id: &str,
2053 ) -> Result<Option<MessageLocalExtras>, SoftchatError> {
2054 NostrEventId::from_hex(message_id)?;
2055 self.connection
2056 .query_row(
2057 "SELECT transcript, waveform, updated_at
2058 FROM message_local_extras WHERE message_id = ?1",
2059 [message_id],
2060 |row| {
2061 Ok(MessageLocalExtras {
2062 message_id: message_id.to_owned(),
2063 transcript: row.get(0)?,
2064 waveform: row.get::<_, Option<Vec<u8>>>(1)?.unwrap_or_default(),
2065 updated_at: row.get(2)?,
2066 })
2067 },
2068 )
2069 .optional()
2070 .map_err(map_sqlite_error)
2071 }
2072
2073 pub(crate) fn set_product_message_local_extras(
2074 &mut self,
2075 message_id: &str,
2076 transcript: Option<String>,
2077 waveform: Vec<u8>,
2078 now: i64,
2079 ) -> Result<AccountMutationResult, SoftchatError> {
2080 NostrEventId::from_hex(message_id)?;
2081 validate_timestamp(now)?;
2082 if transcript
2083 .as_ref()
2084 .is_some_and(|value| value.len() > MAX_PRODUCT_TRANSCRIPT_BYTES)
2085 || waveform.len() > MAX_PRODUCT_WAVEFORM_SAMPLES
2086 {
2087 return Err(SoftchatError::InvalidAccountOperation);
2088 }
2089 let transaction = self
2090 .connection
2091 .transaction_with_behavior(TransactionBehavior::Immediate)
2092 .map_err(map_sqlite_error)?;
2093 let exists = transaction
2094 .query_row(
2095 "SELECT 1 FROM projections
2096 WHERE logical_event_id = ?1 AND kind = 'chat_message'",
2097 [message_id],
2098 |row| row.get::<_, i64>(0),
2099 )
2100 .optional()
2101 .map_err(map_sqlite_error)?
2102 .is_some();
2103 if !exists {
2104 return Err(SoftchatError::InvalidAccountOperation);
2105 }
2106 transaction
2107 .execute(
2108 "INSERT INTO message_local_extras
2109 (message_id, transcript, waveform, updated_at)
2110 VALUES (?1, ?2, ?3, ?4)
2111 ON CONFLICT(message_id) DO UPDATE SET
2112 transcript = excluded.transcript,
2113 waveform = excluded.waveform,
2114 updated_at = excluded.updated_at",
2115 params![message_id, transcript, waveform, now],
2116 )
2117 .map_err(map_sqlite_error)?;
2118 let revision = bump_revision(&transaction)?;
2119 transaction.commit().map_err(map_sqlite_error)?;
2120 Ok(AccountMutationResult { revision })
2121 }
2122
2123 fn load_product_read_state_rows(&self) -> Result<Vec<StoredReadStateRow>, SoftchatError> {
2124 let mut statement = self
2125 .connection
2126 .prepare(
2127 "SELECT conversation_id, read_at, read_message_id, forced_unread,
2128 updated_at, update_id, unknown_json
2129 FROM conversation_read_state
2130 ORDER BY conversation_id ASC",
2131 )
2132 .map_err(map_sqlite_error)?;
2133 let rows = statement
2134 .query_map([], |row| {
2135 Ok((
2136 row.get::<_, String>(0)?,
2137 row.get::<_, Option<i64>>(1)?,
2138 row.get::<_, String>(2)?,
2139 row.get::<_, i64>(3)? != 0,
2140 row.get::<_, i64>(4)?,
2141 row.get::<_, String>(5)?,
2142 row.get::<_, String>(6)?,
2143 ))
2144 })
2145 .map_err(map_sqlite_error)?;
2146 let mut states = Vec::new();
2147 for row in rows {
2148 let (
2149 conversation_id,
2150 read_at,
2151 read_message_id,
2152 forced_unread,
2153 updated_at,
2154 update_id,
2155 unknown_json,
2156 ) = row.map_err(map_sqlite_error)?;
2157 let boundary = read_at.map(|created_at| MessageCursor {
2158 created_at,
2159 message_id: if read_message_id.is_empty() {
2160 minimum_event_id()
2161 } else {
2162 read_message_id
2163 },
2164 });
2165 states.push(StoredReadStateRow {
2166 state: AccountReadState {
2167 conversation_id,
2168 boundary,
2169 forced_unread,
2170 updated_at,
2171 update_id,
2172 },
2173 unknown_json,
2174 });
2175 }
2176 Ok(states)
2177 }
2178
2179 fn product_read_state_wire_json(
2180 &self,
2181 rows: &[StoredReadStateRow],
2182 ) -> Result<String, SoftchatError> {
2183 if rows.len() > MAX_ACCOUNT_READ_STATE_ENTRIES {
2184 return Err(SoftchatError::InvalidAccountOperation);
2185 }
2186 let mut root = self
2187 .connection
2188 .query_row(
2189 "SELECT canonical_json FROM app_data_effective
2190 WHERE context = 'read-state'",
2191 [],
2192 |row| row.get::<_, String>(0),
2193 )
2194 .optional()
2195 .map_err(map_sqlite_error)?
2196 .and_then(|value| serde_json::from_str::<Map<String, Value>>(&value).ok())
2197 .unwrap_or_default();
2198 root.remove("schemaVersion");
2199 root.remove("chats");
2200 let mut chats = Vec::with_capacity(rows.len());
2201 for row in rows {
2202 let mut entry: Map<String, Value> = serde_json::from_str(&row.unknown_json)
2203 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2204 let chat_key = self
2205 .product_conversation_members(&row.state.conversation_id)
2206 .ok()
2207 .filter(|members| !members.is_empty())
2208 .map(|members| members.join(","))
2209 .unwrap_or_else(|| row.state.conversation_id.clone());
2210 entry.insert("chatKey".to_owned(), json!(chat_key));
2211 entry.insert(
2212 "conversationId".to_owned(),
2213 json!(row.state.conversation_id),
2214 );
2215 entry.insert(
2216 "readAt".to_owned(),
2217 row.state.boundary.as_ref().map_or(Value::Null, |boundary| {
2218 json!(boundary.created_at as f64 - SWIFT_REFERENCE_DATE_UNIX_SECONDS as f64)
2219 }),
2220 );
2221 entry.insert(
2222 "readAtUnix".to_owned(),
2223 row.state
2224 .boundary
2225 .as_ref()
2226 .map_or(Value::Null, |boundary| json!(boundary.created_at)),
2227 );
2228 entry.insert(
2229 "readMessageId".to_owned(),
2230 json!(
2231 row.state
2232 .boundary
2233 .as_ref()
2234 .map_or("", |boundary| boundary.message_id.as_str())
2235 ),
2236 );
2237 entry.insert("forcedUnread".to_owned(), json!(row.state.forced_unread));
2238 entry.insert("updatedAt".to_owned(), json!(row.state.updated_at));
2239 entry.insert("updateId".to_owned(), json!(row.state.update_id));
2240 chats.push(Value::Object(entry));
2241 }
2242 root.insert(
2243 "schemaVersion".to_owned(),
2244 json!(ACCOUNT_READ_STATE_SCHEMA_VERSION),
2245 );
2246 root.insert("chats".to_owned(), Value::Array(chats));
2247 let json =
2248 serde_json::to_string(&root).map_err(|_| SoftchatError::InvalidAccountOperation)?;
2249 if json.len() > MAX_ACCOUNT_READ_STATE_BYTES {
2250 return Err(SoftchatError::InvalidAccountOperation);
2251 }
2252 Ok(json)
2253 }
2254
2255 pub(crate) fn product_settings(&self) -> Result<AccountSettings, SoftchatError> {
2256 let json = self.product_settings_canonical_json()?;
2257 settings_from_json(&json)
2258 }
2259
2260 pub(crate) fn product_settings_canonical_json(&self) -> Result<String, SoftchatError> {
2261 self.connection
2262 .query_row(
2263 "SELECT canonical_json FROM account_settings WHERE singleton = 1",
2264 [],
2265 |row| row.get::<_, String>(0),
2266 )
2267 .map_err(map_sqlite_error)
2268 }
2269
2270 #[cfg(test)]
2271 pub(crate) fn apply_product_settings_patch(
2272 &mut self,
2273 patch_json: &str,
2274 now: i64,
2275 ) -> Result<AccountSettingsMutation, SoftchatError> {
2276 validate_timestamp(now)?;
2277 let preview = self.preview_product_settings_patch(patch_json)?;
2278 let transaction = self
2279 .connection
2280 .transaction_with_behavior(TransactionBehavior::Immediate)
2281 .map_err(map_sqlite_error)?;
2282 transaction
2283 .execute(
2284 "UPDATE account_settings
2285 SET schema_version = ?1, canonical_json = ?2, updated_at = ?3
2286 WHERE singleton = 1",
2287 params![ACCOUNT_SETTINGS_SCHEMA_VERSION, preview.canonical_json, now],
2288 )
2289 .map_err(map_sqlite_error)?;
2290 let revision = bump_revision(&transaction)?;
2291 transaction.commit().map_err(map_sqlite_error)?;
2292 Ok(AccountSettingsMutation {
2293 revision,
2294 ..preview
2295 })
2296 }
2297
2298 pub(crate) fn preview_product_settings_patch(
2299 &self,
2300 patch_json: &str,
2301 ) -> Result<AccountSettingsMutation, SoftchatError> {
2302 if patch_json.len() > MAX_ACCOUNT_SETTINGS_BYTES {
2303 return Err(SoftchatError::InvalidAccountSettings);
2304 }
2305 let patch: Map<String, Value> =
2306 serde_json::from_str(patch_json).map_err(|_| SoftchatError::InvalidAccountSettings)?;
2307 let current = self.product_settings()?;
2308 let mut value = settings_to_object(¤t)?;
2309 for (key, patch_value) in patch {
2310 if key == "schemaVersion" {
2311 return Err(SoftchatError::InvalidAccountSettings);
2312 }
2313 if patch_value.is_null() {
2314 value.remove(&key);
2315 } else {
2316 value.insert(key, patch_value);
2317 }
2318 }
2319 let canonical =
2320 serde_json::to_string(&value).map_err(|_| SoftchatError::InvalidAccountSettings)?;
2321 let settings = settings_from_json(&canonical)?;
2322 let canonical = serde_json::to_string(&settings_to_object(&settings)?)
2323 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
2324 Ok(AccountSettingsMutation {
2325 settings,
2326 canonical_json: canonical,
2327 revision: self.revision()?,
2328 })
2329 }
2330
2331 pub(crate) fn product_profile(
2332 &self,
2333 public_key: &str,
2334 ) -> Result<AccountProfile, SoftchatError> {
2335 let public_key = NostrPublicKey::from_hex(public_key)?.to_hex();
2336 let mut profile = self.authenticated_product_profile(&public_key)?;
2337 let local = self
2338 .connection
2339 .query_row(
2340 "SELECT name, updated_at_millis FROM local_contacts WHERE public_key = ?1",
2341 [&public_key],
2342 |row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, i64>(1)?)),
2343 )
2344 .optional()
2345 .map_err(map_sqlite_error)?;
2346 let use_local = local.as_ref().is_some_and(|(local_name, local_time)| {
2347 local_name.is_some() && *local_time / 1_000 >= profile.updated_at
2348 });
2349 if use_local {
2350 profile.name = local.as_ref().and_then(|value| value.0.clone());
2351 profile.local_override = true;
2352 profile.updated_at = local.map_or(profile.updated_at, |value| value.1 / 1_000);
2353 }
2354 Ok(profile)
2355 }
2356
2357 pub(crate) fn authenticated_product_profile(
2358 &self,
2359 public_key: &str,
2360 ) -> Result<AccountProfile, SoftchatError> {
2361 let public_key = NostrPublicKey::from_hex(public_key)?.to_hex();
2362 let authenticated = self
2363 .list_event_nodes(
2364 Some(ProjectionKind::UserMetadata),
2365 String::new(),
2366 String::new(),
2367 String::new(),
2368 public_key.clone(),
2369 None,
2370 1,
2371 0,
2372 )?
2373 .into_iter()
2374 .next()
2375 .map(|node| {
2376 let rumor = node
2377 .projection
2378 .rumor
2379 .ok_or(SoftchatError::InvalidPersistenceResult)?;
2380 let metadata = classify_user_metadata(rumor)?;
2381 Ok::<_, SoftchatError>((
2382 metadata.name,
2383 metadata.display_name,
2384 metadata.about,
2385 metadata.picture,
2386 metadata.website,
2387 metadata.banner,
2388 metadata.bot,
2389 metadata.unknown_json,
2390 node.projection.created_at,
2391 ))
2392 })
2393 .transpose()?;
2394 let (name, display_name, about, picture, website, banner, bot, unknown_json, updated_at) =
2395 authenticated.unwrap_or((None, None, None, None, None, None, None, "{}".to_owned(), 0));
2396 let first_seen_at = self.connection.query_row(
2397 "SELECT MIN(created_at) FROM projections WHERE author_public_key = ?1 AND ephemeral = 0",
2398 [&public_key], |row| row.get::<_, Option<i64>>(0),
2399 ).map_err(map_sqlite_error)?;
2400 Ok(AccountProfile {
2401 public_key,
2402 name,
2403 display_name,
2404 about,
2405 picture,
2406 website,
2407 banner,
2408 bot,
2409 unknown_json,
2410 local_override: false,
2411 updated_at,
2412 first_seen_at,
2413 })
2414 }
2415
2416 pub(crate) fn authenticated_product_follow_updated_at(
2417 &self,
2418 own_public_key: &str,
2419 ) -> Result<i64, SoftchatError> {
2420 let own_public_key = NostrPublicKey::from_hex(own_public_key)?.to_hex();
2421 Ok(self
2422 .list_event_nodes(
2423 Some(ProjectionKind::FollowList),
2424 String::new(),
2425 String::new(),
2426 String::new(),
2427 own_public_key,
2428 None,
2429 1,
2430 0,
2431 )?
2432 .first()
2433 .map_or(0, |node| node.projection.created_at))
2434 }
2435
2436 pub(crate) fn product_profiles(
2437 &self,
2438 limit: u32,
2439 offset: u32,
2440 ) -> Result<Vec<AccountProfile>, SoftchatError> {
2441 validate_limit(limit)?;
2442 let keys = self.list_known_public_keys(limit, offset)?;
2443 self.product_profiles_for_keys(&keys)
2444 }
2445
2446 pub(crate) fn product_profiles_for_keys(
2447 &self,
2448 public_keys: &[String],
2449 ) -> Result<Vec<AccountProfile>, SoftchatError> {
2450 if public_keys.len() > MAX_PRODUCT_PROFILE_LOOKUPS {
2451 return Err(SoftchatError::InvalidAccountOperation);
2452 }
2453 if public_keys.is_empty() {
2454 return Ok(Vec::new());
2455 }
2456 for public_key in public_keys {
2457 NostrPublicKey::from_hex(public_key)?;
2458 }
2459 let values = public_keys
2460 .iter()
2461 .enumerate()
2462 .map(|(position, _)| format!("(?, {position})"))
2463 .collect::<Vec<_>>()
2464 .join(",");
2465 let sql = format!(
2466 "WITH requested(public_key, position) AS (
2467 VALUES {values}
2468 )
2469 , ranked_metadata AS (
2470 SELECT
2471 profile.author_public_key,
2472 profile.logical_event_id,
2473 rumor.canonical_json,
2474 profile.created_at,
2475 ROW_NUMBER() OVER (
2476 PARTITION BY profile.author_public_key
2477 ORDER BY profile.created_at DESC,
2478 profile.logical_event_id ASC
2479 ) AS row_number
2480 FROM projections profile
2481 JOIN requested
2482 ON requested.public_key = profile.author_public_key
2483 LEFT JOIN authenticated_rumors rumor
2484 ON rumor.rumor_id = profile.logical_event_id
2485 WHERE profile.kind = 'user_metadata'
2486 )
2487 SELECT
2488 requested.public_key,
2489 metadata.logical_event_id,
2490 metadata.canonical_json,
2491 metadata.created_at,
2492 local.name,
2493 local.updated_at_millis,
2494 (SELECT MIN(first.created_at) FROM projections first
2495 WHERE first.author_public_key = requested.public_key AND first.ephemeral = 0)
2496 FROM requested
2497 LEFT JOIN ranked_metadata metadata
2498 ON metadata.author_public_key = requested.public_key
2499 AND metadata.row_number = 1
2500 LEFT JOIN local_contacts local
2501 ON local.public_key = requested.public_key
2502 ORDER BY requested.position ASC"
2503 );
2504 let parameters = public_keys
2505 .iter()
2506 .cloned()
2507 .map(SqlValue::Text)
2508 .collect::<Vec<_>>();
2509 let mut statement = self.connection.prepare(&sql).map_err(map_sqlite_error)?;
2510 let rows = statement
2511 .query_map(params_from_iter(parameters), |row| {
2512 Ok((
2513 row.get::<_, String>(0)?,
2514 row.get::<_, Option<String>>(1)?,
2515 row.get::<_, Option<String>>(2)?,
2516 row.get::<_, Option<i64>>(3)?,
2517 row.get::<_, Option<String>>(4)?,
2518 row.get::<_, Option<i64>>(5)?,
2519 row.get::<_, Option<i64>>(6)?,
2520 ))
2521 })
2522 .map_err(map_sqlite_error)?;
2523 let mut profiles = Vec::with_capacity(public_keys.len());
2524 for row in rows {
2525 let (
2526 public_key,
2527 metadata_id,
2528 metadata_json,
2529 metadata_updated_at,
2530 local_name,
2531 local_updated_at_millis,
2532 first_seen_at,
2533 ) = row.map_err(map_sqlite_error)?;
2534 let metadata = match (metadata_id, metadata_json, metadata_updated_at) {
2535 (Some(_), Some(json), Some(updated_at)) => {
2536 let rumor = serde_json::from_str::<RumorEvent>(&json)
2537 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2538 Some((classify_user_metadata(rumor)?, updated_at))
2539 }
2540 (None, None, None) => None,
2541 _ => return Err(SoftchatError::InvalidPersistenceResult),
2542 };
2543 let (mut profile, authenticated_updated_at) = metadata.map_or_else(
2544 || {
2545 (
2546 AccountProfile {
2547 public_key: public_key.clone(),
2548 name: None,
2549 display_name: None,
2550 about: None,
2551 picture: None,
2552 website: None,
2553 banner: None,
2554 bot: None,
2555 unknown_json: "{}".to_owned(),
2556 local_override: false,
2557 updated_at: 0,
2558 first_seen_at,
2559 },
2560 0,
2561 )
2562 },
2563 |(value, updated_at)| {
2564 (
2565 AccountProfile {
2566 public_key: public_key.clone(),
2567 name: value.name,
2568 display_name: value.display_name,
2569 about: value.about,
2570 picture: value.picture,
2571 website: value.website,
2572 banner: value.banner,
2573 bot: value.bot,
2574 unknown_json: value.unknown_json,
2575 local_override: false,
2576 updated_at,
2577 first_seen_at,
2578 },
2579 updated_at,
2580 )
2581 },
2582 );
2583 if let (Some(name), Some(updated_at_millis)) = (local_name, local_updated_at_millis)
2584 && updated_at_millis / 1_000 >= authenticated_updated_at
2585 {
2586 profile.name = Some(name);
2587 profile.local_override = true;
2588 profile.updated_at = updated_at_millis / 1_000;
2589 }
2590 profiles.push(profile);
2591 }
2592 Ok(profiles)
2593 }
2594
2595 pub(crate) fn product_follows(
2596 &self,
2597 own_public_key: &str,
2598 ) -> Result<Vec<crate::Contact>, SoftchatError> {
2599 let own_public_key = NostrPublicKey::from_hex(own_public_key)?.to_hex();
2600 let node = self
2601 .list_event_nodes(
2602 Some(ProjectionKind::FollowList),
2603 String::new(),
2604 String::new(),
2605 String::new(),
2606 own_public_key,
2607 None,
2608 1,
2609 0,
2610 )?
2611 .into_iter()
2612 .next();
2613 let Some(node) = node else {
2614 return Ok(Vec::new());
2615 };
2616 let rumor = node
2617 .projection
2618 .rumor
2619 .ok_or(SoftchatError::InvalidPersistenceResult)?;
2620 Ok(classify_follow_list(rumor)?.contacts)
2621 }
2622
2623 pub(crate) fn search_product_profiles(
2624 &self,
2625 query: &str,
2626 limit: u32,
2627 ) -> Result<Vec<AccountProfile>, SoftchatError> {
2628 validate_limit(limit)?;
2629 let pattern = escaped_like_pattern(query)?;
2630 let mut statement = self
2631 .connection
2632 .prepare(
2633 r"WITH known(public_key) AS (
2634 SELECT author_public_key FROM projections
2635 UNION
2636 SELECT public_key FROM projection_participants
2637 UNION
2638 SELECT public_key FROM local_contacts
2639 )
2640 SELECT known.public_key
2641 FROM known
2642 LEFT JOIN local_contacts local
2643 ON local.public_key = known.public_key
2644 WHERE lower(known.public_key) LIKE ?1 ESCAPE '\'
2645 OR lower(COALESCE(local.name, '')) LIKE ?1 ESCAPE '\'
2646 OR EXISTS (
2647 SELECT 1 FROM json_tree((
2648 SELECT profile.content
2649 FROM projections profile
2650 WHERE profile.kind = 'user_metadata'
2651 AND profile.author_public_key = known.public_key
2652 ORDER BY profile.created_at DESC, profile.logical_event_id ASC
2653 LIMIT 1
2654 )) metadata
2655 WHERE metadata.type = 'text'
2656 AND lower(metadata.atom) LIKE ?1 ESCAPE '\'
2657 )
2658 ORDER BY
2659 CASE WHEN local.name IS NULL THEN 1 ELSE 0 END,
2660 lower(COALESCE(local.name, '')),
2661 known.public_key
2662 LIMIT ?2",
2663 )
2664 .map_err(map_sqlite_error)?;
2665 let rows = statement
2666 .query_map(params![pattern, limit], |row| row.get::<_, String>(0))
2667 .map_err(map_sqlite_error)?;
2668 let mut keys = Vec::new();
2669 for row in rows {
2670 keys.push(row.map_err(map_sqlite_error)?);
2671 }
2672 self.product_profiles_for_keys(&keys)
2673 }
2674
2675 pub(crate) fn product_media_gallery(
2676 &self,
2677 conversation_id: Option<String>,
2678 before: Option<MessageCursor>,
2679 limit: u32,
2680 ) -> Result<Vec<AccountMediaItem>, SoftchatError> {
2681 validate_limit(limit)?;
2682 if let Some(value) = &conversation_id {
2683 validate_conversation_id(value)?;
2684 }
2685 if let Some(value) = &before {
2686 validate_timestamp(value.created_at)?;
2687 NostrEventId::from_hex(&value.message_id)?;
2688 }
2689 let (before_at, before_id, has_cursor) = before
2690 .map(|value| (value.created_at, value.message_id, 1_i64))
2691 .unwrap_or((0, String::new(), 0));
2692 let mut statement = self
2696 .connection
2697 .prepare(
2698 "SELECT p.logical_event_id FROM projections p
2699 JOIN authenticated_rumors rumor
2700 ON rumor.rumor_id = p.logical_event_id
2701 WHERE p.kind = 'chat_message' AND p.ephemeral = 0
2702 AND (?1 IS NULL OR p.conversation_id = ?1)
2703 AND (
2704 ?2 = 0 OR p.created_at < ?3
2705 OR (p.created_at = ?3 AND p.logical_event_id > ?4)
2706 )
2707 AND NOT EXISTS (
2708 SELECT 1 FROM projection_targets target
2709 JOIN projections deletion
2710 ON deletion.logical_event_id = target.logical_event_id
2711 WHERE target.target_event_id = p.logical_event_id
2712 AND deletion.kind = 'deletion'
2713 AND deletion.author_public_key = p.author_public_key
2714 )
2715 AND EXISTS (
2716 SELECT 1 FROM json_each(rumor.canonical_json, '$.tags') tag
2717 WHERE json_extract(tag.value, '$[0]') = 'imeta'
2718 )
2719 ORDER BY p.created_at DESC, p.logical_event_id ASC LIMIT ?5",
2720 )
2721 .map_err(map_sqlite_error)?;
2722 let rows = statement
2723 .query_map(
2724 params![conversation_id, has_cursor, before_at, before_id, limit],
2725 |row| row.get::<_, String>(0),
2726 )
2727 .map_err(map_sqlite_error)?;
2728 let mut ids = Vec::new();
2729 for row in rows {
2730 ids.push(row.map_err(map_sqlite_error)?);
2731 }
2732 let mut result = Vec::new();
2733 let requested =
2734 usize::try_from(limit).map_err(|_| SoftchatError::InvalidAccountOperation)?;
2735 for message in self.product_message_views(ProductMessageQuery::Ids(&ids))? {
2736 for attachment in message.attachments {
2737 result.push(AccountMediaItem {
2738 message_id: message.id.clone(),
2739 conversation_id: message.conversation_id.clone(),
2740 author_public_key: message.author_public_key.clone(),
2741 created_at: message.created_at,
2742 attachment,
2743 });
2744 }
2745 if result.len() >= requested {
2748 return Ok(result);
2749 }
2750 }
2751 Ok(result)
2752 }
2753
2754 pub(crate) fn prepare_product_media_message(
2755 &mut self,
2756 preparation: ProductMediaMessagePreparation<'_>,
2757 ) -> Result<PendingMediaMessage, SoftchatError> {
2758 let ProductMediaMessagePreparation {
2759 command_id,
2760 conversation_id,
2761 content,
2762 reply_to_message_id,
2763 sources,
2764 command_hash,
2765 draft_match,
2766 now,
2767 } = preparation;
2768 validate_command_id(command_id)?;
2769 validate_conversation_id(conversation_id)?;
2770 validate_timestamp(now)?;
2771 if sources.is_empty()
2772 || sources.len() > MAX_PRODUCT_DRAFT_ATTACHMENTS
2773 || content.text.len() > MAX_PRODUCT_MESSAGE_BYTES
2774 || !content.attachments.is_empty()
2775 || content.emoji_tags.len() > crate::MAX_CHAT_EMOJI_TAGS
2776 || command_hash.len() != 64
2777 || !command_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
2778 {
2779 return Err(SoftchatError::InvalidMediaOperation);
2780 }
2781 for tag in &content.emoji_tags {
2782 crate::NostrTag::new(tag.clone())?;
2783 }
2784 if !reply_to_message_id.is_empty() {
2785 NostrEventId::from_hex(reply_to_message_id)?;
2786 }
2787 let existing_hash = self
2788 .connection
2789 .query_row(
2790 "SELECT command_hash FROM pending_media_messages WHERE command_id = ?1",
2791 [command_id],
2792 |row| row.get::<_, String>(0),
2793 )
2794 .optional()
2795 .map_err(map_sqlite_error)?;
2796 if let Some(existing_hash) = existing_hash {
2797 if existing_hash != command_hash {
2798 return Err(SoftchatError::CommandConflict);
2799 }
2800 return self
2801 .product_pending_media_message(command_id)?
2802 .ok_or(SoftchatError::InvalidPersistenceResult);
2803 }
2804 let conversation_exists = self
2805 .connection
2806 .query_row(
2807 "SELECT 1 FROM conversation_state WHERE conversation_id = ?1",
2808 [conversation_id],
2809 |row| row.get::<_, i64>(0),
2810 )
2811 .optional()
2812 .map_err(map_sqlite_error)?
2813 .is_some();
2814 if !conversation_exists {
2815 return Err(SoftchatError::InvalidMediaOperation);
2816 }
2817 if !reply_to_message_id.is_empty() {
2818 let reply_exists = self
2819 .connection
2820 .query_row(
2821 "SELECT 1 FROM projections
2822 WHERE logical_event_id = ?1 AND conversation_id = ?2
2823 AND kind = 'chat_message'",
2824 params![reply_to_message_id, conversation_id],
2825 |row| row.get::<_, i64>(0),
2826 )
2827 .optional()
2828 .map_err(map_sqlite_error)?
2829 .is_some();
2830 if !reply_exists {
2831 return Err(SoftchatError::InvalidMediaOperation);
2832 }
2833 }
2834 for source in &sources {
2835 if source.source_fingerprint.is_empty() || source.source_fingerprint.len() > 512 {
2836 return Err(SoftchatError::InvalidMediaOperation);
2837 }
2838 }
2839 let emoji_tags_json = serde_json::to_string(&content.emoji_tags)
2840 .map_err(|_| SoftchatError::InternalFailure)?;
2841 let transaction = self
2842 .connection
2843 .transaction_with_behavior(TransactionBehavior::Immediate)
2844 .map_err(map_sqlite_error)?;
2845 transaction
2846 .execute(
2847 "INSERT INTO pending_media_messages
2848 (command_id, conversation_id, text_content, emoji_tags_json,
2849 reply_to_message_id, command_hash, state, created_at, updated_at)
2850 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'waiting_for_uploads', ?7, ?7)",
2851 params![
2852 command_id,
2853 conversation_id,
2854 content.text,
2855 emoji_tags_json,
2856 reply_to_message_id,
2857 command_hash,
2858 now,
2859 ],
2860 )
2861 .map_err(map_sqlite_error)?;
2862 for (index, source) in sources.into_iter().enumerate() {
2863 let attachment_command_id =
2864 stable_pending_attachment_command_id(command_id, index, &source.source_fingerprint);
2865 let operation_id = stable_media_operation_id(
2866 &self.account_id,
2867 &attachment_command_id,
2868 "upload",
2869 &source.source_fingerprint,
2870 );
2871 transaction
2872 .execute(
2873 "INSERT INTO media_operations
2874 (operation_id, command_id, conversation_id, direction, state,
2875 source_fingerprint, created_at, updated_at)
2876 VALUES (?1, ?2, ?3, 'upload', 'prepared', ?4, ?5, ?5)",
2877 params![
2878 operation_id,
2879 attachment_command_id,
2880 conversation_id,
2881 source.source_fingerprint,
2882 now,
2883 ],
2884 )
2885 .map_err(map_sqlite_error)?;
2886 transaction
2887 .execute(
2888 "INSERT INTO pending_message_attachments
2889 (command_id, attachment_index, operation_id)
2890 VALUES (?1, ?2, ?3)",
2891 params![
2892 command_id,
2893 i64::try_from(index).map_err(|_| SoftchatError::InvalidMediaOperation)?,
2894 operation_id,
2895 ],
2896 )
2897 .map_err(map_sqlite_error)?;
2898 }
2899 if let Some(draft_match) = draft_match {
2900 transaction
2901 .execute(
2902 "UPDATE conversation_state
2903 SET draft_json = NULL, draft_reply_to = '', draft_updated_at = NULL
2904 WHERE conversation_id = ?1 AND draft_json = ?2
2905 AND COALESCE(draft_reply_to, '') = ?3",
2906 params![
2907 conversation_id,
2908 draft_match.draft_json,
2909 draft_match.reply_to_message_id,
2910 ],
2911 )
2912 .map_err(map_sqlite_error)?;
2913 }
2914 bump_revision(&transaction)?;
2915 transaction.commit().map_err(map_sqlite_error)?;
2916 self.record_diagnostic(Diagnostic::new(
2917 AccountLogCategory::Media,
2918 "media.dependency.waiting",
2919 "Publication waiting for uploads",
2920 ));
2921 self.product_pending_media_message(command_id)?
2922 .ok_or(SoftchatError::InvalidPersistenceResult)
2923 }
2924
2925 pub(crate) fn product_pending_media_message(
2926 &self,
2927 command_id: &str,
2928 ) -> Result<Option<PendingMediaMessage>, SoftchatError> {
2929 validate_command_id(command_id)?;
2930 let row = self
2931 .connection
2932 .query_row(
2933 "SELECT conversation_id, text_content, emoji_tags_json,
2934 reply_to_message_id, state, result_message_id, updated_at
2935 FROM pending_media_messages WHERE command_id = ?1",
2936 [command_id],
2937 |row| {
2938 Ok((
2939 row.get::<_, String>(0)?,
2940 row.get::<_, String>(1)?,
2941 row.get::<_, String>(2)?,
2942 row.get::<_, String>(3)?,
2943 row.get::<_, String>(4)?,
2944 row.get::<_, String>(5)?,
2945 row.get::<_, i64>(6)?,
2946 ))
2947 },
2948 )
2949 .optional()
2950 .map_err(map_sqlite_error)?;
2951 let Some((
2952 conversation_id,
2953 text,
2954 emoji_tags_json,
2955 reply_to_message_id,
2956 state,
2957 result_message_id,
2958 updated_at,
2959 )) = row
2960 else {
2961 return Ok(None);
2962 };
2963 let emoji_tags = serde_json::from_str(&emoji_tags_json)
2964 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2965 let mut statement = self
2966 .connection
2967 .prepare(
2968 "SELECT operation_id FROM pending_message_attachments
2969 WHERE command_id = ?1
2970 ORDER BY attachment_index ASC",
2971 )
2972 .map_err(map_sqlite_error)?;
2973 let rows = statement
2974 .query_map([command_id], |row| row.get::<_, String>(0))
2975 .map_err(map_sqlite_error)?;
2976 let mut attachments = Vec::new();
2977 for row in rows {
2978 attachments.push(
2979 self.product_media_operation(&row.map_err(map_sqlite_error)?)?
2980 .ok_or(SoftchatError::InvalidPersistenceResult)?,
2981 );
2982 }
2983 Ok(Some(PendingMediaMessage {
2984 command_id: command_id.to_owned(),
2985 conversation_id,
2986 text,
2987 emoji_tags,
2988 reply_to_message_id,
2989 state: pending_media_message_state_from_name(&state)?,
2990 attachments,
2991 result_message_id,
2992 updated_at,
2993 }))
2994 }
2995
2996 pub(crate) fn cancel_product_pending_media_message(
2997 &mut self,
2998 command_id: &str,
2999 now: i64,
3000 ) -> Result<PendingMediaMessage, SoftchatError> {
3001 validate_command_id(command_id)?;
3002 validate_timestamp(now)?;
3003 let current = self
3004 .product_pending_media_message(command_id)?
3005 .ok_or(SoftchatError::InvalidMediaOperation)?;
3006 match current.state {
3007 PendingMediaMessageState::Cancelled => {}
3008 PendingMediaMessageState::Published => {
3009 return Err(SoftchatError::InvalidMediaOperation);
3010 }
3011 PendingMediaMessageState::WaitingForUploads
3012 | PendingMediaMessageState::ReadyToPublish
3013 | PendingMediaMessageState::Failed => {}
3014 }
3015
3016 let transaction = self
3017 .connection
3018 .transaction_with_behavior(TransactionBehavior::Immediate)
3019 .map_err(map_sqlite_error)?;
3020 let cancelled_attachments = transaction
3021 .execute(
3022 "UPDATE media_operations
3023 SET state = 'cancelled', last_error_category = '',
3024 lease_id = '', lease_expires_at = 0, updated_at = ?1
3025 WHERE operation_id IN (
3026 SELECT operation_id FROM pending_message_attachments
3027 WHERE command_id = ?2
3028 ) AND state IN ('prepared', 'retryable', 'running', 'failed')",
3029 params![now, command_id],
3030 )
3031 .map_err(map_sqlite_error)?;
3032 let cancelled_message = if current.state == PendingMediaMessageState::Cancelled {
3033 0
3034 } else {
3035 transaction
3036 .execute(
3037 "UPDATE pending_media_messages
3038 SET state = 'cancelled', updated_at = ?1
3039 WHERE command_id = ?2
3040 AND publication_claimed = 0
3041 AND state IN ('waiting_for_uploads', 'ready_to_publish', 'failed')",
3042 params![now, command_id],
3043 )
3044 .map_err(map_sqlite_error)?
3045 };
3046 if current.state != PendingMediaMessageState::Cancelled && cancelled_message != 1 {
3047 return Err(SoftchatError::InvalidMediaOperation);
3048 }
3049 if cancelled_message == 0 && cancelled_attachments == 0 {
3050 transaction.commit().map_err(map_sqlite_error)?;
3051 return Ok(current);
3052 }
3053 bump_revision(&transaction)?;
3054 transaction.commit().map_err(map_sqlite_error)?;
3055 let mut event = Diagnostic::new(
3056 AccountLogCategory::Media,
3057 "media.dependency.cancelled",
3058 "Pending message uploads cancelled",
3059 )
3060 .count(
3061 crate::account_diagnostics::AccountLogCounterKind::Attachments,
3062 cancelled_attachments as u64,
3063 );
3064 event.direction = Some(crate::account_diagnostics::AccountLogDirection::Tx);
3065 self.record_diagnostic(event);
3066 self.product_pending_media_message(command_id)?
3067 .ok_or(SoftchatError::InvalidPersistenceResult)
3068 }
3069
3070 pub(crate) fn claim_pending_media_publication(
3077 &mut self,
3078 command_id: &str,
3079 ) -> Result<bool, SoftchatError> {
3080 validate_command_id(command_id)?;
3081 let transaction = self
3082 .connection
3083 .transaction_with_behavior(TransactionBehavior::Immediate)
3084 .map_err(map_sqlite_error)?;
3085 let current = transaction
3086 .query_row(
3087 "SELECT state, publication_claimed
3088 FROM pending_media_messages WHERE command_id = ?1",
3089 [command_id],
3090 |row| Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?)),
3091 )
3092 .optional()
3093 .map_err(map_sqlite_error)?
3094 .ok_or(SoftchatError::InvalidMediaOperation)?;
3095 if current.0 != "ready_to_publish" {
3096 transaction.commit().map_err(map_sqlite_error)?;
3097 return Ok(false);
3098 }
3099 if !current.1 {
3100 let changed = transaction
3101 .execute(
3102 "UPDATE pending_media_messages
3103 SET publication_claimed = 1
3104 WHERE command_id = ?1 AND state = 'ready_to_publish'
3105 AND publication_claimed = 0",
3106 [command_id],
3107 )
3108 .map_err(map_sqlite_error)?;
3109 if changed != 1 {
3110 return Err(SoftchatError::InvalidMediaOperation);
3111 }
3112 }
3113 transaction.commit().map_err(map_sqlite_error)?;
3114 Ok(true)
3115 }
3116
3117 pub(crate) fn product_pending_media_messages(
3118 &self,
3119 limit: u32,
3120 ) -> Result<Vec<PendingMediaMessage>, SoftchatError> {
3121 validate_limit(limit)?;
3122 let mut statement = self
3123 .connection
3124 .prepare(
3125 "SELECT command_id FROM pending_media_messages
3126 ORDER BY updated_at DESC, command_id ASC LIMIT ?1",
3127 )
3128 .map_err(map_sqlite_error)?;
3129 let rows = statement
3130 .query_map([limit], |row| row.get::<_, String>(0))
3131 .map_err(map_sqlite_error)?;
3132 let mut messages = Vec::new();
3133 for row in rows {
3134 messages.push(
3135 self.product_pending_media_message(&row.map_err(map_sqlite_error)?)?
3136 .ok_or(SoftchatError::InvalidPersistenceResult)?,
3137 );
3138 }
3139 Ok(messages)
3140 }
3141
3142 pub(crate) fn ready_pending_media_commands(
3143 &self,
3144 limit: u32,
3145 ) -> Result<Vec<String>, SoftchatError> {
3146 validate_limit(limit)?;
3147 let mut statement = self
3148 .connection
3149 .prepare(
3150 "SELECT command_id FROM pending_media_messages
3151 WHERE state = 'ready_to_publish'
3152 ORDER BY updated_at ASC, command_id ASC LIMIT ?1",
3153 )
3154 .map_err(map_sqlite_error)?;
3155 let rows = statement
3156 .query_map([limit], |row| row.get::<_, String>(0))
3157 .map_err(map_sqlite_error)?;
3158 rows.collect::<Result<Vec<_>, _>>()
3159 .map_err(map_sqlite_error)
3160 }
3161
3162 pub(crate) fn pending_media_command_for_operation(
3163 &self,
3164 operation_id: &str,
3165 ) -> Result<Option<String>, SoftchatError> {
3166 self.connection
3167 .query_row(
3168 "SELECT command_id FROM pending_message_attachments
3169 WHERE operation_id = ?1",
3170 [operation_id],
3171 |row| row.get::<_, String>(0),
3172 )
3173 .optional()
3174 .map_err(map_sqlite_error)
3175 }
3176
3177 pub(crate) fn mark_pending_media_published(
3178 &mut self,
3179 command_id: &str,
3180 result_message_id: &str,
3181 now: i64,
3182 ) -> Result<AccountMutationResult, SoftchatError> {
3183 validate_command_id(command_id)?;
3184 NostrEventId::from_hex(result_message_id)?;
3185 validate_timestamp(now)?;
3186 let transaction = self
3187 .connection
3188 .transaction_with_behavior(TransactionBehavior::Immediate)
3189 .map_err(map_sqlite_error)?;
3190 let changed = transaction
3191 .execute(
3192 "UPDATE pending_media_messages
3193 SET state = 'published', result_message_id = ?1, updated_at = ?2
3194 WHERE command_id = ?3
3195 AND (
3196 (state = 'ready_to_publish' AND publication_claimed = 1)
3197 OR state = 'published'
3198 )
3199 AND (result_message_id = '' OR result_message_id = ?1)",
3200 params![result_message_id, now, command_id],
3201 )
3202 .map_err(map_sqlite_error)?;
3203 if changed != 1 {
3204 return Err(SoftchatError::InvalidMediaOperation);
3205 }
3206 let revision = bump_revision(&transaction)?;
3207 transaction.commit().map_err(map_sqlite_error)?;
3208 Ok(AccountMutationResult { revision })
3209 }
3210
3211 pub(crate) fn prepare_product_asset_replacement(
3212 &mut self,
3213 preparation: ProductAssetReplacementPreparation<'_>,
3214 ) -> Result<PendingAssetReplacement, SoftchatError> {
3215 let ProductAssetReplacementPreparation {
3216 command_id,
3217 target,
3218 conversation_id,
3219 source_fingerprint,
3220 command_hash,
3221 now,
3222 } = preparation;
3223 validate_command_id(command_id)?;
3224 validate_timestamp(now)?;
3225 if source_fingerprint.is_empty()
3226 || source_fingerprint.len() > 512
3227 || command_hash.len() != 64
3228 || !command_hash
3229 .bytes()
3230 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
3231 {
3232 return Err(SoftchatError::InvalidMediaOperation);
3233 }
3234 let existing_hash = self
3235 .connection
3236 .query_row(
3237 "SELECT command_hash FROM pending_asset_replacements WHERE command_id = ?1",
3238 [command_id],
3239 |row| row.get::<_, String>(0),
3240 )
3241 .optional()
3242 .map_err(map_sqlite_error)?;
3243 if let Some(existing_hash) = existing_hash {
3244 if existing_hash != command_hash {
3245 return Err(SoftchatError::CommandConflict);
3246 }
3247 return self
3248 .product_pending_asset_replacement(command_id)?
3249 .ok_or(SoftchatError::InvalidPersistenceResult);
3250 }
3251 let (subject, emoji_tags) = match target {
3252 AssetReplacementTarget::ProfilePicture | AssetReplacementTarget::ProfileBanner => {
3253 if !conversation_id.is_empty() {
3254 return Err(SoftchatError::InvalidMediaOperation);
3255 }
3256 (String::new(), Vec::new())
3257 }
3258 AssetReplacementTarget::ConversationIcon => {
3259 validate_conversation_id(conversation_id)?;
3260 let conversation_exists = self
3261 .connection
3262 .query_row(
3263 "SELECT 1 FROM conversation_state WHERE conversation_id = ?1",
3264 [conversation_id],
3265 |row| row.get::<_, i64>(0),
3266 )
3267 .optional()
3268 .map_err(map_sqlite_error)?
3269 .is_some();
3270 if !conversation_exists {
3271 return Err(SoftchatError::InvalidMediaOperation);
3272 }
3273 let subject = self.product_subject(conversation_id)?;
3274 (subject.text, subject.emoji_tags)
3275 }
3276 };
3277 let emoji_tags_json =
3278 serde_json::to_string(&emoji_tags).map_err(|_| SoftchatError::InternalFailure)?;
3279 let upload_command_id =
3280 stable_asset_upload_command_id(command_id, target, conversation_id, source_fingerprint);
3281 let operation_id = stable_media_operation_id(
3282 &self.account_id,
3283 &upload_command_id,
3284 "upload",
3285 source_fingerprint,
3286 );
3287 let transaction = self
3288 .connection
3289 .transaction_with_behavior(TransactionBehavior::Immediate)
3290 .map_err(map_sqlite_error)?;
3291 transaction
3292 .execute(
3293 "INSERT INTO media_operations
3294 (operation_id, command_id, conversation_id, direction,
3295 protection, state, source_fingerprint, created_at, updated_at)
3296 VALUES (?1, ?2, ?3, 'upload', 'public', 'prepared', ?4, ?5, ?5)",
3297 params![
3298 operation_id,
3299 upload_command_id,
3300 (!conversation_id.is_empty()).then_some(conversation_id),
3301 source_fingerprint,
3302 now,
3303 ],
3304 )
3305 .map_err(map_sqlite_error)?;
3306 transaction
3307 .execute(
3308 "INSERT INTO pending_asset_replacements
3309 (command_id, target, conversation_id, operation_id, subject,
3310 emoji_tags_json, command_hash, state, created_at, updated_at)
3311 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'waiting_for_upload', ?8, ?8)",
3312 params![
3313 command_id,
3314 asset_target_name(target),
3315 conversation_id,
3316 operation_id,
3317 subject,
3318 emoji_tags_json,
3319 command_hash,
3320 now,
3321 ],
3322 )
3323 .map_err(map_sqlite_error)?;
3324 bump_revision(&transaction)?;
3325 transaction.commit().map_err(map_sqlite_error)?;
3326 self.record_diagnostic(Diagnostic::new(
3327 AccountLogCategory::Media,
3328 "media.dependency.waiting",
3329 "Publication waiting for uploads",
3330 ));
3331 self.product_pending_asset_replacement(command_id)?
3332 .ok_or(SoftchatError::InvalidPersistenceResult)
3333 }
3334
3335 pub(crate) fn product_pending_asset_replacement(
3336 &self,
3337 command_id: &str,
3338 ) -> Result<Option<PendingAssetReplacement>, SoftchatError> {
3339 validate_command_id(command_id)?;
3340 let row = self
3341 .connection
3342 .query_row(
3343 "SELECT target, conversation_id, operation_id, state,
3344 result_operation_id, updated_at
3345 FROM pending_asset_replacements WHERE command_id = ?1",
3346 [command_id],
3347 |row| {
3348 Ok((
3349 row.get::<_, String>(0)?,
3350 row.get::<_, String>(1)?,
3351 row.get::<_, String>(2)?,
3352 row.get::<_, String>(3)?,
3353 row.get::<_, String>(4)?,
3354 row.get::<_, i64>(5)?,
3355 ))
3356 },
3357 )
3358 .optional()
3359 .map_err(map_sqlite_error)?;
3360 let Some((target, conversation_id, operation_id, state, result_operation_id, updated_at)) =
3361 row
3362 else {
3363 return Ok(None);
3364 };
3365 let operation = self
3366 .product_media_operation(&operation_id)?
3367 .ok_or(SoftchatError::InvalidPersistenceResult)?;
3368 Ok(Some(PendingAssetReplacement {
3369 command_id: command_id.to_owned(),
3370 target: asset_target_from_name(&target)?,
3371 conversation_id,
3372 state: pending_asset_replacement_state_from_name(&state)?,
3373 operation,
3374 result_operation_id,
3375 updated_at,
3376 }))
3377 }
3378
3379 pub(crate) fn product_pending_asset_replacements(
3380 &self,
3381 limit: u32,
3382 ) -> Result<Vec<PendingAssetReplacement>, SoftchatError> {
3383 validate_limit(limit)?;
3384 let mut statement = self
3385 .connection
3386 .prepare(
3387 "SELECT command_id FROM pending_asset_replacements
3388 ORDER BY updated_at DESC, command_id ASC LIMIT ?1",
3389 )
3390 .map_err(map_sqlite_error)?;
3391 let rows = statement
3392 .query_map([limit], |row| row.get::<_, String>(0))
3393 .map_err(map_sqlite_error)?;
3394 let mut replacements = Vec::new();
3395 for row in rows {
3396 replacements.push(
3397 self.product_pending_asset_replacement(&row.map_err(map_sqlite_error)?)?
3398 .ok_or(SoftchatError::InvalidPersistenceResult)?,
3399 );
3400 }
3401 Ok(replacements)
3402 }
3403
3404 pub(crate) fn ready_pending_asset_commands(
3405 &self,
3406 limit: u32,
3407 ) -> Result<Vec<String>, SoftchatError> {
3408 validate_limit(limit)?;
3409 let mut statement = self
3410 .connection
3411 .prepare(
3412 "SELECT command_id FROM pending_asset_replacements
3413 WHERE state = 'ready_to_publish'
3414 ORDER BY updated_at ASC, command_id ASC LIMIT ?1",
3415 )
3416 .map_err(map_sqlite_error)?;
3417 let rows = statement
3418 .query_map([limit], |row| row.get::<_, String>(0))
3419 .map_err(map_sqlite_error)?;
3420 rows.collect::<Result<Vec<_>, _>>()
3421 .map_err(map_sqlite_error)
3422 }
3423
3424 pub(crate) fn pending_asset_command_for_operation(
3425 &self,
3426 operation_id: &str,
3427 ) -> Result<Option<String>, SoftchatError> {
3428 self.connection
3429 .query_row(
3430 "SELECT command_id FROM pending_asset_replacements WHERE operation_id = ?1",
3431 [operation_id],
3432 |row| row.get::<_, String>(0),
3433 )
3434 .optional()
3435 .map_err(map_sqlite_error)
3436 }
3437
3438 pub(crate) fn pending_asset_subject(
3439 &self,
3440 command_id: &str,
3441 ) -> Result<(String, Vec<Vec<String>>), SoftchatError> {
3442 let (subject, emoji_tags_json) = self
3443 .connection
3444 .query_row(
3445 "SELECT subject, emoji_tags_json
3446 FROM pending_asset_replacements WHERE command_id = ?1",
3447 [command_id],
3448 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
3449 )
3450 .map_err(map_sqlite_error)?;
3451 let emoji_tags = serde_json::from_str(&emoji_tags_json)
3452 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
3453 Ok((subject, emoji_tags))
3454 }
3455
3456 fn pending_asset_subject_for_operation(
3457 &self,
3458 operation_id: &str,
3459 ) -> Result<Option<(String, String)>, SoftchatError> {
3460 let target = self
3461 .connection
3462 .query_row(
3463 "SELECT target, conversation_id
3464 FROM pending_asset_replacements WHERE operation_id = ?1",
3465 [operation_id],
3466 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
3467 )
3468 .optional()
3469 .map_err(map_sqlite_error)?;
3470 let Some((target, conversation_id)) = target else {
3471 return Ok(None);
3472 };
3473 if asset_target_from_name(&target)? != AssetReplacementTarget::ConversationIcon {
3474 return Ok(Some((String::new(), "[]".to_owned())));
3475 }
3476 let subject = self.product_subject(&conversation_id)?;
3477 let emoji_tags_json = serde_json::to_string(&subject.emoji_tags)
3478 .map_err(|_| SoftchatError::InternalFailure)?;
3479 Ok(Some((subject.text, emoji_tags_json)))
3480 }
3481
3482 pub(crate) fn mark_pending_asset_published(
3483 &mut self,
3484 command_id: &str,
3485 result_operation_id: &str,
3486 now: i64,
3487 ) -> Result<AccountMutationResult, SoftchatError> {
3488 validate_command_id(command_id)?;
3489 validate_command_id(result_operation_id)?;
3490 validate_timestamp(now)?;
3491 let transaction = self
3492 .connection
3493 .transaction_with_behavior(TransactionBehavior::Immediate)
3494 .map_err(map_sqlite_error)?;
3495 let changed = transaction
3496 .execute(
3497 "UPDATE pending_asset_replacements
3498 SET state = 'published', result_operation_id = ?1, updated_at = ?2
3499 WHERE command_id = ?3
3500 AND state IN ('ready_to_publish', 'published')
3501 AND (result_operation_id = '' OR result_operation_id = ?1)",
3502 params![result_operation_id, now, command_id],
3503 )
3504 .map_err(map_sqlite_error)?;
3505 if changed != 1 {
3506 return Err(SoftchatError::InvalidMediaOperation);
3507 }
3508 let revision = bump_revision(&transaction)?;
3509 transaction.commit().map_err(map_sqlite_error)?;
3510 Ok(AccountMutationResult { revision })
3511 }
3512
3513 pub(crate) fn prepare_product_media(
3514 &mut self,
3515 command_id: &str,
3516 conversation_id: Option<String>,
3517 direction: &str,
3518 source_fingerprint: &str,
3519 now: i64,
3520 ) -> Result<AccountMediaOperation, SoftchatError> {
3521 self.prepare_product_media_with_attachment(ProductMediaOperationPreparation {
3522 command_id,
3523 conversation_id,
3524 kind: ProductMediaOperationKind::Transfer,
3525 direction,
3526 protection: AccountMediaProtection::Encrypted,
3527 source_fingerprint,
3528 source_message_id: None,
3529 attachment: None,
3530 now,
3531 })
3532 }
3533
3534 pub(crate) fn prepare_product_media_download(
3535 &mut self,
3536 message_id: &str,
3537 attachment_url: &str,
3538 now: i64,
3539 ) -> Result<AccountMediaOperation, SoftchatError> {
3540 let message = self
3541 .product_message(message_id)?
3542 .filter(|message| !message.deleted)
3543 .ok_or(SoftchatError::InvalidMediaOperation)?;
3544 let attachment = message
3545 .attachments
3546 .into_iter()
3547 .find(|attachment| attachment.url == attachment_url)
3548 .ok_or(SoftchatError::InvalidMediaOperation)?;
3549 let source_fingerprint = command_hash(&("download", message_id, &attachment))?;
3550 let command_id = format!("media-download-{source_fingerprint}");
3551 self.prepare_product_media_with_attachment(ProductMediaOperationPreparation {
3552 command_id: &command_id,
3553 conversation_id: Some(message.conversation_id),
3554 source_message_id: Some(message_id),
3555 kind: ProductMediaOperationKind::Transfer,
3556 direction: "download",
3557 protection: if attachment.encryption.is_some() {
3558 AccountMediaProtection::Encrypted
3559 } else {
3560 AccountMediaProtection::Public
3561 },
3562 source_fingerprint: &source_fingerprint,
3563 attachment: Some(attachment),
3564 now,
3565 })
3566 }
3567
3568 pub(crate) fn prepare_product_conversation_icon_download(
3569 &mut self,
3570 conversation_id: &str,
3571 now: i64,
3572 ) -> Result<AccountMediaOperation, SoftchatError> {
3573 validate_conversation_id(conversation_id)?;
3574 let exists = self
3575 .connection
3576 .query_row(
3577 "SELECT EXISTS(
3578 SELECT 1 FROM conversation_state WHERE conversation_id = ?1
3579 )",
3580 [conversation_id],
3581 |row| row.get::<_, bool>(0),
3582 )
3583 .map_err(map_sqlite_error)?;
3584 if !exists {
3585 return Err(SoftchatError::InvalidMediaOperation);
3586 }
3587 let subject = self.product_subject(conversation_id)?;
3588 let attachment = subject
3589 .icon
3590 .filter(has_conversation_icon_integrity)
3591 .ok_or(SoftchatError::InvalidMediaOperation)?;
3592 let source_fingerprint = conversation_icon_source_fingerprint(
3593 conversation_id,
3594 &subject.icon_event_id,
3595 &attachment,
3596 )?;
3597 let command_id = format!("conversation-icon-download-{source_fingerprint}");
3598 self.prepare_product_media_with_attachment(ProductMediaOperationPreparation {
3599 command_id: &command_id,
3600 conversation_id: Some(conversation_id.to_owned()),
3601 source_message_id: None,
3602 kind: ProductMediaOperationKind::ConversationIcon,
3603 direction: "download",
3604 protection: if attachment.encryption.is_some() {
3605 AccountMediaProtection::Encrypted
3606 } else {
3607 AccountMediaProtection::Public
3608 },
3609 source_fingerprint: &source_fingerprint,
3610 attachment: Some(attachment),
3611 now,
3612 })
3613 }
3614
3615 fn prepare_product_media_with_attachment(
3616 &mut self,
3617 preparation: ProductMediaOperationPreparation<'_>,
3618 ) -> Result<AccountMediaOperation, SoftchatError> {
3619 let ProductMediaOperationPreparation {
3620 command_id,
3621 conversation_id,
3622 source_message_id,
3623 kind,
3624 direction,
3625 protection,
3626 source_fingerprint,
3627 attachment,
3628 now,
3629 } = preparation;
3630 validate_command_id(command_id)?;
3631 validate_timestamp(now)?;
3632 if let Some(value) = &conversation_id {
3633 validate_conversation_id(value)?;
3634 }
3635 if let Some(value) = source_message_id {
3636 NostrEventId::from_hex(value)?;
3637 }
3638 if !matches!(direction, "upload" | "download")
3639 || source_fingerprint.is_empty()
3640 || source_fingerprint.len() > 512
3641 {
3642 return Err(SoftchatError::InvalidMediaOperation);
3643 }
3644 let id =
3645 stable_media_operation_id(&self.account_id, command_id, direction, source_fingerprint);
3646 let existing = self.product_media_by_command(command_id)?;
3647 if let Some(value) = existing {
3648 if value.id != id
3649 || value.conversation_id != conversation_id.clone().unwrap_or_default()
3650 || value.source_message_id != source_message_id.unwrap_or_default()
3651 || self.product_media_operation_kind(&value.id)? != kind
3652 || value.protection != protection
3653 || value.attachment != attachment
3654 {
3655 return Err(SoftchatError::CommandConflict);
3656 }
3657 return Ok(value);
3658 }
3659 let attachment_json = attachment
3660 .as_ref()
3661 .map(serde_json::to_string)
3662 .transpose()
3663 .map_err(|_| SoftchatError::InvalidMediaOperation)?;
3664 let transaction = self
3665 .connection
3666 .transaction_with_behavior(TransactionBehavior::Immediate)
3667 .map_err(map_sqlite_error)?;
3668 transaction
3669 .execute(
3670 "INSERT INTO media_operations
3671 (operation_id, command_id, conversation_id, source_message_id,
3672 operation_kind, direction, protection, state, source_fingerprint,
3673 attachment_json, created_at, updated_at)
3674 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'prepared', ?8, ?9, ?10, ?10)",
3675 params![
3676 id,
3677 command_id,
3678 conversation_id,
3679 source_message_id,
3680 kind.as_str(),
3681 direction,
3682 media_protection_name(protection),
3683 source_fingerprint,
3684 attachment_json,
3685 now,
3686 ],
3687 )
3688 .map_err(map_sqlite_error)?;
3689 bump_revision(&transaction)?;
3690 transaction.commit().map_err(map_sqlite_error)?;
3691 let operation = self
3692 .product_media_operation(&id)?
3693 .ok_or(SoftchatError::InvalidPersistenceResult)?;
3694 self.record_diagnostic(crate::account_diagnostics::media(&operation));
3695 Ok(operation)
3696 }
3697
3698 pub(crate) fn complete_product_media(
3699 &mut self,
3700 operation_id: &str,
3701 lease_id: &str,
3702 attachment: AttachmentMetadata,
3703 byte_count: u64,
3704 now: i64,
3705 ) -> Result<AccountMediaOperation, SoftchatError> {
3706 validate_timestamp(now)?;
3707 attachment.to_tag()?;
3708 if attachment_tag_json_bytes(&attachment)? > MAX_PRODUCT_ATTACHMENT_TAG_BYTES {
3709 return Err(SoftchatError::InvalidMediaOperation);
3710 }
3711 let existing = self
3712 .product_media_operation(operation_id)?
3713 .ok_or(SoftchatError::InvalidMediaOperation)?;
3714 if existing.state == AccountMediaOperationState::Completed {
3715 if existing.attachment.as_ref() == Some(&attachment)
3716 && existing.byte_count == Some(byte_count)
3717 {
3718 return Ok(existing);
3719 }
3720 return Err(SoftchatError::InvalidMediaOperation);
3721 }
3722 if self.product_media_operation_kind(operation_id)?
3723 == ProductMediaOperationKind::ConversationIcon
3724 && attachment != self.validate_current_conversation_icon_operation(&existing)?
3725 {
3726 return Err(SoftchatError::InvalidMediaOperation);
3727 }
3728 if existing.protection == AccountMediaProtection::Public && attachment.encryption.is_some()
3729 {
3730 return Err(SoftchatError::InvalidMediaOperation);
3731 }
3732 let pending_asset_subject = self.pending_asset_subject_for_operation(operation_id)?;
3733 let byte_count =
3734 i64::try_from(byte_count).map_err(|_| SoftchatError::InvalidMediaOperation)?;
3735 let attachment_json =
3736 serde_json::to_string(&attachment).map_err(|_| SoftchatError::InternalFailure)?;
3737 let transaction = self
3738 .connection
3739 .transaction_with_behavior(TransactionBehavior::Immediate)
3740 .map_err(map_sqlite_error)?;
3741 let changed = transaction
3742 .execute(
3743 "UPDATE media_operations
3744 SET state = 'completed', attachment_json = ?1, byte_count = ?2,
3745 lease_id = '', lease_expires_at = 0,
3746 last_error_category = '', updated_at = ?3
3747 WHERE operation_id = ?4 AND state = 'running' AND lease_id = ?5",
3748 params![attachment_json, byte_count, now, operation_id, lease_id],
3749 )
3750 .map_err(map_sqlite_error)?;
3751 if changed != 1 {
3752 return Err(SoftchatError::InvalidMediaOperation);
3753 }
3754 let message_ready = transaction
3755 .execute(
3756 "UPDATE pending_media_messages
3757 SET state = 'ready_to_publish', updated_at = ?1
3758 WHERE command_id = (
3759 SELECT command_id FROM pending_message_attachments
3760 WHERE operation_id = ?2
3761 )
3762 AND state = 'waiting_for_uploads'
3763 AND NOT EXISTS (
3764 SELECT 1
3765 FROM pending_message_attachments dependency
3766 JOIN media_operations media
3767 ON media.operation_id = dependency.operation_id
3768 WHERE dependency.command_id = pending_media_messages.command_id
3769 AND media.state <> 'completed'
3770 )",
3771 params![now, operation_id],
3772 )
3773 .map_err(map_sqlite_error)?;
3774 let mut asset_ready = 0;
3775 if let Some((subject, emoji_tags_json)) = pending_asset_subject {
3776 asset_ready = transaction
3777 .execute(
3778 "UPDATE pending_asset_replacements
3779 SET subject = ?1, emoji_tags_json = ?2,
3780 state = 'ready_to_publish', updated_at = ?3
3781 WHERE operation_id = ?4 AND state = 'waiting_for_upload'",
3782 params![subject, emoji_tags_json, now, operation_id],
3783 )
3784 .map_err(map_sqlite_error)?;
3785 }
3786 bump_revision(&transaction)?;
3787 transaction.commit().map_err(map_sqlite_error)?;
3788 let operation = self
3789 .product_media_operation(operation_id)?
3790 .ok_or(SoftchatError::InvalidPersistenceResult)?;
3791 self.record_diagnostic(crate::account_diagnostics::media(&operation));
3792 if message_ready > 0 || asset_ready > 0 {
3793 self.record_diagnostic(Diagnostic::new(
3794 AccountLogCategory::Media,
3795 "media.dependency.ready",
3796 "Upload dependencies completed",
3797 ));
3798 }
3799 Ok(operation)
3800 }
3801
3802 pub(crate) fn restart_product_media_download(
3803 &mut self,
3804 operation_id: &str,
3805 now: i64,
3806 ) -> Result<AccountMediaOperation, SoftchatError> {
3807 validate_timestamp(now)?;
3808 let operation = self
3809 .product_media_operation(operation_id)?
3810 .ok_or(SoftchatError::InvalidMediaOperation)?;
3811 if operation.direction != "download" {
3812 return Err(SoftchatError::InvalidMediaOperation);
3813 }
3814 if !matches!(
3815 operation.state,
3816 AccountMediaOperationState::Completed
3817 | AccountMediaOperationState::Failed
3818 | AccountMediaOperationState::Cancelled
3819 ) {
3820 return Ok(operation);
3821 }
3822 if self.product_media_operation_kind(operation_id)?
3823 == ProductMediaOperationKind::ConversationIcon
3824 {
3825 self.validate_current_conversation_icon_operation(&operation)?;
3826 }
3827 let transaction = self
3828 .connection
3829 .transaction_with_behavior(TransactionBehavior::Immediate)
3830 .map_err(map_sqlite_error)?;
3831 let changed = transaction
3832 .execute(
3833 "UPDATE media_operations
3834 SET state = 'prepared', byte_count = NULL, lease_id = '',
3835 lease_expires_at = 0, last_error_category = '',
3836 updated_at = ?1
3837 WHERE operation_id = ?2 AND direction = 'download'
3838 AND state IN ('completed', 'failed', 'cancelled')",
3839 params![now, operation_id],
3840 )
3841 .map_err(map_sqlite_error)?;
3842 if changed != 1 {
3843 return Err(SoftchatError::InvalidMediaOperation);
3844 }
3845 bump_revision(&transaction)?;
3846 transaction.commit().map_err(map_sqlite_error)?;
3847 let operation = self
3848 .product_media_operation(operation_id)?
3849 .ok_or(SoftchatError::InvalidPersistenceResult)?;
3850 self.record_diagnostic(crate::account_diagnostics::media(&operation));
3851 Ok(operation)
3852 }
3853
3854 fn validate_current_conversation_icon_operation(
3855 &self,
3856 operation: &AccountMediaOperation,
3857 ) -> Result<AttachmentMetadata, SoftchatError> {
3858 let current_subject = self.product_subject(&operation.conversation_id)?;
3859 let current_attachment = current_subject
3860 .icon
3861 .filter(has_conversation_icon_integrity)
3862 .ok_or(SoftchatError::InvalidMediaOperation)?;
3863 let current_fingerprint = conversation_icon_source_fingerprint(
3864 &operation.conversation_id,
3865 ¤t_subject.icon_event_id,
3866 ¤t_attachment,
3867 )?;
3868 if operation.source_fingerprint != current_fingerprint
3869 || operation.attachment.as_ref() != Some(¤t_attachment)
3870 {
3871 return Err(SoftchatError::InvalidMediaOperation);
3872 }
3873 Ok(current_attachment)
3874 }
3875
3876 pub(crate) fn claim_product_media(
3877 &mut self,
3878 operation_id: &str,
3879 lease_id: &str,
3880 now: i64,
3881 lease_duration_seconds: u32,
3882 ) -> Result<Option<AccountMediaLease>, SoftchatError> {
3883 validate_timestamp(now)?;
3884 validate_media_operation_id(operation_id)?;
3885 validate_media_lease_id(lease_id)?;
3886 if lease_duration_seconds == 0 || lease_duration_seconds > 3_600 {
3887 return Err(SoftchatError::InvalidMediaOperation);
3888 }
3889 let expires_at = now
3890 .checked_add(i64::from(lease_duration_seconds))
3891 .ok_or(SoftchatError::InvalidMediaOperation)?;
3892 let transaction = self
3893 .connection
3894 .transaction_with_behavior(TransactionBehavior::Immediate)
3895 .map_err(map_sqlite_error)?;
3896 let recovered = transaction
3897 .execute(
3898 "UPDATE media_operations
3899 SET state = 'retryable', lease_id = '', lease_expires_at = 0,
3900 last_error_category = 'lease_expired', updated_at = ?1
3901 WHERE state = 'running' AND lease_expires_at > 0
3902 AND lease_expires_at <= ?1",
3903 [now],
3904 )
3905 .map_err(map_sqlite_error)?;
3906 let claimable = transaction
3907 .query_row(
3908 "SELECT operation_id FROM media_operations
3909 WHERE operation_id = ?1
3910 AND state IN ('prepared', 'retryable') AND lease_id = ''",
3911 [operation_id],
3912 |row| row.get::<_, String>(0),
3913 )
3914 .optional()
3915 .map_err(map_sqlite_error)?;
3916 let Some(operation_id) = claimable else {
3917 transaction.commit().map_err(map_sqlite_error)?;
3918 self.record_media_lease_recovery(recovered);
3919 return Ok(None);
3920 };
3921 let changed = transaction
3922 .execute(
3923 "UPDATE media_operations
3924 SET state = 'running', lease_id = ?1, lease_expires_at = ?2,
3925 attempt_count = attempt_count + 1,
3926 last_error_category = '', updated_at = ?3
3927 WHERE operation_id = ?4
3928 AND state IN ('prepared', 'retryable') AND lease_id = ''",
3929 params![lease_id, expires_at, now, operation_id],
3930 )
3931 .map_err(map_sqlite_error)?;
3932 if changed != 1 {
3933 return Err(SoftchatError::InvalidMediaOperation);
3934 }
3935 bump_revision(&transaction)?;
3936 transaction.commit().map_err(map_sqlite_error)?;
3937 self.record_media_lease_recovery(recovered);
3938 let operation = self
3939 .product_media_operation(&operation_id)?
3940 .ok_or(SoftchatError::InvalidPersistenceResult)?;
3941 self.record_diagnostic(crate::account_diagnostics::media(&operation));
3942 Ok(Some(AccountMediaLease {
3943 lease_id: lease_id.to_owned(),
3944 expires_at,
3945 operation,
3946 }))
3947 }
3948
3949 fn record_media_lease_recovery(&self, recovered: usize) {
3950 if recovered > 0 {
3951 let mut event = Diagnostic::new(
3952 AccountLogCategory::Recovery,
3953 "recovery.completed",
3954 "Expired media leases recovered",
3955 )
3956 .count(
3957 crate::account_diagnostics::AccountLogCounterKind::RecoveredItems,
3958 recovered as u64,
3959 );
3960 event.source = Some(crate::account_diagnostics::AccountLogSource::Recovery);
3961 event.reason = "lease_expired";
3962 event.summarize = true;
3963 self.record_diagnostic(event);
3964 }
3965 }
3966
3967 pub(crate) fn finish_product_media_lease(
3968 &mut self,
3969 operation_id: &str,
3970 lease_id: &str,
3971 retryable: bool,
3972 error_category: &str,
3973 now: i64,
3974 ) -> Result<AccountMediaOperation, SoftchatError> {
3975 validate_timestamp(now)?;
3976 validate_media_lease_id(lease_id)?;
3977 validate_media_error_category(error_category)?;
3978 let state = if retryable { "retryable" } else { "failed" };
3979 let transaction = self
3980 .connection
3981 .transaction_with_behavior(TransactionBehavior::Immediate)
3982 .map_err(map_sqlite_error)?;
3983 let changed = transaction
3984 .execute(
3985 "UPDATE media_operations
3986 SET state = ?1, lease_id = '', lease_expires_at = 0,
3987 last_error_category = ?2, updated_at = ?3
3988 WHERE operation_id = ?4 AND state = 'running' AND lease_id = ?5",
3989 params![state, error_category, now, operation_id, lease_id],
3990 )
3991 .map_err(map_sqlite_error)?;
3992 if changed != 1 {
3993 return Err(SoftchatError::InvalidMediaOperation);
3994 }
3995 if !retryable {
3996 transaction
3997 .execute(
3998 "UPDATE pending_media_messages
3999 SET state = 'failed', updated_at = ?1
4000 WHERE command_id = (
4001 SELECT command_id FROM pending_message_attachments
4002 WHERE operation_id = ?2
4003 ) AND state = 'waiting_for_uploads'",
4004 params![now, operation_id],
4005 )
4006 .map_err(map_sqlite_error)?;
4007 transaction
4008 .execute(
4009 "UPDATE pending_asset_replacements
4010 SET state = 'failed', updated_at = ?1
4011 WHERE operation_id = ?2 AND state = 'waiting_for_upload'",
4012 params![now, operation_id],
4013 )
4014 .map_err(map_sqlite_error)?;
4015 }
4016 bump_revision(&transaction)?;
4017 transaction.commit().map_err(map_sqlite_error)?;
4018 let operation = self
4019 .product_media_operation(operation_id)?
4020 .ok_or(SoftchatError::InvalidPersistenceResult)?;
4021 self.record_diagnostic(crate::account_diagnostics::media(&operation));
4022 Ok(operation)
4023 }
4024
4025 pub(crate) fn transition_product_media(
4026 &mut self,
4027 operation_id: &str,
4028 transition: &str,
4029 error_category: &str,
4030 now: i64,
4031 ) -> Result<AccountMediaOperation, SoftchatError> {
4032 validate_timestamp(now)?;
4033 if error_category.len() > 128 {
4034 return Err(SoftchatError::InvalidMediaOperation);
4035 }
4036 let (state, allowed) = match transition {
4037 "retry" => ("prepared", "retryable"),
4038 "cancel" => ("cancelled", "prepared,retryable"),
4039 _ => return Err(SoftchatError::InvalidMediaOperation),
4040 };
4041 let current = self
4042 .product_media_operation(operation_id)?
4043 .ok_or(SoftchatError::InvalidMediaOperation)?;
4044 let current_name = media_state_name(current.state);
4045 if !allowed.split(',').any(|value| value == current_name) {
4046 if current_name == state {
4047 return Ok(current);
4048 }
4049 return Err(SoftchatError::InvalidMediaOperation);
4050 }
4051 let transaction = self
4052 .connection
4053 .transaction_with_behavior(TransactionBehavior::Immediate)
4054 .map_err(map_sqlite_error)?;
4055 transaction
4056 .execute(
4057 "UPDATE media_operations
4058 SET state = ?1, last_error_category = ?2,
4059 lease_id = '', lease_expires_at = 0, updated_at = ?3
4060 WHERE operation_id = ?4",
4061 params![state, error_category, now, operation_id],
4062 )
4063 .map_err(map_sqlite_error)?;
4064 if state == "cancelled" {
4065 transaction
4066 .execute(
4067 "UPDATE pending_media_messages
4068 SET state = 'cancelled', updated_at = ?1
4069 WHERE command_id = (
4070 SELECT command_id FROM pending_message_attachments
4071 WHERE operation_id = ?2
4072 ) AND state = 'waiting_for_uploads'",
4073 params![now, operation_id],
4074 )
4075 .map_err(map_sqlite_error)?;
4076 transaction
4077 .execute(
4078 "UPDATE pending_asset_replacements
4079 SET state = 'cancelled', updated_at = ?1
4080 WHERE operation_id = ?2 AND state = 'waiting_for_upload'",
4081 params![now, operation_id],
4082 )
4083 .map_err(map_sqlite_error)?;
4084 }
4085 bump_revision(&transaction)?;
4086 transaction.commit().map_err(map_sqlite_error)?;
4087 let operation = self
4088 .product_media_operation(operation_id)?
4089 .ok_or(SoftchatError::InvalidPersistenceResult)?;
4090 self.record_diagnostic(crate::account_diagnostics::media(&operation));
4091 Ok(operation)
4092 }
4093
4094 pub(crate) fn product_message_downloads(
4095 &self,
4096 message_id: &str,
4097 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
4098 let Some(message) = self
4099 .product_message(message_id)?
4100 .filter(|value| !value.deleted)
4101 else {
4102 return Ok(Vec::new());
4103 };
4104 let mut downloads = Vec::new();
4105 for attachment in message.attachments {
4106 let fingerprint = command_hash(&("download", message_id, &attachment))?;
4107 if let Some(operation) =
4108 self.product_media_by_command(&format!("media-download-{fingerprint}"))?
4109 {
4110 if operation.direction != "download"
4111 || operation.conversation_id != message.conversation_id
4112 || operation.source_message_id != message_id
4113 || operation.source_fingerprint != fingerprint
4114 || operation.attachment.as_ref() != Some(&attachment)
4115 {
4116 return Err(SoftchatError::CommandConflict);
4117 }
4118 downloads.push(operation);
4119 }
4120 }
4121 Ok(downloads)
4122 }
4123
4124 pub(crate) fn product_media_operations(
4125 &self,
4126 limit: u32,
4127 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
4128 validate_limit(limit)?;
4129 let mut statement = self
4130 .connection
4131 .prepare(
4132 "SELECT operation_id FROM media_operations
4133 ORDER BY updated_at DESC, operation_id ASC LIMIT ?1",
4134 )
4135 .map_err(map_sqlite_error)?;
4136 let rows = statement
4137 .query_map([limit], |row| row.get::<_, String>(0))
4138 .map_err(map_sqlite_error)?;
4139 let mut result = Vec::new();
4140 for row in rows {
4141 result.push(
4142 self.product_media_operation(&row.map_err(map_sqlite_error)?)?
4143 .ok_or(SoftchatError::InvalidPersistenceResult)?,
4144 );
4145 }
4146 Ok(result)
4147 }
4148
4149 pub(crate) fn product_media_operations_by_ids(
4150 &self,
4151 operation_ids: &[String],
4152 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
4153 if operation_ids.len() > MAX_PRODUCT_MEDIA_OPERATION_LOOKUPS {
4154 return Err(SoftchatError::InvalidMediaOperation);
4155 }
4156 let mut distinct = BTreeSet::new();
4157 for operation_id in operation_ids {
4158 validate_media_operation_id(operation_id)?;
4159 if !distinct.insert(operation_id) {
4160 return Err(SoftchatError::InvalidMediaOperation);
4161 }
4162 }
4163 operation_ids
4164 .iter()
4165 .filter_map(
4166 |operation_id| match self.product_media_operation(operation_id) {
4167 Ok(Some(operation)) => Some(Ok(operation)),
4168 Ok(None) => None,
4169 Err(error) => Some(Err(error)),
4170 },
4171 )
4172 .collect()
4173 }
4174
4175 pub(crate) fn product_recoverable_media_operations(
4176 &self,
4177 cursor: Option<&str>,
4178 limit: u32,
4179 ) -> Result<AccountMediaOperationPage, SoftchatError> {
4180 validate_limit(limit)?;
4181 if let Some(cursor) = cursor {
4182 validate_media_operation_id(cursor)?;
4183 }
4184 let cursor = cursor.unwrap_or_default();
4185 let query_limit = i64::from(limit) + 1;
4186 let mut statement = self
4187 .connection
4188 .prepare(
4189 "SELECT operation_id FROM media_operations
4190 WHERE state IN ('prepared', 'running', 'retryable')
4191 AND (?1 = '' OR operation_id > ?1)
4192 ORDER BY operation_id ASC LIMIT ?2",
4193 )
4194 .map_err(map_sqlite_error)?;
4195 let rows = statement
4196 .query_map(params![cursor, query_limit], |row| row.get::<_, String>(0))
4197 .map_err(map_sqlite_error)?;
4198 let mut operation_ids = rows
4199 .collect::<Result<Vec<_>, _>>()
4200 .map_err(map_sqlite_error)?;
4201 let has_more = operation_ids.len() > limit as usize;
4202 operation_ids.truncate(limit as usize);
4203 let next_cursor = has_more.then(|| operation_ids.last().cloned()).flatten();
4204 let operations = operation_ids
4205 .into_iter()
4206 .map(|operation_id| {
4207 self.product_media_operation(&operation_id)?
4208 .ok_or(SoftchatError::InvalidPersistenceResult)
4209 })
4210 .collect::<Result<Vec<_>, _>>()?;
4211 Ok(AccountMediaOperationPage {
4212 operations,
4213 next_cursor,
4214 })
4215 }
4216
4217 fn product_message_views(
4218 &self,
4219 query: ProductMessageQuery<'_>,
4220 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
4221 let (predicate, ordering, parameters) = match query {
4222 ProductMessageQuery::ConversationPage {
4223 conversation_id,
4224 reply_to,
4225 cursor,
4226 limit,
4227 } => {
4228 let (has_cursor, cursor_timestamp, cursor_id) = cursor.map_or_else(
4229 || (0_i64, 0_i64, String::new()),
4230 |value| (1_i64, value.created_at, value.message_id.clone()),
4231 );
4232 (
4233 "p.conversation_id = ?
4234 AND (? = '' OR (
4235 EXISTS (SELECT 1 FROM projection_targets target
4236 WHERE target.logical_event_id = p.logical_event_id
4237 AND target.target_event_id = ?)
4238 AND NOT EXISTS (SELECT 1 FROM json_each(rumor.canonical_json, '$.tags') tag
4239 WHERE json_extract(tag.value, '$[0]') = 'q')
4240 ))
4241 AND (
4242 ? = 0
4243 OR p.created_at < ?
4244 OR (p.created_at = ? AND p.logical_event_id > ?)
4245 )"
4246 .to_owned(),
4247 "ORDER BY p.created_at DESC, p.logical_event_id ASC LIMIT ?".to_owned(),
4248 vec![
4249 SqlValue::Text(conversation_id.to_owned()),
4250 SqlValue::Text(reply_to.unwrap_or_default().to_owned()),
4251 SqlValue::Text(reply_to.unwrap_or_default().to_owned()),
4252 SqlValue::Integer(has_cursor),
4253 SqlValue::Integer(cursor_timestamp),
4254 SqlValue::Integer(cursor_timestamp),
4255 SqlValue::Text(cursor_id),
4256 SqlValue::Integer(i64::from(limit)),
4257 ],
4258 )
4259 }
4260 ProductMessageQuery::Ids(message_ids) => {
4261 if message_ids.is_empty() {
4262 return Ok(Vec::new());
4263 }
4264 for message_id in message_ids {
4265 NostrEventId::from_hex(message_id)?;
4266 }
4267 let placeholders = vec!["?"; message_ids.len()].join(",");
4268 (
4269 format!("p.logical_event_id IN ({placeholders})"),
4270 "ORDER BY p.created_at DESC, p.logical_event_id ASC".to_owned(),
4271 message_ids.iter().cloned().map(SqlValue::Text).collect(),
4272 )
4273 }
4274 };
4275 let sql = format!(
4276 "SELECT
4277 p.logical_event_id,
4278 p.conversation_id,
4279 p.author_public_key,
4280 p.created_at,
4281 rumor.canonical_json,
4282 EXISTS(
4283 SELECT 1 FROM projection_targets deletion_target
4284 JOIN projections deletion
4285 ON deletion.logical_event_id = deletion_target.logical_event_id
4286 WHERE deletion_target.target_event_id = p.logical_event_id
4287 AND deletion.kind = 'deletion'
4288 AND deletion.author_public_key = p.author_public_key
4289 ),
4290 latest_edit.logical_event_id,
4291 latest_edit_rumor.canonical_json,
4292 (
4293 SELECT COUNT(*)
4294 FROM projection_targets reply_target
4295 JOIN projections reply
4296 ON reply.logical_event_id = reply_target.logical_event_id
4297 JOIN authenticated_rumors reply_rumor
4298 ON reply_rumor.rumor_id = reply.logical_event_id
4299 WHERE reply_target.target_event_id = p.logical_event_id
4300 AND reply.kind = 'chat_message'
4301 AND reply.conversation_id = p.conversation_id
4302 AND NOT EXISTS (
4303 SELECT 1 FROM json_each(reply_rumor.canonical_json, '$.tags') tag
4304 WHERE json_extract(tag.value, '$[0]') = 'q'
4305 )
4306 AND NOT EXISTS (
4307 SELECT 1 FROM projection_targets deletion_target
4308 JOIN projections deletion
4309 ON deletion.logical_event_id =
4310 deletion_target.logical_event_id
4311 WHERE deletion_target.target_event_id =
4312 reply.logical_event_id
4313 AND deletion.kind = 'deletion'
4314 AND deletion.author_public_key =
4315 reply.author_public_key
4316 )
4317 )
4318 FROM projections p
4319 JOIN authenticated_rumors rumor
4320 ON rumor.rumor_id = p.logical_event_id
4321 LEFT JOIN projections latest_edit
4322 ON latest_edit.logical_event_id = (
4323 SELECT effective.edit_id FROM effective_message_edits effective
4324 WHERE effective.message_id = p.logical_event_id
4325 )
4326 LEFT JOIN authenticated_rumors latest_edit_rumor
4327 ON latest_edit_rumor.rumor_id = latest_edit.logical_event_id
4328 WHERE p.kind = 'chat_message' AND p.ephemeral = 0
4329 AND {predicate}
4330 {ordering}"
4331 );
4332 let mut statement = self.connection.prepare(&sql).map_err(map_sqlite_error)?;
4333 let rows = statement
4334 .query_map(params_from_iter(parameters), |row| {
4335 Ok(ProductMessageRow {
4336 id: row.get(0)?,
4337 conversation_id: row.get(1)?,
4338 author_public_key: row.get(2)?,
4339 created_at: row.get(3)?,
4340 rumor_json: row.get(4)?,
4341 deleted: row.get::<_, i64>(5)? != 0,
4342 edit_id: row.get(6)?,
4343 edit_rumor_json: row.get(7)?,
4344 reply_count: row.get(8)?,
4345 })
4346 })
4347 .map_err(map_sqlite_error)?;
4348 let mut message_rows = Vec::new();
4349 for row in rows {
4350 message_rows.push(row.map_err(map_sqlite_error)?);
4351 }
4352 let message_ids = message_rows
4353 .iter()
4354 .map(|row| row.id.clone())
4355 .collect::<Vec<_>>();
4356 let mut reactions = self.product_reactions_for_messages(&message_ids)?;
4357 message_rows
4358 .into_iter()
4359 .map(|row| {
4360 let row_reactions = reactions.remove(&row.id).unwrap_or_default();
4361 product_message_from_row(row, row_reactions)
4362 })
4363 .collect()
4364 }
4365
4366 pub(crate) fn active_product_reaction_ids(
4370 &self,
4371 message_id: &str,
4372 author_public_key: &str,
4373 value: &str,
4374 ) -> Result<Vec<String>, SoftchatError> {
4375 NostrEventId::from_hex(message_id)?;
4376 NostrPublicKey::from_hex(author_public_key)?;
4377 let mut statement = self
4378 .connection
4379 .prepare(
4380 "SELECT reaction.logical_event_id
4381 FROM projection_targets target
4382 JOIN projections reaction ON reaction.logical_event_id = target.logical_event_id
4383 WHERE target.target_event_id = ?1
4384 AND reaction.kind = 'reaction'
4385 AND reaction.author_public_key = ?2
4386 AND reaction.content = ?3
4387 AND NOT EXISTS (
4388 SELECT 1 FROM projection_targets deletion_target
4389 JOIN projections deletion
4390 ON deletion.logical_event_id = deletion_target.logical_event_id
4391 WHERE deletion_target.target_event_id = reaction.logical_event_id
4392 AND deletion.kind = 'deletion'
4393 AND deletion.author_public_key = reaction.author_public_key
4394 )
4395 ORDER BY reaction.created_at DESC, reaction.logical_event_id ASC
4396 LIMIT 257",
4397 )
4398 .map_err(map_sqlite_error)?;
4399 let rows = statement
4400 .query_map(params![message_id, author_public_key, value], |row| {
4401 row.get::<_, String>(0)
4402 })
4403 .map_err(map_sqlite_error)?;
4404 let ids = rows
4405 .collect::<Result<Vec<_>, _>>()
4406 .map_err(map_sqlite_error)?;
4407 if ids.len() > 256 {
4408 return Err(SoftchatError::InvalidAccountOperation);
4409 }
4410 Ok(ids)
4411 }
4412
4413 fn product_reactions_for_messages(
4414 &self,
4415 message_ids: &[String],
4416 ) -> Result<BTreeMap<String, Vec<MessageReactionView>>, SoftchatError> {
4417 if message_ids.is_empty() {
4418 return Ok(BTreeMap::new());
4419 }
4420 for message_id in message_ids {
4421 NostrEventId::from_hex(message_id)?;
4422 }
4423 let placeholders = vec!["?"; message_ids.len()].join(",");
4424 let sql = format!(
4425 "WITH ranked_reactions AS (
4426 SELECT
4427 reaction_target.target_event_id,
4428 reaction.logical_event_id,
4429 reaction.author_public_key,
4430 reaction.created_at,
4431 rumor.canonical_json,
4432 ROW_NUMBER() OVER (
4433 PARTITION BY reaction_target.target_event_id
4434 ORDER BY reaction.created_at DESC,
4435 reaction.logical_event_id ASC
4436 ) AS reaction_rank
4437 FROM projection_targets reaction_target
4438 JOIN projections reaction
4439 ON reaction.logical_event_id =
4440 reaction_target.logical_event_id
4441 JOIN authenticated_rumors rumor
4442 ON rumor.rumor_id = reaction.logical_event_id
4443 WHERE reaction.kind = 'reaction'
4444 AND reaction_target.target_event_id IN ({placeholders})
4445 AND NOT EXISTS (
4446 SELECT 1 FROM projection_targets deletion_target
4447 JOIN projections deletion
4448 ON deletion.logical_event_id =
4449 deletion_target.logical_event_id
4450 WHERE deletion_target.target_event_id =
4451 reaction.logical_event_id
4452 AND deletion.kind = 'deletion'
4453 AND deletion.author_public_key =
4454 reaction.author_public_key
4455 )
4456 )
4457 SELECT
4458 target_event_id,
4459 logical_event_id,
4460 author_public_key,
4461 created_at,
4462 canonical_json
4463 FROM ranked_reactions
4464 WHERE reaction_rank <= ?
4465 ORDER BY target_event_id ASC, created_at DESC,
4466 logical_event_id ASC"
4467 );
4468 let mut parameters = message_ids
4469 .iter()
4470 .cloned()
4471 .map(SqlValue::Text)
4472 .collect::<Vec<_>>();
4473 parameters.push(SqlValue::Integer(i64::from(MAX_PRODUCT_QUERY_PAGE)));
4474 let mut statement = self.connection.prepare(&sql).map_err(map_sqlite_error)?;
4475 let rows = statement
4476 .query_map(params_from_iter(parameters), |row| {
4477 Ok((
4478 row.get::<_, String>(0)?,
4479 row.get::<_, String>(1)?,
4480 row.get::<_, String>(2)?,
4481 row.get::<_, i64>(3)?,
4482 row.get::<_, String>(4)?,
4483 ))
4484 })
4485 .map_err(map_sqlite_error)?;
4486 let mut latest = BTreeMap::<String, BTreeMap<(String, String), MessageReactionView>>::new();
4487 for row in rows {
4488 let (target_id, id, author_public_key, created_at, rumor_json) =
4489 row.map_err(map_sqlite_error)?;
4490 let rumor = serde_json::from_str::<RumorEvent>(&rumor_json)
4491 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4492 let parsed = classify_reaction(rumor)?;
4493 latest
4494 .entry(target_id)
4495 .or_default()
4496 .entry((author_public_key.clone(), parsed.reaction.clone()))
4497 .or_insert(MessageReactionView {
4498 id,
4499 author_public_key,
4500 value: parsed.reaction,
4501 custom_emoji_url: parsed.custom_emoji_url,
4502 created_at,
4503 });
4504 }
4505 Ok(latest
4506 .into_iter()
4507 .map(|(message_id, values)| (message_id, values.into_values().collect()))
4508 .collect())
4509 }
4510
4511 fn product_subject(&self, conversation_id: &str) -> Result<ProductSubject, SoftchatError> {
4512 let latest = self
4513 .list_event_nodes(
4514 Some(ProjectionKind::Subject),
4515 String::new(),
4516 conversation_id.to_owned(),
4517 String::new(),
4518 String::new(),
4519 None,
4520 1,
4521 0,
4522 )?
4523 .into_iter()
4524 .next();
4525 let mut subject = if let Some(latest) = latest {
4526 let rumor = latest
4527 .projection
4528 .rumor
4529 .ok_or(SoftchatError::InvalidPersistenceResult)?;
4530 classify_product_subject(rumor)?
4531 } else {
4532 ProductSubject {
4533 icon_event_id: String::new(),
4534 text: String::new(),
4535 icon: None,
4536 emoji_tags: Vec::new(),
4537 }
4538 };
4539 let stored_icon = self
4540 .connection
4541 .query_row(
4542 "SELECT subject_event_id, attachment_json
4543 FROM conversation_subject_icons WHERE conversation_id = ?1",
4544 [conversation_id],
4545 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
4546 )
4547 .optional()
4548 .map_err(map_sqlite_error)?;
4549 if let Some((event_id, attachment_json)) = stored_icon {
4550 let (event_id, icon) =
4551 parse_stored_subject_icon(Some(event_id), Some(attachment_json))?
4552 .ok_or(SoftchatError::InvalidPersistenceResult)?;
4553 subject.icon_event_id = event_id;
4554 subject.icon = Some(icon);
4555 } else {
4556 subject.icon_event_id.clear();
4557 subject.icon = None;
4558 }
4559 Ok(subject)
4560 }
4561
4562 fn product_conversation_members(
4563 &self,
4564 conversation_id: &str,
4565 ) -> Result<Vec<String>, SoftchatError> {
4566 let mut statement = self
4567 .connection
4568 .prepare(
4569 "SELECT public_key FROM conversation_members
4570 WHERE conversation_id = ?1 ORDER BY public_key ASC",
4571 )
4572 .map_err(map_sqlite_error)?;
4573 let rows = statement
4574 .query_map([conversation_id], |row| row.get::<_, String>(0))
4575 .map_err(map_sqlite_error)?;
4576 let mut result = Vec::new();
4577 for row in rows {
4578 let key = row.map_err(map_sqlite_error)?;
4579 NostrPublicKey::from_hex(&key).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4580 result.push(key);
4581 }
4582 if result.len() < 2 {
4583 return Err(SoftchatError::InvalidPersistenceResult);
4584 }
4585 Ok(result)
4586 }
4587
4588 fn mutate_existing_conversation<P>(
4589 &mut self,
4590 sql: &str,
4591 params: P,
4592 ) -> Result<AccountMutationResult, SoftchatError>
4593 where
4594 P: rusqlite::Params,
4595 {
4596 let transaction = self
4597 .connection
4598 .transaction_with_behavior(TransactionBehavior::Immediate)
4599 .map_err(map_sqlite_error)?;
4600 let changed = transaction.execute(sql, params).map_err(map_sqlite_error)?;
4601 if changed != 1 {
4602 return Err(SoftchatError::InvalidAccountOperation);
4603 }
4604 let revision = bump_revision(&transaction)?;
4605 transaction.commit().map_err(map_sqlite_error)?;
4606 Ok(AccountMutationResult { revision })
4607 }
4608
4609 fn product_media_by_command(
4610 &self,
4611 command_id: &str,
4612 ) -> Result<Option<AccountMediaOperation>, SoftchatError> {
4613 let id = self
4614 .connection
4615 .query_row(
4616 "SELECT operation_id FROM media_operations WHERE command_id = ?1",
4617 [command_id],
4618 |row| row.get::<_, String>(0),
4619 )
4620 .optional()
4621 .map_err(map_sqlite_error)?;
4622 id.as_deref()
4623 .map(|value| self.product_media_operation(value))
4624 .transpose()
4625 .map(Option::flatten)
4626 }
4627
4628 fn product_media_operation_kind(
4629 &self,
4630 operation_id: &str,
4631 ) -> Result<ProductMediaOperationKind, SoftchatError> {
4632 let value = self
4633 .connection
4634 .query_row(
4635 "SELECT operation_kind FROM media_operations WHERE operation_id = ?1",
4636 [operation_id],
4637 |row| row.get::<_, String>(0),
4638 )
4639 .optional()
4640 .map_err(map_sqlite_error)?
4641 .ok_or(SoftchatError::InvalidMediaOperation)?;
4642 ProductMediaOperationKind::from_str(&value)
4643 }
4644
4645 pub(crate) fn product_media_operation(
4646 &self,
4647 operation_id: &str,
4648 ) -> Result<Option<AccountMediaOperation>, SoftchatError> {
4649 self.connection
4650 .query_row(
4651 "SELECT command_id, COALESCE(conversation_id, ''),
4652 COALESCE(source_message_id, ''), direction, protection,
4653 state, source_fingerprint, attachment_json, byte_count,
4654 attempt_count, last_error_category, updated_at
4655 FROM media_operations WHERE operation_id = ?1",
4656 [operation_id],
4657 |row| {
4658 Ok((
4659 row.get::<_, String>(0)?,
4660 row.get::<_, String>(1)?,
4661 row.get::<_, String>(2)?,
4662 row.get::<_, String>(3)?,
4663 row.get::<_, String>(4)?,
4664 row.get::<_, String>(5)?,
4665 row.get::<_, String>(6)?,
4666 row.get::<_, Option<String>>(7)?,
4667 row.get::<_, Option<i64>>(8)?,
4668 row.get::<_, u32>(9)?,
4669 row.get::<_, String>(10)?,
4670 row.get::<_, i64>(11)?,
4671 ))
4672 },
4673 )
4674 .optional()
4675 .map_err(map_sqlite_error)?
4676 .map(
4677 |(
4678 command_id,
4679 conversation_id,
4680 source_message_id,
4681 direction,
4682 protection,
4683 state,
4684 source_fingerprint,
4685 attachment_json,
4686 byte_count,
4687 attempt_count,
4688 last_error_category,
4689 updated_at,
4690 )| {
4691 let attachment = attachment_json
4692 .map(|value| {
4693 serde_json::from_str(&value)
4694 .map_err(|_| SoftchatError::InvalidPersistenceResult)
4695 })
4696 .transpose()?;
4697 Ok(AccountMediaOperation {
4698 id: operation_id.to_owned(),
4699 command_id,
4700 conversation_id,
4701 source_message_id,
4702 direction,
4703 protection: media_protection_from_name(&protection)?,
4704 state: media_state_from_name(&state)?,
4705 source_fingerprint,
4706 attachment,
4707 byte_count: byte_count
4708 .map(u64::try_from)
4709 .transpose()
4710 .map_err(|_| SoftchatError::InvalidPersistenceResult)?,
4711 attempt_count,
4712 last_error_category,
4713 updated_at,
4714 })
4715 },
4716 )
4717 .transpose()
4718 }
4719}
4720
4721fn product_message_from_row(
4722 row: ProductMessageRow,
4723 reactions: Vec<MessageReactionView>,
4724) -> Result<AccountMessageView, SoftchatError> {
4725 let rumor = serde_json::from_str::<RumorEvent>(&row.rumor_json)
4726 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4727 let parsed = classify_chat_message(rumor)?;
4728 let edit = match (row.edit_id, row.edit_rumor_json) {
4729 (Some(_), Some(json)) => {
4730 let rumor = serde_json::from_str::<RumorEvent>(&json)
4731 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4732 Some(classify_edit(rumor)?)
4733 }
4734 (None, None) => None,
4735 _ => return Err(SoftchatError::InvalidPersistenceResult),
4736 };
4737 let (text, emoji_tags, edited) = edit.map_or_else(
4738 || (parsed.rumor.content.clone(), parsed.emoji_tags, false),
4739 |value| (value.content, value.emoji_tags, true),
4740 );
4741 Ok(AccountMessageView {
4742 id: row.id,
4743 conversation_id: row.conversation_id,
4744 author_public_key: row.author_public_key,
4745 created_at: row.created_at,
4746 text,
4747 attachments: parsed.attachments,
4748 emoji_tags,
4749 relation: parsed.relation_kind,
4750 related_message_id: parsed.related_event_id,
4751 forwarded_author_public_key: parsed.original_author,
4752 edited,
4753 deleted: row.deleted,
4754 reply_count: row.reply_count,
4755 reactions,
4756 })
4757}
4758
4759fn product_draft(
4760 draft_json: Option<String>,
4761 draft_reply_to: Option<String>,
4762 draft_updated_at: Option<i64>,
4763) -> Result<Option<AccountDraft>, SoftchatError> {
4764 match (draft_json, draft_updated_at) {
4765 (Some(value), Some(updated_at)) => {
4766 let stored: StoredDraft = serde_json::from_str(&value)
4767 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4768 Ok(Some(AccountDraft {
4769 text: stored.text,
4770 emoji_tags: stored.emoji_tags,
4771 spans: stored.spans,
4772 reply_to_message_id: draft_reply_to.unwrap_or_default(),
4773 attachment_operation_ids: stored.attachment_operation_ids,
4774 updated_at,
4775 }))
4776 }
4777 (None, None) => Ok(None),
4778 _ => Err(SoftchatError::InvalidPersistenceResult),
4779 }
4780}
4781
4782fn parse_public_key_csv(value: Option<String>) -> Result<Vec<String>, SoftchatError> {
4783 let keys = value
4784 .filter(|csv| !csv.is_empty())
4785 .ok_or(SoftchatError::InvalidPersistenceResult)?
4786 .split(',')
4787 .map(|key| {
4788 NostrPublicKey::from_hex(key)
4789 .map(|value| value.to_hex())
4790 .map_err(|_| SoftchatError::InvalidPersistenceResult)
4791 })
4792 .collect::<Result<Vec<_>, _>>()?;
4793 if keys.len() < 2 {
4794 return Err(SoftchatError::InvalidPersistenceResult);
4795 }
4796 Ok(keys)
4797}
4798
4799fn canonical_members(
4800 own_public_key: &str,
4801 member_public_keys: Vec<String>,
4802) -> Result<Vec<String>, SoftchatError> {
4803 let own = NostrPublicKey::from_hex(own_public_key)?.to_hex();
4804 let mut members = member_public_keys
4805 .into_iter()
4806 .map(|value| NostrPublicKey::from_hex(&value).map(|key| key.to_hex()))
4807 .collect::<Result<Vec<_>, _>>()?;
4808 members.push(own);
4809 members.sort();
4810 members.dedup();
4811 if members.len() < 2 || members.len() > crate::MAX_CHAT_PARTICIPANTS + 1 {
4812 return Err(SoftchatError::InvalidAccountOperation);
4813 }
4814 Ok(members)
4815}
4816
4817fn validate_limit(limit: u32) -> Result<(), SoftchatError> {
4818 if limit == 0 || limit > MAX_PRODUCT_QUERY_PAGE {
4819 Err(SoftchatError::InvalidAccountOperation)
4820 } else {
4821 Ok(())
4822 }
4823}
4824
4825fn validate_timestamp(timestamp: i64) -> Result<(), SoftchatError> {
4826 if timestamp < 0
4827 || u64::try_from(timestamp).map_err(|_| SoftchatError::InvalidAccountOperation)?
4828 > crate::MAX_PORTABLE_TIMESTAMP_SECONDS
4829 {
4830 Err(SoftchatError::InvalidAccountOperation)
4831 } else {
4832 Ok(())
4833 }
4834}
4835
4836pub(crate) fn validate_conversation_id(value: &str) -> Result<(), SoftchatError> {
4837 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
4838 Err(SoftchatError::InvalidAccountOperation)
4839 } else {
4840 Ok(())
4841 }
4842}
4843
4844pub(crate) fn validate_command_id(value: &str) -> Result<(), SoftchatError> {
4845 if value.is_empty() || value.len() > 256 {
4846 Err(SoftchatError::InvalidAccountOperation)
4847 } else {
4848 Ok(())
4849 }
4850}
4851
4852pub(crate) fn command_hash<T: Serialize>(value: &T) -> Result<String, SoftchatError> {
4853 let encoded = serde_json::to_vec(value).map_err(|_| SoftchatError::InternalFailure)?;
4854 Ok(hex::encode(Sha256::digest(encoded)))
4855}
4856
4857fn escaped_fts_query(query: &str) -> Result<String, SoftchatError> {
4858 let terms = query
4859 .split_whitespace()
4860 .filter(|term| !term.is_empty())
4861 .map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
4862 .collect::<Vec<_>>();
4863 if terms.is_empty() || terms.len() > 32 {
4864 return Err(SoftchatError::InvalidAccountOperation);
4865 }
4866 Ok(terms.join(" AND "))
4867}
4868
4869fn validate_product_draft(draft: &AccountDraft) -> Result<(), SoftchatError> {
4870 if draft.text.len() > MAX_PRODUCT_MESSAGE_BYTES
4871 || draft.emoji_tags.len() > crate::MAX_CHAT_EMOJI_TAGS
4872 || draft.spans.len() > MAX_PRODUCT_DRAFT_SPANS
4873 || draft.attachment_operation_ids.len() > MAX_PRODUCT_DRAFT_ATTACHMENTS
4874 {
4875 return Err(SoftchatError::InvalidAccountOperation);
4876 }
4877 let mut boundaries = BTreeSet::new();
4878 let mut offset = 0_u32;
4879 boundaries.insert(offset);
4880 for character in draft.text.chars() {
4881 offset = offset
4882 .checked_add(character.len_utf16() as u32)
4883 .ok_or(SoftchatError::InvalidAccountOperation)?;
4884 boundaries.insert(offset);
4885 }
4886 let mut semantic_ranges = Vec::new();
4887 let mut unique = BTreeSet::new();
4888 for span in &draft.spans {
4889 if span.start_utf16 >= span.end_utf16
4890 || !boundaries.contains(&span.start_utf16)
4891 || !boundaries.contains(&span.end_utf16)
4892 || !unique.insert((
4893 span.start_utf16,
4894 span.end_utf16,
4895 span.kind,
4896 span.value.clone(),
4897 ))
4898 {
4899 return Err(SoftchatError::InvalidAccountOperation);
4900 }
4901 match span.kind {
4902 DraftSpanKind::Bold | DraftSpanKind::Italic | DraftSpanKind::Code => {
4903 if !span.value.is_empty() {
4904 return Err(SoftchatError::InvalidAccountOperation);
4905 }
4906 }
4907 DraftSpanKind::Mention => {
4908 NostrPublicKey::from_hex(&span.value)?;
4909 semantic_ranges.push((span.start_utf16, span.end_utf16));
4910 }
4911 DraftSpanKind::Link => {
4912 let url =
4913 Url::parse(&span.value).map_err(|_| SoftchatError::InvalidAccountOperation)?;
4914 if url.scheme() != "https" || url.host_str().is_none() {
4915 return Err(SoftchatError::InvalidAccountOperation);
4916 }
4917 semantic_ranges.push((span.start_utf16, span.end_utf16));
4918 }
4919 DraftSpanKind::CustomEmoji => {
4920 if span.value.is_empty() || span.value.len() > 128 || span.value.contains(':') {
4921 return Err(SoftchatError::InvalidAccountOperation);
4922 }
4923 semantic_ranges.push((span.start_utf16, span.end_utf16));
4924 }
4925 }
4926 }
4927 semantic_ranges.sort_unstable();
4928 if semantic_ranges
4929 .windows(2)
4930 .any(|ranges| ranges[1].0 < ranges[0].1)
4931 {
4932 return Err(SoftchatError::InvalidAccountOperation);
4933 }
4934 let mut attachment_ids = BTreeSet::new();
4935 for operation_id in &draft.attachment_operation_ids {
4936 if operation_id.is_empty()
4937 || operation_id.len() > 256
4938 || operation_id.chars().any(char::is_control)
4939 || !attachment_ids.insert(operation_id)
4940 {
4941 return Err(SoftchatError::InvalidAccountOperation);
4942 }
4943 }
4944 Ok(())
4945}
4946
4947pub(crate) fn product_draft_match(draft: AccountDraft) -> Result<ProductDraftMatch, SoftchatError> {
4948 validate_product_draft(&draft)?;
4949 if !draft.reply_to_message_id.is_empty() {
4950 NostrEventId::from_hex(&draft.reply_to_message_id)?;
4951 }
4952 let draft_json = serde_json::to_string(&StoredDraft {
4953 text: draft.text,
4954 emoji_tags: draft.emoji_tags,
4955 spans: draft.spans,
4956 attachment_operation_ids: draft.attachment_operation_ids,
4957 })
4958 .map_err(|_| SoftchatError::InternalFailure)?;
4959 Ok(ProductDraftMatch {
4960 draft_json,
4961 reply_to_message_id: draft.reply_to_message_id,
4962 })
4963}
4964
4965pub(crate) fn parse_read_state_snapshot(
4966 value: &str,
4967 event_created_at: i64,
4968 event_id: &str,
4969) -> Result<ParsedReadStateSnapshot, SoftchatError> {
4970 if value.is_empty() || value.len() > MAX_ACCOUNT_READ_STATE_BYTES {
4971 return Err(SoftchatError::InvalidAccountOperation);
4972 }
4973 validate_timestamp(event_created_at)?;
4974 NostrEventId::from_hex(event_id)?;
4975 let snapshot: StoredReadStateSnapshot =
4976 serde_json::from_str(value).map_err(|_| SoftchatError::InvalidAccountOperation)?;
4977 if snapshot.schema_version == 0
4978 || snapshot.schema_version > ACCOUNT_READ_STATE_SCHEMA_VERSION
4979 || snapshot.chats.len() > MAX_ACCOUNT_READ_STATE_ENTRIES
4980 {
4981 return Err(SoftchatError::InvalidAccountOperation);
4982 }
4983
4984 let mut entries: BTreeMap<String, ParsedReadStateEntry> = BTreeMap::new();
4985 for entry in snapshot.chats {
4986 let conversation_id = if entry.conversation_id.is_empty() {
4987 let members = entry
4988 .chat_key
4989 .split(',')
4990 .filter(|value| !value.is_empty())
4991 .map(NostrPublicKey::from_hex)
4992 .collect::<Result<Vec<_>, _>>()?;
4993 if members.is_empty() {
4994 return Err(SoftchatError::InvalidAccountOperation);
4995 }
4996 conversation_id(members.into_iter().map(|member| member.to_hex()).collect())?
4997 } else {
4998 validate_conversation_id(&entry.conversation_id)?;
4999 entry.conversation_id.clone()
5000 };
5001 let read_at = match (entry.read_at_unix, entry.read_at) {
5002 (Some(value), _) => Some(value),
5003 (None, Some(Some(value))) if value.is_finite() => {
5004 let unix = value + SWIFT_REFERENCE_DATE_UNIX_SECONDS as f64;
5005 if unix < 0.0 || unix > crate::MAX_PORTABLE_TIMESTAMP_SECONDS as f64 {
5006 return Err(SoftchatError::InvalidAccountOperation);
5007 }
5008 Some(unix.floor() as i64)
5009 }
5010 (None, Some(Some(_))) => return Err(SoftchatError::InvalidAccountOperation),
5011 (None, Some(None) | None) => None,
5012 };
5013 if let Some(value) = read_at {
5014 validate_timestamp(value)?;
5015 }
5016 let forced_unread = entry
5017 .forced_unread
5018 .unwrap_or_else(|| entry.read_at_unix.is_none() && entry.read_at.flatten().is_none());
5019 let updated_at = entry.updated_at.unwrap_or(event_created_at);
5020 validate_timestamp(updated_at)?;
5021 let update_id = if entry.update_id.is_empty() {
5022 event_id.to_owned()
5023 } else {
5024 validate_command_id(&entry.update_id)?;
5025 entry.update_id
5026 };
5027 let boundary = read_at.map(|created_at| {
5028 let message_id = if entry.read_message_id.is_empty() {
5029 minimum_event_id()
5030 } else {
5031 entry.read_message_id
5032 };
5033 NostrEventId::from_hex(&message_id)?;
5034 Ok(MessageCursor {
5035 created_at,
5036 message_id,
5037 })
5038 });
5039 let boundary = boundary.transpose()?;
5040 let unknown_json = serde_json::to_string(&entry.unknown)
5041 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
5042 let parsed = ParsedReadStateEntry {
5043 state: AccountReadState {
5044 conversation_id: conversation_id.clone(),
5045 boundary,
5046 forced_unread,
5047 updated_at,
5048 update_id,
5049 },
5050 unknown_json,
5051 };
5052 match entries.get_mut(&conversation_id) {
5053 Some(existing)
5054 if read_state_order(&parsed.state) > read_state_order(&existing.state) =>
5055 {
5056 *existing = parsed;
5057 }
5058 Some(existing)
5059 if read_state_order(&parsed.state) == read_state_order(&existing.state) =>
5060 {
5061 if !parsed.state.forced_unread {
5062 existing.state.boundary = match (
5063 existing.state.boundary.clone(),
5064 parsed.state.boundary.clone(),
5065 ) {
5066 (Some(left), Some(right)) => Some(advance_read_boundary(Some(left), right)),
5067 (None, right) => right,
5068 (left, None) => left,
5069 };
5070 existing.state.forced_unread = false;
5071 }
5072 }
5073 Some(_) => {}
5074 None => {
5075 entries.insert(conversation_id, parsed);
5076 }
5077 }
5078 }
5079 let unknown_json = serde_json::to_string(&snapshot.unknown)
5080 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
5081 Ok(ParsedReadStateSnapshot {
5082 entries: entries.into_values().collect(),
5083 unknown_json,
5084 })
5085}
5086
5087fn default_read_state_schema() -> u32 {
5088 ACCOUNT_READ_STATE_SCHEMA_VERSION
5089}
5090
5091fn minimum_event_id() -> String {
5092 "0".repeat(64)
5093}
5094
5095fn read_state_order(state: &AccountReadState) -> (i64, &str) {
5096 (state.updated_at, state.update_id.as_str())
5097}
5098
5099fn advance_read_boundary(
5100 current: Option<MessageCursor>,
5101 candidate: MessageCursor,
5102) -> MessageCursor {
5103 let Some(current) = current else {
5104 return candidate;
5105 };
5106 if candidate.created_at > current.created_at
5107 || (candidate.created_at == current.created_at && candidate.message_id < current.message_id)
5108 {
5109 candidate
5110 } else {
5111 current
5112 }
5113}
5114
5115pub(crate) fn settings_from_json(value: &str) -> Result<AccountSettings, SoftchatError> {
5116 if value.len() > MAX_ACCOUNT_SETTINGS_BYTES {
5117 return Err(SoftchatError::InvalidAccountSettings);
5118 }
5119 let mut object: Map<String, Value> =
5120 serde_json::from_str(value).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5121 apply_legacy_ios_settings_aliases(&mut object)?;
5122 let schema_version =
5123 take_u32(&mut object, "schemaVersion")?.unwrap_or(ACCOUNT_SETTINGS_SCHEMA_VERSION);
5124 if schema_version == 0 || schema_version > ACCOUNT_SETTINGS_SCHEMA_VERSION {
5125 return Err(SoftchatError::InvalidAccountSettings);
5126 }
5127 let quick_reaction = take_string(&mut object, "quickReaction")?;
5128 if quick_reaction.as_ref().is_some_and(|value| {
5129 let custom = value.starts_with(':') && value.ends_with(':');
5130 if custom {
5131 value.len() > 130
5132 } else {
5133 value.chars().count() > 64
5134 }
5135 }) {
5136 return Err(SoftchatError::InvalidAccountSettings);
5137 }
5138 let typing_indicators_enabled =
5139 take_bool(&mut object, "typingIndicatorsEnabled")?.unwrap_or(true);
5140 let notification_content_privacy = take_string(&mut object, "notificationContentPrivacy")?
5141 .unwrap_or_else(|| "full".to_owned());
5142 if !matches!(
5143 notification_content_privacy.as_str(),
5144 "full" | "sender" | "hidden"
5145 ) {
5146 return Err(SoftchatError::InvalidAccountSettings);
5147 }
5148 let media_service = take_string(&mut object, "mediaService")?;
5149 if media_service.as_ref().is_some_and(|value| {
5150 Url::parse(value)
5151 .ok()
5152 .is_none_or(|url| url.scheme() != "https")
5153 }) {
5154 return Err(SoftchatError::InvalidAccountSettings);
5155 }
5156 let image_upload_quality = quality(&mut object, "imageUploadQuality")?;
5157 let video_upload_quality = quality(&mut object, "videoUploadQuality")?;
5158 let preserve_hdr = take_bool(&mut object, "preserveHdr")?.unwrap_or(true);
5159 let transcription_locale = take_string(&mut object, "transcriptionLocale")?;
5160 if transcription_locale
5161 .as_ref()
5162 .is_some_and(|value| value.len() > 128)
5163 {
5164 return Err(SoftchatError::InvalidAccountSettings);
5165 }
5166 let domain_rules = object
5167 .remove("domainReplacementRules")
5168 .unwrap_or_else(|| Value::Array(Vec::new()));
5169 let domain_rules_array = domain_rules
5170 .as_array()
5171 .ok_or(SoftchatError::InvalidAccountSettings)?;
5172 if domain_rules_array.len() > 128 {
5173 return Err(SoftchatError::InvalidAccountSettings);
5174 }
5175 let domain_replacement_rules_json =
5176 serde_json::to_string(&domain_rules).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5177 let chat_wallpaper = take_string(&mut object, "chatWallpaper")?;
5178 if chat_wallpaper
5179 .as_ref()
5180 .is_some_and(|value| value.is_empty() || value.len() > 256)
5181 {
5182 return Err(SoftchatError::InvalidAccountSettings);
5183 }
5184 let custom_emoji_references =
5185 take_custom_emoji_references(&mut object, "customEmojiReferences")?;
5186 let saved_sticker_references = take_string_array(&mut object, "savedStickerReferences", 512)?;
5187 let seasonal_appearance = take_string(&mut object, "seasonalAppearance")?;
5188 let unknown_json =
5189 serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5190 Ok(AccountSettings {
5191 schema_version,
5192 quick_reaction,
5193 typing_indicators_enabled,
5194 notification_content_privacy,
5195 media_service,
5196 image_upload_quality,
5197 video_upload_quality,
5198 preserve_hdr,
5199 transcription_locale,
5200 domain_replacement_rules_json,
5201 chat_wallpaper,
5202 custom_emoji_references,
5203 saved_sticker_references,
5204 seasonal_appearance,
5205 unknown_json,
5206 })
5207}
5208
5209pub(crate) fn profile_update_from_patch(
5210 current: &AccountProfile,
5211 patch_json: &str,
5212) -> Result<(ProfileUpdateInput, Value), SoftchatError> {
5213 if patch_json.len() > crate::MAX_APP_DATA_JSON_BYTES {
5214 return Err(SoftchatError::InvalidUserMetadata);
5215 }
5216 let mut patch: Map<String, Value> =
5217 serde_json::from_str(patch_json).map_err(|_| SoftchatError::InvalidUserMetadata)?;
5218 let name = apply_optional_string_patch(&mut patch, "name", current.name.clone())?;
5219 let display_name =
5220 apply_optional_string_patch(&mut patch, "displayName", current.display_name.clone())?;
5221 let about = apply_optional_string_patch(&mut patch, "about", current.about.clone())?;
5222 let picture = apply_optional_string_patch(&mut patch, "picture", current.picture.clone())?;
5223 let website = apply_optional_string_patch(&mut patch, "website", current.website.clone())?;
5224 let banner = apply_optional_string_patch(&mut patch, "banner", current.banner.clone())?;
5225 let bot = apply_optional_bool_patch(&mut patch, "bot", current.bot)?;
5226 if !patch.is_empty() {
5227 return Err(SoftchatError::InvalidUserMetadata);
5228 }
5229 let canonical_patch: Value =
5230 serde_json::from_str(patch_json).map_err(|_| SoftchatError::InvalidUserMetadata)?;
5231 Ok((
5232 ProfileUpdateInput {
5233 name,
5234 display_name,
5235 about,
5236 picture,
5237 website,
5238 banner,
5239 bot,
5240 unknown_json: current.unknown_json.clone(),
5241 },
5242 canonical_patch,
5243 ))
5244}
5245
5246pub(crate) fn canonical_settings_json(value: &str) -> Result<String, SoftchatError> {
5247 let settings = settings_from_json(value)?;
5248 serde_json::to_string(&settings_to_object(&settings)?)
5249 .map_err(|_| SoftchatError::InvalidAccountSettings)
5250}
5251
5252pub(crate) fn settings_wire_json(value: &str) -> Result<String, SoftchatError> {
5253 let settings = settings_from_json(value)?;
5254 let mut object = settings_to_object(&settings)?;
5255 let video_preset = match settings.video_upload_quality.as_str() {
5256 "low" => "AVAssetExportPresetLowQuality",
5257 "balanced" => "AVAssetExportPreset960x540",
5258 "high" => "AVAssetExportPresetHighestQuality",
5259 _ => return Err(SoftchatError::InvalidAccountSettings),
5260 };
5261 object.insert("videoUploadPreset".to_owned(), json!(video_preset));
5262 object.insert(
5263 "preserveHDROnUpload".to_owned(),
5264 json!(settings.preserve_hdr),
5265 );
5266 object.insert(
5267 "christmasTheme".to_owned(),
5268 json!(settings.seasonal_appearance.as_deref() == Some("christmas")),
5269 );
5270 object.insert(
5271 "hideNotificationContent".to_owned(),
5272 json!(settings.notification_content_privacy == "hidden"),
5273 );
5274 if let Some(reaction) = &settings.quick_reaction {
5275 let swift_emoji = swift_quick_reaction_json(reaction, &settings.custom_emoji_references)?;
5276 object.insert(
5277 "quickReactionEmoji".to_owned(),
5278 Value::String(STANDARD.encode(swift_emoji.as_bytes())),
5279 );
5280 } else {
5281 object.remove("quickReactionEmoji");
5282 }
5283 insert_optional(
5284 &mut object,
5285 "chatWallpaperImage",
5286 settings.chat_wallpaper.clone(),
5287 );
5288 object.insert(
5289 "customEmojis".to_owned(),
5290 Value::Array(
5291 settings
5292 .custom_emoji_references
5293 .iter()
5294 .map(|emoji| json!({"id": emoji.shortcode, "url": emoji.url}))
5295 .collect(),
5296 ),
5297 );
5298 let sticker_urls = settings
5299 .saved_sticker_references
5300 .iter()
5301 .filter(|value| {
5302 Url::parse(value)
5303 .ok()
5304 .is_some_and(|url| matches!(url.scheme(), "https" | "http"))
5305 })
5306 .cloned()
5307 .map(Value::String)
5308 .collect();
5309 object.insert("savedStickerURLs".to_owned(), Value::Array(sticker_urls));
5310 let json = serde_json::to_string(&object).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5311 if json.len() > MAX_ACCOUNT_SETTINGS_BYTES {
5312 return Err(SoftchatError::InvalidAccountSettings);
5313 }
5314 Ok(json)
5315}
5316
5317fn settings_to_object(settings: &AccountSettings) -> Result<Map<String, Value>, SoftchatError> {
5318 let mut object: Map<String, Value> = serde_json::from_str(&settings.unknown_json)
5319 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5320 object.insert("schemaVersion".to_owned(), json!(settings.schema_version));
5321 insert_optional(
5322 &mut object,
5323 "quickReaction",
5324 settings.quick_reaction.clone(),
5325 );
5326 object.insert(
5327 "typingIndicatorsEnabled".to_owned(),
5328 json!(settings.typing_indicators_enabled),
5329 );
5330 object.insert(
5331 "notificationContentPrivacy".to_owned(),
5332 json!(settings.notification_content_privacy),
5333 );
5334 insert_optional(&mut object, "mediaService", settings.media_service.clone());
5335 object.insert(
5336 "imageUploadQuality".to_owned(),
5337 json!(settings.image_upload_quality),
5338 );
5339 object.insert(
5340 "videoUploadQuality".to_owned(),
5341 json!(settings.video_upload_quality),
5342 );
5343 object.insert("preserveHdr".to_owned(), json!(settings.preserve_hdr));
5344 insert_optional(
5345 &mut object,
5346 "transcriptionLocale",
5347 settings.transcription_locale.clone(),
5348 );
5349 object.insert(
5350 "domainReplacementRules".to_owned(),
5351 serde_json::from_str(&settings.domain_replacement_rules_json)
5352 .map_err(|_| SoftchatError::InvalidAccountSettings)?,
5353 );
5354 insert_optional(
5355 &mut object,
5356 "chatWallpaper",
5357 settings.chat_wallpaper.clone(),
5358 );
5359 object.insert(
5360 "customEmojiReferences".to_owned(),
5361 serde_json::to_value(&settings.custom_emoji_references)
5362 .map_err(|_| SoftchatError::InvalidAccountSettings)?,
5363 );
5364 object.insert(
5365 "savedStickerReferences".to_owned(),
5366 json!(settings.saved_sticker_references),
5367 );
5368 insert_optional(
5369 &mut object,
5370 "seasonalAppearance",
5371 settings.seasonal_appearance.clone(),
5372 );
5373 Ok(object)
5374}
5375
5376fn apply_legacy_ios_settings_aliases(object: &mut Map<String, Value>) -> Result<(), SoftchatError> {
5377 if !object.contains_key("quickReaction") {
5378 if let Some(value) = object.remove("quickReactionEmoji")
5379 && !value.is_null()
5380 {
5381 let encoded = value
5382 .as_str()
5383 .ok_or(SoftchatError::InvalidAccountSettings)?;
5384 let decoded = STANDARD
5385 .decode(encoded)
5386 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5387 if decoded.len() > 16 * 1024 {
5388 return Err(SoftchatError::InvalidAccountSettings);
5389 }
5390 let reaction = parse_swift_quick_reaction(&decoded)?;
5391 object.insert("quickReaction".to_owned(), Value::String(reaction));
5392 }
5393 } else {
5394 object.remove("quickReactionEmoji");
5395 }
5396 if !object.contains_key("chatWallpaper") {
5397 if let Some(value) = object.remove("chatWallpaperImage") {
5398 object.insert("chatWallpaper".to_owned(), value);
5399 }
5400 } else {
5401 object.remove("chatWallpaperImage");
5402 }
5403 if !object.contains_key("customEmojiReferences") {
5404 if let Some(value) = object.remove("customEmojis") {
5405 let values = value
5406 .as_array()
5407 .ok_or(SoftchatError::InvalidAccountSettings)?;
5408 let mut converted = Vec::with_capacity(values.len());
5409 for value in values {
5410 let value = value
5411 .as_object()
5412 .ok_or(SoftchatError::InvalidAccountSettings)?;
5413 let shortcode = value
5414 .get("id")
5415 .and_then(Value::as_str)
5416 .ok_or(SoftchatError::InvalidAccountSettings)?;
5417 let url = value
5418 .get("url")
5419 .and_then(Value::as_str)
5420 .ok_or(SoftchatError::InvalidAccountSettings)?;
5421 converted.push(json!({"shortcode": shortcode, "url": url}));
5422 }
5423 object.insert("customEmojiReferences".to_owned(), Value::Array(converted));
5424 }
5425 } else {
5426 object.remove("customEmojis");
5427 }
5428 if !object.contains_key("preserveHdr") {
5429 if let Some(value) = object.remove("preserveHDROnUpload") {
5430 object.insert("preserveHdr".to_owned(), value);
5431 }
5432 } else {
5433 object.remove("preserveHDROnUpload");
5434 }
5435 if !object.contains_key("notificationContentPrivacy") {
5436 if let Some(value) = object.remove("hideNotificationContent") {
5437 let hidden = value
5438 .as_bool()
5439 .ok_or(SoftchatError::InvalidAccountSettings)?;
5440 object.insert(
5441 "notificationContentPrivacy".to_owned(),
5442 json!(if hidden { "hidden" } else { "full" }),
5443 );
5444 }
5445 } else {
5446 object.remove("hideNotificationContent");
5447 }
5448 if !object.contains_key("seasonalAppearance") {
5449 if let Some(value) = object.remove("christmasTheme") {
5450 let enabled = value
5451 .as_bool()
5452 .ok_or(SoftchatError::InvalidAccountSettings)?;
5453 if enabled {
5454 object.insert("seasonalAppearance".to_owned(), json!("christmas"));
5455 }
5456 }
5457 } else {
5458 object.remove("christmasTheme");
5459 }
5460 if !object.contains_key("videoUploadQuality") {
5461 if let Some(value) = object.remove("videoUploadPreset") {
5462 let preset = value
5463 .as_str()
5464 .ok_or(SoftchatError::InvalidAccountSettings)?;
5465 let quality = if preset.contains("LowQuality") {
5466 "low"
5467 } else if preset.contains("HighestQuality") || preset.contains("HEVC1920") {
5468 "high"
5469 } else {
5470 "balanced"
5471 };
5472 object.insert("videoUploadQuality".to_owned(), json!(quality));
5473 }
5474 } else {
5475 object.remove("videoUploadPreset");
5476 }
5477 if !object.contains_key("savedStickerReferences") {
5478 if let Some(value) = object.remove("savedStickerURLs") {
5479 object.insert("savedStickerReferences".to_owned(), value);
5480 }
5481 } else {
5482 object.remove("savedStickerURLs");
5483 }
5484 Ok(())
5485}
5486
5487fn parse_swift_quick_reaction(value: &[u8]) -> Result<String, SoftchatError> {
5488 if let Ok(text) = std::str::from_utf8(value)
5489 && !text.is_empty()
5490 && !text.trim_start().starts_with('{')
5491 {
5492 return Ok(text.to_owned());
5493 }
5494 let value: Value =
5495 serde_json::from_slice(value).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5496 if let Some(emoji) = value
5497 .get("unicode")
5498 .and_then(|value| value.get("_0"))
5499 .and_then(|value| value.get("emoji"))
5500 .and_then(Value::as_str)
5501 {
5502 if emoji.is_empty() || emoji.chars().count() > 64 {
5503 return Err(SoftchatError::InvalidAccountSettings);
5504 }
5505 return Ok(emoji.to_owned());
5506 }
5507 let shortcode = value
5508 .get("custom")
5509 .and_then(|value| value.get("_0"))
5510 .and_then(|value| value.get("id"))
5511 .and_then(Value::as_str)
5512 .ok_or(SoftchatError::InvalidAccountSettings)?;
5513 if shortcode.is_empty() || shortcode.len() > 128 || shortcode.contains(':') {
5514 return Err(SoftchatError::InvalidAccountSettings);
5515 }
5516 Ok(format!(":{shortcode}:"))
5517}
5518
5519fn swift_quick_reaction_json(
5520 reaction: &str,
5521 custom_emojis: &[CustomEmojiReference],
5522) -> Result<String, SoftchatError> {
5523 let value = if let Some(shortcode) = reaction
5524 .strip_prefix(':')
5525 .and_then(|value| value.strip_suffix(':'))
5526 .filter(|value| !value.is_empty() && !value.contains(':'))
5527 {
5528 let custom = custom_emojis
5529 .iter()
5530 .find(|emoji| emoji.shortcode.eq_ignore_ascii_case(shortcode))
5531 .ok_or(SoftchatError::InvalidAccountSettings)?;
5532 json!({"custom":{"_0":{"id": custom.shortcode, "url": custom.url}}})
5533 } else {
5534 json!({"unicode":{"_0":{"emoji":reaction}}})
5535 };
5536 serde_json::to_string(&value).map_err(|_| SoftchatError::InvalidAccountSettings)
5537}
5538
5539fn insert_optional(object: &mut Map<String, Value>, key: &str, value: Option<String>) {
5540 if let Some(value) = value {
5541 object.insert(key.to_owned(), Value::String(value));
5542 } else {
5543 object.remove(key);
5544 }
5545}
5546
5547fn apply_optional_string_patch(
5548 patch: &mut Map<String, Value>,
5549 key: &str,
5550 current: Option<String>,
5551) -> Result<Option<String>, SoftchatError> {
5552 match patch.remove(key) {
5553 None => Ok(current),
5554 Some(Value::Null) => Ok(None),
5555 Some(Value::String(value)) => Ok(Some(value)),
5556 Some(_) => Err(SoftchatError::InvalidUserMetadata),
5557 }
5558}
5559
5560fn apply_optional_bool_patch(
5561 patch: &mut Map<String, Value>,
5562 key: &str,
5563 current: Option<bool>,
5564) -> Result<Option<bool>, SoftchatError> {
5565 match patch.remove(key) {
5566 None => Ok(current),
5567 Some(Value::Null) => Ok(None),
5568 Some(Value::Bool(value)) => Ok(Some(value)),
5569 Some(_) => Err(SoftchatError::InvalidUserMetadata),
5570 }
5571}
5572
5573fn take_string(
5574 object: &mut Map<String, Value>,
5575 key: &str,
5576) -> Result<Option<String>, SoftchatError> {
5577 match object.remove(key) {
5578 Some(Value::String(value)) => Ok(Some(value)),
5579 Some(Value::Null) | None => Ok(None),
5580 Some(_) => Err(SoftchatError::InvalidAccountSettings),
5581 }
5582}
5583
5584fn take_custom_emoji_references(
5585 object: &mut Map<String, Value>,
5586 key: &str,
5587) -> Result<Vec<CustomEmojiReference>, SoftchatError> {
5588 let value = object
5589 .remove(key)
5590 .unwrap_or_else(|| Value::Array(Vec::new()));
5591 let values = value
5592 .as_array()
5593 .ok_or(SoftchatError::InvalidAccountSettings)?;
5594 if values.len() > 512 {
5595 return Err(SoftchatError::InvalidAccountSettings);
5596 }
5597 let mut result = Vec::with_capacity(values.len());
5598 let mut shortcodes = BTreeSet::new();
5599 for value in values {
5600 let emoji: CustomEmojiReference = serde_json::from_value(value.clone())
5601 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5602 if emoji.shortcode.is_empty()
5603 || emoji.shortcode.len() > 128
5604 || emoji.shortcode.contains(':')
5605 || emoji.url.len() > 8_192
5606 || Url::parse(&emoji.url)
5607 .ok()
5608 .is_none_or(|url| url.scheme() != "https")
5609 || !shortcodes.insert(emoji.shortcode.to_lowercase())
5610 {
5611 return Err(SoftchatError::InvalidAccountSettings);
5612 }
5613 result.push(emoji);
5614 }
5615 result.sort_by(|left, right| {
5616 left.shortcode
5617 .to_lowercase()
5618 .cmp(&right.shortcode.to_lowercase())
5619 .then_with(|| left.url.cmp(&right.url))
5620 });
5621 Ok(result)
5622}
5623
5624fn take_bool(object: &mut Map<String, Value>, key: &str) -> Result<Option<bool>, SoftchatError> {
5625 match object.remove(key) {
5626 Some(Value::Bool(value)) => Ok(Some(value)),
5627 Some(Value::Null) | None => Ok(None),
5628 Some(_) => Err(SoftchatError::InvalidAccountSettings),
5629 }
5630}
5631
5632fn take_u32(object: &mut Map<String, Value>, key: &str) -> Result<Option<u32>, SoftchatError> {
5633 match object.remove(key) {
5634 Some(Value::Number(value)) => value
5635 .as_u64()
5636 .and_then(|value| u32::try_from(value).ok())
5637 .map(Some)
5638 .ok_or(SoftchatError::InvalidAccountSettings),
5639 Some(Value::Null) | None => Ok(None),
5640 Some(_) => Err(SoftchatError::InvalidAccountSettings),
5641 }
5642}
5643
5644fn take_string_array(
5645 object: &mut Map<String, Value>,
5646 key: &str,
5647 max: usize,
5648) -> Result<Vec<String>, SoftchatError> {
5649 match object.remove(key) {
5650 Some(Value::Array(values)) => {
5651 if values.len() > max {
5652 return Err(SoftchatError::InvalidAccountSettings);
5653 }
5654 let mut result = Vec::with_capacity(values.len());
5655 let mut unique = BTreeSet::new();
5656 for value in values {
5657 let Value::String(value) = value else {
5658 return Err(SoftchatError::InvalidAccountSettings);
5659 };
5660 if value.len() > 512 || !unique.insert(value.clone()) {
5661 return Err(SoftchatError::InvalidAccountSettings);
5662 }
5663 result.push(value);
5664 }
5665 Ok(result)
5666 }
5667 Some(Value::Null) | None => Ok(Vec::new()),
5668 Some(_) => Err(SoftchatError::InvalidAccountSettings),
5669 }
5670}
5671
5672fn quality(object: &mut Map<String, Value>, key: &str) -> Result<String, SoftchatError> {
5673 let value = take_string(object, key)?.unwrap_or_else(|| "balanced".to_owned());
5674 if matches!(value.as_str(), "low" | "balanced" | "high") {
5675 Ok(value)
5676 } else {
5677 Err(SoftchatError::InvalidAccountSettings)
5678 }
5679}
5680
5681fn stable_media_operation_id(
5682 account_id: &str,
5683 command_id: &str,
5684 direction: &str,
5685 source_fingerprint: &str,
5686) -> String {
5687 let mut digest = Sha256::new();
5688 digest.update(account_id.as_bytes());
5689 digest.update([0]);
5690 digest.update(command_id.as_bytes());
5691 digest.update([0]);
5692 digest.update(direction.as_bytes());
5693 digest.update([0]);
5694 digest.update(source_fingerprint.as_bytes());
5695 hex::encode(digest.finalize())
5696}
5697
5698fn stable_pending_attachment_command_id(
5699 command_id: &str,
5700 index: usize,
5701 source_fingerprint: &str,
5702) -> String {
5703 let mut digest = Sha256::new();
5704 digest.update(b"pending-media-attachment");
5705 digest.update([0]);
5706 digest.update(command_id.as_bytes());
5707 digest.update([0]);
5708 digest.update(index.to_le_bytes());
5709 digest.update([0]);
5710 digest.update(source_fingerprint.as_bytes());
5711 hex::encode(digest.finalize())
5712}
5713
5714fn stable_asset_upload_command_id(
5715 command_id: &str,
5716 target: AssetReplacementTarget,
5717 conversation_id: &str,
5718 source_fingerprint: &str,
5719) -> String {
5720 let mut digest = Sha256::new();
5721 digest.update(b"pending-asset-upload");
5722 digest.update([0]);
5723 digest.update(command_id.as_bytes());
5724 digest.update([0]);
5725 digest.update(asset_target_name(target).as_bytes());
5726 digest.update([0]);
5727 digest.update(conversation_id.as_bytes());
5728 digest.update([0]);
5729 digest.update(source_fingerprint.as_bytes());
5730 hex::encode(digest.finalize())
5731}
5732
5733fn validate_media_operation_id(value: &str) -> Result<(), SoftchatError> {
5734 if value.len() != 64
5735 || !value
5736 .bytes()
5737 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
5738 {
5739 return Err(SoftchatError::InvalidMediaOperation);
5740 }
5741 Ok(())
5742}
5743
5744fn validate_media_lease_id(value: &str) -> Result<(), SoftchatError> {
5745 if value.is_empty()
5746 || value.len() > 128
5747 || !value
5748 .bytes()
5749 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
5750 {
5751 return Err(SoftchatError::InvalidMediaOperation);
5752 }
5753 Ok(())
5754}
5755
5756fn validate_media_error_category(value: &str) -> Result<(), SoftchatError> {
5757 if value.is_empty()
5758 || value.len() > 128
5759 || !value
5760 .bytes()
5761 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
5762 {
5763 return Err(SoftchatError::InvalidMediaOperation);
5764 }
5765 Ok(())
5766}
5767
5768fn escaped_like_pattern(query: &str) -> Result<String, SoftchatError> {
5769 if query.is_empty() || query.len() > MAX_PRODUCT_SEARCH_BYTES {
5770 return Err(SoftchatError::InvalidAccountOperation);
5771 }
5772 let mut pattern = String::with_capacity(query.len() + 2);
5773 pattern.push('%');
5774 for character in query
5777 .chars()
5778 .map(|character| character.to_ascii_lowercase())
5779 {
5780 if matches!(character, '\\' | '%' | '_') {
5781 pattern.push('\\');
5782 }
5783 pattern.push(character);
5784 }
5785 pattern.push('%');
5786 Ok(pattern)
5787}
5788
5789fn media_state_name(value: AccountMediaOperationState) -> &'static str {
5790 match value {
5791 AccountMediaOperationState::Prepared => "prepared",
5792 AccountMediaOperationState::Running => "running",
5793 AccountMediaOperationState::Completed => "completed",
5794 AccountMediaOperationState::Retryable => "retryable",
5795 AccountMediaOperationState::Failed => "failed",
5796 AccountMediaOperationState::Cancelled => "cancelled",
5797 }
5798}
5799
5800fn media_protection_name(value: AccountMediaProtection) -> &'static str {
5801 match value {
5802 AccountMediaProtection::Encrypted => "encrypted",
5803 AccountMediaProtection::Public => "public",
5804 }
5805}
5806
5807fn media_protection_from_name(value: &str) -> Result<AccountMediaProtection, SoftchatError> {
5808 match value {
5809 "encrypted" => Ok(AccountMediaProtection::Encrypted),
5810 "public" => Ok(AccountMediaProtection::Public),
5811 _ => Err(SoftchatError::InvalidPersistenceResult),
5812 }
5813}
5814
5815fn media_state_from_name(value: &str) -> Result<AccountMediaOperationState, SoftchatError> {
5816 match value {
5817 "prepared" => Ok(AccountMediaOperationState::Prepared),
5818 "running" => Ok(AccountMediaOperationState::Running),
5819 "completed" => Ok(AccountMediaOperationState::Completed),
5820 "retryable" => Ok(AccountMediaOperationState::Retryable),
5821 "failed" => Ok(AccountMediaOperationState::Failed),
5822 "cancelled" => Ok(AccountMediaOperationState::Cancelled),
5823 _ => Err(SoftchatError::InvalidPersistenceResult),
5824 }
5825}
5826
5827fn asset_target_name(value: AssetReplacementTarget) -> &'static str {
5828 match value {
5829 AssetReplacementTarget::ProfilePicture => "profile_picture",
5830 AssetReplacementTarget::ProfileBanner => "profile_banner",
5831 AssetReplacementTarget::ConversationIcon => "conversation_icon",
5832 }
5833}
5834
5835fn asset_target_from_name(value: &str) -> Result<AssetReplacementTarget, SoftchatError> {
5836 match value {
5837 "profile_picture" => Ok(AssetReplacementTarget::ProfilePicture),
5838 "profile_banner" => Ok(AssetReplacementTarget::ProfileBanner),
5839 "conversation_icon" => Ok(AssetReplacementTarget::ConversationIcon),
5840 _ => Err(SoftchatError::InvalidPersistenceResult),
5841 }
5842}
5843
5844fn pending_media_message_state_from_name(
5845 value: &str,
5846) -> Result<PendingMediaMessageState, SoftchatError> {
5847 match value {
5848 "waiting_for_uploads" => Ok(PendingMediaMessageState::WaitingForUploads),
5849 "ready_to_publish" => Ok(PendingMediaMessageState::ReadyToPublish),
5850 "published" => Ok(PendingMediaMessageState::Published),
5851 "failed" => Ok(PendingMediaMessageState::Failed),
5852 "cancelled" => Ok(PendingMediaMessageState::Cancelled),
5853 _ => Err(SoftchatError::InvalidPersistenceResult),
5854 }
5855}
5856
5857fn pending_asset_replacement_state_from_name(
5858 value: &str,
5859) -> Result<PendingAssetReplacementState, SoftchatError> {
5860 match value {
5861 "waiting_for_upload" => Ok(PendingAssetReplacementState::WaitingForUpload),
5862 "ready_to_publish" => Ok(PendingAssetReplacementState::ReadyToPublish),
5863 "published" => Ok(PendingAssetReplacementState::Published),
5864 "failed" => Ok(PendingAssetReplacementState::Failed),
5865 "cancelled" => Ok(PendingAssetReplacementState::Cancelled),
5866 _ => Err(SoftchatError::InvalidPersistenceResult),
5867 }
5868}
5869
5870#[cfg(test)]
5871mod tests {
5872 use super::*;
5873 use crate::LocalIdentity;
5874
5875 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
5876 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
5877
5878 #[test]
5879 fn equal_timestamp_pages_are_lossless() -> Result<(), SoftchatError> {
5880 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5881 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
5882 let mut database = AccountDatabase::open(":memory:", alice.public_key().to_hex())?;
5883 let conversation = database.get_or_create_product_conversation(
5884 &alice.public_key().to_hex(),
5885 vec![bob.public_key().to_hex()],
5886 1,
5887 )?;
5888 assert_eq!(conversation.member_public_keys.len(), 2);
5889 let first = database.product_conversations(&alice.public_key().to_hex(), false, None, 1)?;
5890 assert_eq!(first.conversations.len(), 1);
5891 Ok(())
5892 }
5893
5894 #[test]
5895 fn settings_patch_retains_unknown_fields() -> Result<(), SoftchatError> {
5896 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5897 let mut database = AccountDatabase::open(":memory:", alice.public_key().to_hex())?;
5898 let first = database
5899 .apply_product_settings_patch(r#"{"future":{"kept":true},"quickReaction":"❤️"}"#, 1)?;
5900 assert_eq!(first.settings.quick_reaction.as_deref(), Some("❤️"));
5901 let second = database.apply_product_settings_patch(r#"{"quickReaction":"👍"}"#, 2)?;
5902 assert_eq!(second.settings.unknown_json, r#"{"future":{"kept":true}}"#);
5903 Ok(())
5904 }
5905
5906 #[test]
5907 fn ios_wallpaper_and_custom_emoji_settings_round_trip_through_canonical_form()
5908 -> Result<(), SoftchatError> {
5909 let input = r#"{
5910 "chatWallpaperImage":"ocean",
5911 "customEmojis":[
5912 {"id":"party","url":"https://cdn.example/party.png"},
5913 {"id":"Wave","url":"https://cdn.example/wave.png"}
5914 ],
5915 "future":{"retained":true}
5916 }"#;
5917 let canonical = canonical_settings_json(input)?;
5918 let settings = settings_from_json(&canonical)?;
5919 assert_eq!(settings.chat_wallpaper.as_deref(), Some("ocean"));
5920 assert_eq!(
5921 settings.custom_emoji_references,
5922 vec![
5923 CustomEmojiReference {
5924 shortcode: "party".to_owned(),
5925 url: "https://cdn.example/party.png".to_owned(),
5926 },
5927 CustomEmojiReference {
5928 shortcode: "Wave".to_owned(),
5929 url: "https://cdn.example/wave.png".to_owned(),
5930 },
5931 ]
5932 );
5933 assert_eq!(settings.unknown_json, r#"{"future":{"retained":true}}"#);
5934 let wire = settings_wire_json(&canonical)?;
5935 let wire: Value =
5936 serde_json::from_str(&wire).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5937 assert_eq!(wire["chatWallpaperImage"], "ocean");
5938 assert_eq!(wire["customEmojis"][0]["id"], "party");
5939 Ok(())
5940 }
5941
5942 #[test]
5943 fn ios_quick_reaction_data_maps_to_the_portable_setting() -> Result<(), SoftchatError> {
5944 let unicode = STANDARD.encode(r#"{"unicode":{"_0":{"emoji":"🔥"}}}"#.as_bytes());
5945 let input = json!({
5946 "quickReactionEmoji": unicode,
5947 "customEmojis": [
5948 {"id":"party","url":"https://cdn.example/party.png"}
5949 ]
5950 });
5951 let settings = settings_from_json(&input.to_string())?;
5952 assert_eq!(settings.quick_reaction.as_deref(), Some("🔥"));
5953 let canonical = serde_json::to_string(&settings_to_object(&settings)?)
5954 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5955 let wire = settings_wire_json(&canonical)?;
5956 let wire: Value =
5957 serde_json::from_str(&wire).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5958 let encoded = wire["quickReactionEmoji"]
5959 .as_str()
5960 .ok_or(SoftchatError::InvalidAccountSettings)?;
5961 let decoded = STANDARD
5962 .decode(encoded)
5963 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5964 let decoded: Value =
5965 serde_json::from_slice(&decoded).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5966 assert_eq!(decoded["unicode"]["_0"]["emoji"], "🔥");
5967
5968 let custom = settings_from_json(
5969 r#"{"quickReaction":":party:","customEmojiReferences":[{"shortcode":"party","url":"https://cdn.example/party.png"}]}"#,
5970 )?;
5971 let canonical = serde_json::to_string(&settings_to_object(&custom)?)
5972 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5973 let wire = settings_wire_json(&canonical)?;
5974 let wire: Value =
5975 serde_json::from_str(&wire).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5976 let encoded = wire["quickReactionEmoji"]
5977 .as_str()
5978 .ok_or(SoftchatError::InvalidAccountSettings)?;
5979 let decoded = STANDARD
5980 .decode(encoded)
5981 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
5982 let decoded: Value =
5983 serde_json::from_slice(&decoded).map_err(|_| SoftchatError::InvalidAccountSettings)?;
5984 assert_eq!(decoded["custom"]["_0"]["id"], "party");
5985 Ok(())
5986 }
5987
5988 #[test]
5989 fn profile_patch_retains_authenticated_fields_not_named_by_android() -> Result<(), SoftchatError>
5990 {
5991 let current = AccountProfile {
5992 public_key: "00".repeat(32),
5993 name: Some("alice".to_owned()),
5994 display_name: Some("Alice".to_owned()),
5995 about: Some("before".to_owned()),
5996 picture: Some("https://cdn.example/avatar.png".to_owned()),
5997 website: Some("https://example.com".to_owned()),
5998 banner: Some("https://cdn.example/banner.png".to_owned()),
5999 bot: Some(false),
6000 unknown_json: r#"{"future":{"retained":true}}"#.to_owned(),
6001 local_override: false,
6002 updated_at: 1,
6003 first_seen_at: Some(1),
6004 };
6005 let (updated, _) =
6006 profile_update_from_patch(¤t, r#"{"about":"after","displayName":null}"#)?;
6007 assert_eq!(updated.name.as_deref(), Some("alice"));
6008 assert_eq!(updated.display_name, None);
6009 assert_eq!(updated.about.as_deref(), Some("after"));
6010 assert_eq!(
6011 updated.picture.as_deref(),
6012 Some("https://cdn.example/avatar.png")
6013 );
6014 assert_eq!(updated.website.as_deref(), Some("https://example.com"));
6015 assert_eq!(
6016 updated.banner.as_deref(),
6017 Some("https://cdn.example/banner.png")
6018 );
6019 assert_eq!(updated.bot, Some(false));
6020 assert_eq!(updated.unknown_json, r#"{"future":{"retained":true}}"#);
6021 Ok(())
6022 }
6023
6024 #[test]
6025 fn portable_drafts_validate_utf16_boundaries() -> Result<(), SoftchatError> {
6026 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
6027 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
6028 let mut database = AccountDatabase::open(":memory:", alice.public_key().to_hex())?;
6029 let conversation = database.get_or_create_product_conversation(
6030 &alice.public_key().to_hex(),
6031 vec![bob.public_key().to_hex()],
6032 1,
6033 )?;
6034 database.save_product_draft(
6035 &conversation.id,
6036 Some(AccountDraft {
6037 text: "a😀b".to_owned(),
6038 emoji_tags: Vec::new(),
6039 spans: vec![DraftSpan {
6040 start_utf16: 1,
6041 end_utf16: 3,
6042 kind: DraftSpanKind::Bold,
6043 value: String::new(),
6044 }],
6045 reply_to_message_id: String::new(),
6046 attachment_operation_ids: Vec::new(),
6047 updated_at: 2,
6048 }),
6049 2,
6050 )?;
6051 assert_eq!(
6052 database
6053 .product_conversation(&conversation.id, &alice.public_key().to_hex())?
6054 .and_then(|value| value.draft)
6055 .map(|value| value.spans.len()),
6056 Some(1)
6057 );
6058 database.save_product_draft(
6059 &conversation.id,
6060 Some(AccountDraft {
6061 text: ":Device sticker:".to_owned(),
6062 emoji_tags: vec![vec![
6063 "emoji".to_owned(),
6064 "Device sticker".to_owned(),
6065 "https://cdn.example/device-sticker.webp".to_owned(),
6066 ]],
6067 spans: vec![DraftSpan {
6068 start_utf16: 0,
6069 end_utf16: 16,
6070 kind: DraftSpanKind::CustomEmoji,
6071 value: "Device sticker".to_owned(),
6072 }],
6073 reply_to_message_id: String::new(),
6074 attachment_operation_ids: Vec::new(),
6075 updated_at: 3,
6076 }),
6077 3,
6078 )?;
6079 assert_eq!(
6080 database
6081 .product_conversation(&conversation.id, &alice.public_key().to_hex())?
6082 .and_then(|value| value.draft)
6083 .and_then(|value| value.spans.into_iter().next())
6084 .map(|value| value.value),
6085 Some("Device sticker".to_owned())
6086 );
6087 assert!(matches!(
6088 database.save_product_draft(
6089 &conversation.id,
6090 Some(AccountDraft {
6091 text: "a😀b".to_owned(),
6092 emoji_tags: Vec::new(),
6093 spans: vec![DraftSpan {
6094 start_utf16: 2,
6095 end_utf16: 3,
6096 kind: DraftSpanKind::Bold,
6097 value: String::new(),
6098 }],
6099 reply_to_message_id: String::new(),
6100 attachment_operation_ids: Vec::new(),
6101 updated_at: 4,
6102 }),
6103 4,
6104 ),
6105 Err(SoftchatError::InvalidAccountOperation)
6106 ));
6107 Ok(())
6108 }
6109}