Skip to main content

softchat/
account_product.rs

1//! High-level account values, commands, and bounded product queries.
2//!
3//! This module is deliberately above protocol events and below platform
4//! effects. Android and, later, Apple call these operations with user intent;
5//! Rust resolves authenticated state and persists the complete result.
6
7use 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
30/// Maximum requested rows for one product query.
31/// Media gallery pages finish the last message's attachment group and may
32/// return at most `limit + MAX_CHAT_ATTACHMENTS - 1` items.
33pub const MAX_PRODUCT_QUERY_PAGE: u32 = 100;
34/// Maximum exact profiles resolved by one ordered product lookup.
35pub const MAX_PRODUCT_PROFILE_LOOKUPS: usize = 512;
36/// Maximum exact messages resolved by one ordered product lookup.
37pub const MAX_PRODUCT_MESSAGE_LOOKUPS: usize = 512;
38/// Maximum exact media operations resolved by one ordered lookup.
39pub const MAX_PRODUCT_MEDIA_OPERATION_LOOKUPS: usize = 512;
40/// Maximum UTF-8 bytes in a portable message draft.
41///
42/// This is only the cheap single-field ceiling. Before any durable or platform
43/// effect, the SDK measures the complete rumor, seal, and wrapper and may
44/// reject a smaller draft whose tags consume the remaining wire budget.
45pub const MAX_PRODUCT_MESSAGE_BYTES: usize = crate::MAX_NIP44_WRITER_PLAINTEXT_BYTES;
46/// Maximum UTF-8 bytes in one search query.
47pub const MAX_PRODUCT_SEARCH_BYTES: usize = 512;
48/// Maximum portable rich-text spans in one draft.
49pub const MAX_PRODUCT_DRAFT_SPANS: usize = 256;
50/// Maximum logical attachment preparation references in one draft.
51pub const MAX_PRODUCT_DRAFT_ATTACHMENTS: usize = crate::MAX_CHAT_ATTACHMENTS;
52/// Maximum compact JSON bytes reserved for one outgoing `imeta` tag.
53///
54/// Media preparation reserves this complete amount before upload; completion
55/// accepts only metadata that fits the same reservation.
56pub const MAX_PRODUCT_ATTACHMENT_TAG_BYTES: usize = 2 * 1024;
57/// Maximum UTF-8 bytes in one local transcript.
58pub const MAX_PRODUCT_TRANSCRIPT_BYTES: usize = 64 * 1024;
59/// Maximum normalized waveform samples stored for one message.
60pub const MAX_PRODUCT_WAVEFORM_SAMPLES: usize = 8_192;
61/// Maximum UTF-8 bytes in retained settings JSON.
62pub const MAX_ACCOUNT_SETTINGS_BYTES: usize = 64 * 1024;
63/// Current portable settings schema.
64pub const ACCOUNT_SETTINGS_SCHEMA_VERSION: u32 = 1;
65/// Current portable read-state schema.
66pub const ACCOUNT_READ_STATE_SCHEMA_VERSION: u32 = 1;
67/// Maximum UTF-8 bytes in one complete read-state snapshot.
68pub const MAX_ACCOUNT_READ_STATE_BYTES: usize = 256 * 1024;
69/// Maximum conversations retained in one cross-device read-state snapshot.
70pub const MAX_ACCOUNT_READ_STATE_ENTRIES: usize = 4_096;
71
72const SWIFT_REFERENCE_DATE_UNIX_SECONDS: i64 = 978_307_200;
73
74/// Stable newest-first message paging cursor.
75#[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    /// Timestamp of the last row from the preceding page.
80    pub created_at: i64,
81    /// Event ID of the last row from the preceding page.
82    pub message_id: String,
83}
84
85/// Effective cross-device read state for one conversation.
86#[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    /// Stable participant-set conversation ID.
91    pub conversation_id: String,
92    /// Stable read boundary, absent before any message was marked read.
93    pub boundary: Option<MessageCursor>,
94    /// Explicit manual unread state, cleared by the next mark-read action.
95    pub forced_unread: bool,
96    /// Portable timestamp of the state-changing action.
97    pub updated_at: i64,
98    /// Stable tie-breaker and idempotency identifier for that action.
99    pub update_id: String,
100}
101
102/// Stable conversation-list paging cursor.
103#[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    /// Whether the last row was pinned.
108    pub pinned: bool,
109    /// Activity timestamp of the last row.
110    pub last_activity_at: i64,
111    /// Conversation ID of the last row.
112    pub conversation_id: String,
113}
114
115/// Complete user-authored message input.
116#[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    /// Plain UTF-8 message text.
121    pub text: String,
122    /// Validated uploaded attachment metadata.
123    pub attachments: Vec<AttachmentMetadata>,
124    /// Exact canonical `emoji` tag arrays.
125    pub emoji_tags: Vec<Vec<String>>,
126}
127
128/// Complete profile fields after an account-owned merge update.
129#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
130#[serde(rename_all = "camelCase", deny_unknown_fields)]
131pub(crate) struct ProfileUpdateInput {
132    /// Short account name.
133    pub name: Option<String>,
134    /// Human-readable display name.
135    pub display_name: Option<String>,
136    /// Profile description.
137    pub about: Option<String>,
138    /// HTTPS avatar URL.
139    pub picture: Option<String>,
140    /// HTTPS website URL.
141    pub website: Option<String>,
142    /// HTTPS banner URL.
143    pub banner: Option<String>,
144    /// Optional automation marker.
145    pub bot: Option<bool>,
146    /// Canonical object of unrecognized fields retained by a prior read.
147    pub unknown_json: String,
148}
149
150/// User-selected reaction input.
151#[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    /// Unicode reaction or `:shortcode:`.
156    pub value: String,
157    /// HTTPS custom-emoji URL, absent for Unicode.
158    pub custom_emoji_url: Option<String>,
159}
160
161/// Supported released push-registration wire profiles.
162#[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    /// Android Firebase Cloud Messaging.
167    Fcm,
168    /// Apple Push Notification service.
169    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/// One effective reaction attached to a message.
246#[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    /// Reaction event ID.
251    pub id: String,
252    /// Authenticated reaction author.
253    pub author_public_key: String,
254    /// Unicode reaction or `:shortcode:`.
255    pub value: String,
256    /// HTTPS custom-emoji URL, empty for Unicode.
257    pub custom_emoji_url: String,
258    /// Portable event timestamp.
259    pub created_at: i64,
260}
261
262/// Complete effective message consumed by product UI.
263#[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    /// Canonical logical message ID.
268    pub id: String,
269    /// Stable participant-set conversation ID.
270    pub conversation_id: String,
271    /// Authenticated logical author.
272    pub author_public_key: String,
273    /// Portable event timestamp.
274    pub created_at: i64,
275    /// Effective text after an authorized edit.
276    pub text: String,
277    /// Original authenticated attachment metadata.
278    pub attachments: Vec<AttachmentMetadata>,
279    /// Effective custom-emoji tags.
280    pub emoji_tags: Vec<Vec<String>>,
281    /// Reply/forward relation.
282    pub relation: ChatRelationKind,
283    /// Related message ID, empty when unrelated.
284    pub related_message_id: String,
285    /// Original author for a forward, empty otherwise.
286    pub forwarded_author_public_key: String,
287    /// Whether an authorized edit changed the visible text.
288    pub edited: bool,
289    /// Whether an authorized deletion dominates this message.
290    pub deleted: bool,
291    /// Number of live replies.
292    pub reply_count: u32,
293    /// Bounded effective reactions in deterministic order; older reactions may
294    /// be omitted. Toggle commands query authoritative local state separately.
295    pub reactions: Vec<MessageReactionView>,
296}
297
298/// One bounded page of messages.
299#[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    /// Newest-first rows.
304    pub messages: Vec<AccountMessageView>,
305    /// Cursor for the next page, absent when this page is terminal.
306    pub next_cursor: Option<MessageCursor>,
307}
308
309/// Portable semantic span kind for one draft.
310#[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 presentation.
315    Bold,
316    /// Italic presentation.
317    Italic,
318    /// Inline code presentation.
319    Code,
320    /// HTTPS link whose URL is stored in the span value.
321    Link,
322    /// Mention whose canonical public key is stored in the span value.
323    Mention,
324    /// Custom emoji whose HTTPS URL is stored in the span value.
325    CustomEmoji,
326}
327
328/// One bounded half-open UTF-16 range in a portable draft.
329#[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    /// Inclusive UTF-16 code-unit offset.
334    pub start_utf16: u32,
335    /// Exclusive UTF-16 code-unit offset.
336    pub end_utf16: u32,
337    /// Portable semantic span kind.
338    pub kind: DraftSpanKind,
339    /// Kind-specific canonical value, empty for presentation-only spans.
340    pub value: String,
341}
342
343/// One portable draft stored with its conversation.
344#[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    /// Plain portable text. Platform attributed-text state is reconstructed.
349    pub text: String,
350    /// Exact custom-emoji tags referenced by the text.
351    pub emoji_tags: Vec<Vec<String>>,
352    /// Portable validated rich-text ranges.
353    pub spans: Vec<DraftSpan>,
354    /// Replied message ID, empty when the draft is not a reply.
355    pub reply_to_message_id: String,
356    /// Stable logical media-operation IDs referenced by this draft.
357    pub attachment_operation_ids: Vec<String>,
358    /// Last durable update time.
359    pub updated_at: i64,
360}
361
362/// Sparse account-local non-protocol data attached to one message.
363#[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    /// Stable authenticated message ID.
368    pub message_id: String,
369    /// Optional local transcript, never sent as protocol content.
370    pub transcript: Option<String>,
371    /// Normalized unsigned amplitude samples in the range 0 through 255.
372    pub waveform: Vec<u8>,
373    /// Last durable local update time.
374    pub updated_at: i64,
375}
376
377/// Complete conversation state consumed by product UI.
378#[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    /// Stable participant-set identifier.
383    pub id: String,
384    /// Sorted canonical participant public keys including the local account.
385    pub member_public_keys: Vec<String>,
386    /// Latest authenticated subject text, empty when unset or cleared.
387    pub subject: String,
388    /// Latest authenticated subject icon, absent when unset.
389    pub subject_icon: Option<AttachmentMetadata>,
390    /// Newest live message, absent for a newly created empty conversation.
391    pub last_message: Option<AccountMessageView>,
392    /// Number of live messages.
393    pub message_count: u32,
394    /// Number of unread messages authored by another participant.
395    pub unread_count: u32,
396    /// Whether the local account archived this conversation.
397    pub archived: bool,
398    /// Whether the local account pinned this conversation.
399    pub pinned: bool,
400    /// Durable tuple read boundary, absent before the first explicit read action.
401    pub read_cursor: Option<MessageCursor>,
402    /// Whether the conversation was explicitly marked unread.
403    pub forced_unread: bool,
404    /// Portable draft, absent when empty.
405    pub draft: Option<AccountDraft>,
406    /// Timestamp used for stable list ordering.
407    pub last_activity_at: i64,
408}
409
410/// One bounded page of complete conversations.
411#[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    /// Ordered complete rows.
416    pub conversations: Vec<ConversationRecord>,
417    /// Cursor for the next page, absent when terminal.
418    pub next_cursor: Option<ConversationCursor>,
419}
420
421/// Effective authenticated or explicitly local profile.
422#[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    /// Canonical public key.
427    pub public_key: String,
428    /// Short account name.
429    pub name: Option<String>,
430    /// Human-readable display name.
431    pub display_name: Option<String>,
432    /// Profile description.
433    pub about: Option<String>,
434    /// Avatar URL.
435    pub picture: Option<String>,
436    /// Website URL.
437    pub website: Option<String>,
438    /// Banner URL.
439    pub banner: Option<String>,
440    /// Optional automation marker.
441    pub bot: Option<bool>,
442    /// Canonical object containing authenticated fields unknown to this SDK version.
443    pub unknown_json: String,
444    /// Whether the visible name comes from Android-local address-book state.
445    pub local_override: bool,
446    /// Timestamp of the selected value.
447    pub updated_at: i64,
448    /// Earliest stored authenticated non-ephemeral event by this author, if known.
449    pub first_seen_at: Option<i64>,
450}
451
452/// Versioned platform-neutral account settings.
453#[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    /// Case-insensitive reaction shortcode without surrounding colons.
458    pub shortcode: String,
459    /// HTTPS image URL.
460    pub url: String,
461}
462
463/// Versioned platform-neutral account settings.
464#[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    /// Settings schema version.
469    pub schema_version: u32,
470    /// Default reaction text or shortcode.
471    pub quick_reaction: Option<String>,
472    /// Whether typing indicators are authored and rendered.
473    pub typing_indicators_enabled: bool,
474    /// `full`, `sender`, or `hidden`.
475    pub notification_content_privacy: String,
476    /// Selected HTTPS media service, absent for deployment default.
477    pub media_service: Option<String>,
478    /// Semantic image quality (`low`, `balanced`, or `high`).
479    pub image_upload_quality: String,
480    /// Semantic video quality (`low`, `balanced`, or `high`).
481    pub video_upload_quality: String,
482    /// Whether supported platforms should retain HDR.
483    pub preserve_hdr: bool,
484    /// BCP-47 transcription locale, absent for system selection.
485    pub transcription_locale: Option<String>,
486    /// Canonical JSON array of portable domain replacement rules.
487    pub domain_replacement_rules_json: String,
488    /// Portable wallpaper identifier shared by clients, absent for the default.
489    pub chat_wallpaper: Option<String>,
490    /// Validated portable custom emoji references.
491    pub custom_emoji_references: Vec<CustomEmojiReference>,
492    /// Stable saved sticker references.
493    pub saved_sticker_references: Vec<String>,
494    /// Shared seasonal appearance choice, absent for default.
495    pub seasonal_appearance: Option<String>,
496    /// Canonical object containing fields unknown to this SDK version.
497    pub unknown_json: String,
498}
499
500/// Result of a committed settings patch.
501#[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    /// Complete effective settings after the patch.
506    pub settings: AccountSettings,
507    /// Canonical complete JSON used by kind-30079 synchronization.
508    pub canonical_json: String,
509    /// Durable account revision after commit.
510    pub revision: i64,
511}
512
513/// Logical media-operation state.
514#[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    /// Durable intent exists but no platform transfer has started.
519    Prepared,
520    /// A platform executor owns a bounded lease.
521    Running,
522    /// Authenticated attachment metadata was committed.
523    Completed,
524    /// Retryable failure awaits policy or user action.
525    Retryable,
526    /// Permanent failure cannot be retried without new input.
527    Failed,
528    /// Cancellation completed before message publication.
529    Cancelled,
530}
531
532/// Confidentiality policy for one platform-executed media transfer.
533#[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    /// Encrypt upload bytes before they leave private staging.
538    Encrypted,
539    /// Upload the selected bytes without content encryption for a public asset.
540    Public,
541}
542
543/// One durable logical media operation.
544#[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    /// SDK-generated stable operation ID.
549    pub id: String,
550    /// User-action idempotency key.
551    pub command_id: String,
552    /// Owning conversation, empty for profile media.
553    pub conversation_id: String,
554    /// Authenticated source message for downloads, empty for uploads.
555    pub source_message_id: String,
556    /// `upload` or `download`.
557    pub direction: String,
558    /// Whether the platform encrypts the transferred object.
559    pub protection: AccountMediaProtection,
560    /// Current durable state.
561    pub state: AccountMediaOperationState,
562    /// Platform-computed stable source fingerprint.
563    pub source_fingerprint: String,
564    /// Final validated metadata, absent before completion.
565    pub attachment: Option<AttachmentMetadata>,
566    /// Processed byte count when known.
567    pub byte_count: Option<u64>,
568    /// Number of started platform attempts.
569    pub attempt_count: u32,
570    /// Stable redacted failure category.
571    pub last_error_category: String,
572    /// Last durable update time.
573    pub updated_at: i64,
574}
575
576/// One stable page of non-terminal media operations for a platform executor.
577#[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    /// Recoverable operations ordered by stable operation ID.
582    pub operations: Vec<AccountMediaOperation>,
583    /// Cursor for the next page, absent when this page reaches the end.
584    pub next_cursor: Option<String>,
585}
586
587/// Exclusive bounded lease for one platform-owned media transfer.
588#[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    /// Host-generated idempotent lease identifier.
593    pub lease_id: String,
594    /// Absolute wall-clock expiry persisted by Rust.
595    pub expires_at: i64,
596    /// Complete logical operation after the claim commits.
597    pub operation: AccountMediaOperation,
598}
599
600/// Logical state of one attachment-dependent outgoing message.
601#[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    /// Durable message intent is waiting for one or more platform uploads.
606    WaitingForUploads,
607    /// Every attachment completed and the message can be authored.
608    ReadyToPublish,
609    /// The complete outgoing message operation was committed.
610    Published,
611    /// A required attachment failed permanently.
612    Failed,
613    /// The user cancelled a required attachment before publication.
614    Cancelled,
615}
616
617/// One platform source selected for an attachment-dependent message.
618#[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    /// Stable fingerprint of the private staged source bytes and transformation intent.
623    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/// Durable message intent with its ordered logical attachment operations.
688#[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    /// Stable user-action idempotency key, reused by the eventual send operation.
693    pub command_id: String,
694    /// Owning conversation.
695    pub conversation_id: String,
696    /// Plain message text retained before publication.
697    pub text: String,
698    /// Exact canonical custom-emoji tags.
699    pub emoji_tags: Vec<Vec<String>>,
700    /// Optional replied message ID, empty when unrelated.
701    pub reply_to_message_id: String,
702    /// Current logical dependency state.
703    pub state: PendingMediaMessageState,
704    /// Ordered required upload operations.
705    pub attachments: Vec<AccountMediaOperation>,
706    /// Published message ID, empty before publication.
707    pub result_message_id: String,
708    /// Last durable update time.
709    pub updated_at: i64,
710}
711
712/// Public asset changed after one durable upload dependency completes.
713#[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    /// Replace the local account's public profile picture URL.
718    ProfilePicture,
719    /// Replace the local account's public profile banner URL.
720    ProfileBanner,
721    /// Replace one conversation's public subject icon.
722    ConversationIcon,
723}
724
725/// Logical state of one upload-dependent public asset replacement.
726#[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    /// Durable intent exists and waits for the platform upload.
731    WaitingForUpload,
732    /// The public upload completed and the dependent event can be authored.
733    ReadyToPublish,
734    /// The dependent profile or subject event was durably queued.
735    Published,
736    /// The required upload failed permanently.
737    Failed,
738    /// The user cancelled the upload before publication.
739    Cancelled,
740}
741
742/// One durable public asset replacement and its platform upload dependency.
743#[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    /// Stable user-action idempotency key reused by the dependent event.
748    pub command_id: String,
749    /// Profile or conversation field replaced by the completed upload.
750    pub target: AssetReplacementTarget,
751    /// Owning conversation for a subject icon, empty for profile assets.
752    pub conversation_id: String,
753    /// Current dependency and publication state.
754    pub state: PendingAssetReplacementState,
755    /// Single public upload operation.
756    pub operation: AccountMediaOperation,
757    /// Durable outgoing operation ID, empty before publication.
758    pub result_operation_id: String,
759    /// Last durable update time.
760    pub updated_at: i64,
761}
762
763/// Result of acknowledging one completed attachment lease.
764#[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    /// Completed logical attachment operation.
769    pub operation: AccountMediaOperation,
770    /// Newly published message, profile, or subject operation when ready.
771    pub completed_operation: Option<ProductOperationResult>,
772}
773
774/// One attachment projected into an account media gallery.
775#[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    /// Owning message ID.
780    pub message_id: String,
781    /// Owning conversation ID.
782    pub conversation_id: String,
783    /// Authenticated message author.
784    pub author_public_key: String,
785    /// Message timestamp.
786    pub created_at: i64,
787    /// Validated attachment metadata.
788    pub attachment: AttachmentMetadata,
789}
790
791/// High-level command result with the complete effective message when relevant.
792#[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    /// Durable delivery aggregate.
797    pub operation: OperationSnapshot,
798    /// Durable account revision after the command.
799    pub revision: i64,
800    /// False when an identical command was already committed.
801    pub inserted: bool,
802    /// Created or affected message, absent for non-message commands.
803    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(&current)?;
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        // Filter before applying the bound: arbitrarily many intervening text
2693        // messages must not make an older attachment unreachable. Authenticated
2694        // chat parsing guarantees each selected imeta tag is an attachment.
2695        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            // The public cursor addresses messages, so splitting this group
2746            // would permanently skip its remaining attachments on the next page.
2747            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    /// Durably choose publication over cancellation before authoring the message.
3071    ///
3072    /// The claim is intentionally not exposed as another product state. A
3073    /// process that stops after this commit leaves the message ready for the
3074    /// normal recovery path, while cancellation can no longer report success
3075    /// for an operation that may already have been enqueued.
3076    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            &current_subject.icon_event_id,
3866            &current_attachment,
3867        )?;
3868        if operation.source_fingerprint != current_fingerprint
3869            || operation.attachment.as_ref() != Some(&current_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    /// Load authoritative local toggle state independently of display paging.
4367    /// One deletion rumor supports at most 256 targets; reject larger groups
4368    /// before authoring so a successful toggle cannot leave active copies.
4369    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    // SQLite lower()/LIKE fold ASCII only. Preserve every other code point so
5775    // an exact Unicode query cannot be changed into a pattern that never matches.
5776    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(&current, 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}