iris-chat 0.1.61

Iris Chat command line client and shared encrypted chat core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
use crate::DirectFileTransferSnapshot;
use serde::{Deserialize, Serialize};

#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum Screen {
    Welcome,
    CreateAccount,
    RestoreAccount,
    RemoteSigner,
    AddDevice,
    ChatList,
    NewChat,
    NewGroup,
    CreateInvite,
    JoinInvite,
    Settings,
    Chat { chat_id: String },
    DirectChatInfo { chat_id: String },
    GroupDetails { group_id: String },
    DeviceRoster,
    DeviceRevoked,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct Router {
    pub default_screen: Screen,
    pub screen_stack: Vec<Screen>,
}

#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
pub struct BusyState {
    pub creating_account: bool,
    pub restoring_session: bool,
    pub linking_device: bool,
    pub creating_chat: bool,
    pub creating_group: bool,
    pub sending_message: bool,
    pub updating_roster: bool,
    pub updating_group: bool,
    pub creating_invite: bool,
    pub accepting_invite: bool,
    pub syncing_network: bool,
    pub uploading_attachment: bool,
    pub upload_progress: Option<UploadProgress>,
}

#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
pub struct UploadProgress {
    pub bytes_uploaded: u64,
    pub total_bytes: u64,
}

#[derive(
    uniffi::Record, Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct ChatMuteDeadline {
    pub chat_id: String,
    pub until_secs: u64,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct PreferencesSnapshot {
    pub voice_calls_enabled: bool,
    pub video_calls_enabled: bool,
    pub call_quality: String,
    pub call_max_bitrate_bps: u32,
    pub send_typing_indicators: bool,
    pub send_read_receipts: bool,
    #[uniffi(default = true)]
    pub allow_message_deletion_by_others: bool,
    /// Reversibly hide saved group messages written by blocked people.
    #[uniffi(default = false)]
    pub hide_blocked_group_messages: bool,
    pub desktop_notifications_enabled: bool,
    pub invite_acceptance_notifications_enabled: bool,
    pub startup_at_login_enabled: bool,
    pub nearby_enabled: bool,
    pub nearby_bluetooth_enabled: bool,
    pub nearby_lan_enabled: bool,
    pub nearby_show_in_chat_list: bool,
    /// Whether the on-device nearby mailbag actively reads and writes
    /// store-and-forward records. When off, the local bag is left
    /// alone (so contents survive a toggle off → on cycle) but no new
    /// events are stored and the broadcast/replay path skips it.
    pub nearby_mailbag_enabled: bool,
    pub nostr_relay_urls: Vec<String>,
    pub image_proxy_enabled: bool,
    pub image_proxy_fallback_enabled: bool,
    pub image_proxy_url: String,
    pub image_proxy_key_hex: String,
    pub image_proxy_salt_hex: String,
    pub muted_chat_ids: Vec<String>,
    pub timed_chat_mutes: Vec<ChatMuteDeadline>,
    /// Muted groups where even identity-bearing @mentions stay silent.
    #[uniffi(default = [])]
    pub silent_mention_chat_ids: Vec<String>,
    pub pinned_chat_ids: Vec<String>,
    /// Owner pubkeys (hex) the local user has blocked. Blocking drops
    /// the peer from the nostr relay subscription, the mobile push
    /// subscription, and hides their thread from the chat list; any
    /// further messages from them are also discarded at ingest.
    pub blocked_owner_pubkeys: Vec<String>,
    /// Owner pubkeys (hex) for which the user has accepted a message
    /// request (Signal-style "whitelist"). A direct chat is treated as
    /// a `is_request` thread until the peer is in this set; sending an
    /// outgoing message implicitly adds them.
    pub accepted_owner_pubkeys: Vec<String>,
    pub debug_logging_enabled: bool,
    pub accept_unknown_direct_messages: bool,
    /// User-configurable notification server URL. Empty string means
    /// "use the platform default" (notifications.iris.to in release,
    /// notifications-sandbox.iris.to in debug). When non-empty, the
    /// shells should pass this as the override to
    /// `build_mobile_push_*_subscription_request`.
    pub mobile_push_server_url: String,
}

impl Default for PreferencesSnapshot {
    fn default() -> Self {
        Self {
            voice_calls_enabled: true,
            video_calls_enabled: true,
            call_quality: "auto".into(),
            call_max_bitrate_bps: 2_000_000,
            send_typing_indicators: true,
            send_read_receipts: true,
            allow_message_deletion_by_others: true,
            hide_blocked_group_messages: false,
            desktop_notifications_enabled: true,
            invite_acceptance_notifications_enabled: true,
            startup_at_login_enabled: true,
            nearby_enabled: true,
            nearby_bluetooth_enabled: false,
            nearby_lan_enabled: false,
            nearby_show_in_chat_list: true,
            nearby_mailbag_enabled: true,
            nostr_relay_urls: crate::core::configured_relays(),
            image_proxy_enabled: true,
            image_proxy_fallback_enabled: false,
            image_proxy_url: crate::image_proxy::DEFAULT_IMAGE_PROXY_URL.to_string(),
            image_proxy_key_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_KEY_HEX.to_string(),
            image_proxy_salt_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_SALT_HEX.to_string(),
            muted_chat_ids: Vec::new(),
            timed_chat_mutes: Vec::new(),
            silent_mention_chat_ids: Vec::new(),
            pinned_chat_ids: Vec::new(),
            blocked_owner_pubkeys: Vec::new(),
            accepted_owner_pubkeys: Vec::new(),
            debug_logging_enabled: false,
            accept_unknown_direct_messages: true,
            mobile_push_server_url: String::new(),
        }
    }
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct OutgoingAttachment {
    pub file_path: String,
    pub filename: String,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct AttachmentDownloadResult {
    pub data_base64: Option<String>,
    pub error: Option<String>,
}

#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum DeviceAuthorizationState {
    Authorized,
    AwaitingApproval,
    Revoked,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct AccountSnapshot {
    pub public_key_hex: String,
    pub npub: String,
    pub display_name: String,
    pub picture_url: Option<String>,
    pub about: Option<String>,
    pub device_public_key_hex: String,
    pub device_npub: String,
    pub has_owner_signing_authority: bool,
    pub authorization_state: DeviceAuthorizationState,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct DeviceEntrySnapshot {
    pub display_name: String,
    pub device_pubkey_hex: String,
    pub device_npub: String,
    pub is_current_device: bool,
    pub is_connected: bool,
    pub is_authorized: bool,
    pub is_stale: bool,
    pub added_at_secs: Option<u64>,
    pub device_label: Option<String>,
    pub client_label: Option<String>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct DeviceRosterSnapshot {
    pub owner_public_key_hex: String,
    pub owner_npub: String,
    pub current_device_public_key_hex: String,
    pub current_device_npub: String,
    pub can_manage_devices: bool,
    pub authorization_state: DeviceAuthorizationState,
    pub devices: Vec<DeviceEntrySnapshot>,
}

#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum DeliveryState {
    Queued,
    Pending,
    Sent,
    Received,
    Seen,
    Failed,
}

#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum ChatKind {
    Direct,
    Group,
}

#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChatMessageKind {
    User,
    System,
}

#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessageAttachmentSnapshot {
    /// Local historical receipts for complete encrypted attachment trees.
    #[serde(default)]
    #[uniffi(default = [])]
    pub uploaded_urls: Vec<String>,
    pub nhash: String,
    pub filename: String,
    pub filename_encoded: String,
    pub htree_url: String,
    pub is_image: bool,
    pub is_video: bool,
    pub is_audio: bool,
}

#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessageReactionSnapshot {
    pub emoji: String,
    pub count: u64,
    pub reacted_by_me: bool,
}

#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessageReactor {
    /// Hex-encoded pubkey of the user who reacted.
    pub author: String,
    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub display_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub picture_url: Option<String>,
    /// Emoji content of their current (latest) reaction. Empty means unreacted.
    pub emoji: String,
}

#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessageRecipientDeliverySnapshot {
    /// Hex-encoded owner/user pubkey.
    pub owner_pubkey_hex: String,
    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub display_name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub picture_url: Option<String>,
    pub delivery: DeliveryState,
    pub updated_at_secs: u64,
}

#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct MessageDeliveryTraceSnapshot {
    pub outer_event_ids: Vec<String>,
    pub pending_relay_event_ids: Vec<String>,
    pub queued_protocol_targets: Vec<String>,
    pub transport_channels: Vec<String>,
    pub last_transport_error: Option<String>,
}

/// A local call summary. Signaling and media never enter message history.
#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct CallHistorySnapshot {
    pub call_id: String,
    pub direction: String,
    pub outcome: String,
    pub video: bool,
    pub started_at_secs: u64,
    pub answered_at_secs: Option<u64>,
    pub ended_at_secs: u64,
    pub duration_secs: u64,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct MessageEditSnapshot {
    pub id: String,
    pub body: String,
    pub created_at_secs: u64,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct ChatMessageSnapshot {
    #[uniffi(default = [])]
    pub edit_history: Vec<MessageEditSnapshot>,
    #[uniffi(default = false)]
    pub deleted_for_everyone: bool,
    /// Profile subject of a system notice, independent of the message author.
    #[uniffi(default = None)]
    pub system_notice_owner_pubkey_hex: Option<String>,
    #[uniffi(default = None)]
    pub direct_transfer: Option<DirectFileTransferSnapshot>,
    pub id: String,
    pub chat_id: String,
    pub kind: ChatMessageKind,
    pub author: String,
    /// Hex-encoded owner/user pubkey for the author when known.
    pub author_owner_pubkey_hex: Option<String>,
    pub author_picture_url: Option<String>,
    pub body: String,
    pub attachments: Vec<MessageAttachmentSnapshot>,
    pub reactions: Vec<MessageReactionSnapshot>,
    pub reactors: Vec<MessageReactor>,
    pub is_outgoing: bool,
    pub created_at_secs: u64,
    pub expires_at_secs: Option<u64>,
    pub delivery: DeliveryState,
    pub recipient_deliveries: Vec<MessageRecipientDeliverySnapshot>,
    pub delivery_trace: MessageDeliveryTraceSnapshot,
    /// Hex ID of the outer relay event that carried this rumor. The
    /// notification extension joins on this to find a body the
    /// foreground app already decrypted, so it can render a real
    /// preview instead of "New activity". `None` for messages that
    /// didn't come over the wire (system notices, locally-composed
    /// outgoing rumors).
    pub source_event_id: Option<String>,
    #[uniffi(default = None)]
    pub call: Option<CallHistorySnapshot>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct TypingIndicatorSnapshot {
    pub chat_id: String,
    pub display_name: String,
    pub expires_at_secs: u64,
}

/// A social connection, not identity verification. Computed by nostr-social-graph.
#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum SocialBadge {
    Warning,
    Following,
    Friend,
    Trusted,
    Muted,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct SocialConnectionSnapshot {
    pub badge: Option<SocialBadge>,
    pub follow_distance: Option<u32>,
    pub followed_by_friends: u32,
    pub description: String,
    /// Private account contact preference, independent of public follow relationships.
    #[uniffi(default = false)]
    pub is_favorite: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct MentionNotificationSnapshot {
    pub message_id: String,
    pub preview: String,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct ChatThreadSnapshot {
    /// A newly authenticated incoming chat in this runtime. Never inferred from
    /// unread counts or restored history; replayed handshakes leave it unchanged.
    #[uniffi(default = None)]
    pub new_chat_notification_id: Option<String>,
    pub social_connection: Option<SocialConnectionSnapshot>,
    pub chat_id: String,
    pub kind: ChatKind,
    pub display_name: String,
    pub nickname: Option<String>,
    pub contact_note: Option<String>,
    pub profile_name: Option<String>,
    pub subtitle: Option<String>,
    pub picture_url: Option<String>,
    pub about: Option<String>,
    pub member_count: u64,
    pub last_message_preview: Option<String>,
    pub last_message_at_secs: Option<u64>,
    pub last_message_is_outgoing: Option<bool>,
    #[uniffi(default = None)]
    pub last_message_id: Option<String>,
    #[uniffi(default = [])]
    pub unread_mention_messages: Vec<MentionNotificationSnapshot>,
    pub last_message_delivery: Option<DeliveryState>,
    pub unread_count: u64,
    pub is_typing: bool,
    pub is_muted: bool,
    pub is_pinned: bool,
    /// Unsent composer text the user typed in this thread, persisted
    /// across navigation / suspend / relaunch. Chat list rows render
    /// "Draft: …" in place of the last message preview when this is
    /// non-empty (Signal pattern).
    pub draft: String,
    /// True when this is a direct chat where the user has not yet
    /// replied to a stranger — i.e. incoming messages exist but no
    /// outgoing ones do. Shells render an Accept / Delete / Block
    /// gate (Signal-style "message request"), suppress read /
    /// delivery / typing receipts, and treat the conversation as
    /// untrusted until the user accepts.
    pub is_request: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct ChatParticipantSnapshot {
    pub social_connection: Option<SocialConnectionSnapshot>,
    pub owner_pubkey_hex: String,
    pub display_name: String,
    pub picture_url: Option<String>,
    pub is_local_owner: bool,
}

/// Capability of the selected direct-chat peer. Profile search results are
/// locators only; the composer unlocks after current owner-signed evidence is
/// resolved. Group chats expose `None`.
#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum DirectChatCapabilityState {
    Checking,
    Available,
    Unavailable,
    CheckFailed,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct ContactIdentitySnapshot {
    pub is_following: bool,
    pub can_follow: bool,
    pub updating_follow: bool,
    pub first_seen_name: Option<String>,
    pub saved_name: Option<String>,
    pub pending_name: Option<String>,
    pub is_favorite: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct CurrentChatSnapshot {
    pub contact_identity: Option<ContactIdentitySnapshot>,
    pub social_connection: Option<SocialConnectionSnapshot>,
    pub chat_id: String,
    pub kind: ChatKind,
    pub display_name: String,
    pub nickname: Option<String>,
    pub contact_note: Option<String>,
    pub profile_name: Option<String>,
    pub subtitle: Option<String>,
    pub picture_url: Option<String>,
    pub about: Option<String>,
    pub group_id: Option<String>,
    pub member_count: u64,
    pub message_ttl_seconds: Option<u64>,
    pub is_muted: bool,
    pub participants: Vec<ChatParticipantSnapshot>,
    pub messages: Vec<ChatMessageSnapshot>,
    pub typing_indicators: Vec<TypingIndicatorSnapshot>,
    /// Same persisted draft text exposed on `ChatThreadSnapshot`. The
    /// chat screen pre-fills its composer with this on first appear.
    pub draft: String,
    /// Mirrors `ChatThreadSnapshot::is_request`. Chat screens replace
    /// the composer with an Accept / Delete / Block gate when set.
    pub is_request: bool,
    pub direct_chat_capability: Option<DirectChatCapabilityState>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct GroupMemberSnapshot {
    pub social_connection: Option<SocialConnectionSnapshot>,
    pub owner_pubkey_hex: String,
    pub display_name: String,
    pub npub: String,
    pub picture_url: Option<String>,
    pub is_admin: bool,
    pub is_creator: bool,
    pub is_local_owner: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct GroupDetailsSnapshot {
    pub group_id: String,
    pub name: String,
    pub picture_url: Option<String>,
    /// Free-text group description (Signal calls this the group description).
    /// `None` if unset. Admins can edit via `AppAction::UpdateGroupAbout`.
    pub about: Option<String>,
    pub created_by_display_name: String,
    pub created_by_npub: String,
    pub can_manage: bool,
    pub is_muted: bool,
    pub revision: u64,
    pub members: Vec<GroupMemberSnapshot>,
    pub invite_links: Vec<GroupInviteSnapshot>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct GroupInviteSnapshot {
    pub id: String,
    pub url: String,
    pub title: String,
    pub use_count: u32,
    pub max_uses: Option<u32>,
    pub expires_at: Option<u64>,
}

#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
pub struct MutualGroupsSnapshot {
    pub groups: Vec<ChatThreadSnapshot>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct RelayConnectionSnapshot {
    pub url: String,
    pub status: String,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct NetworkStatusSnapshot {
    pub relay_set_id: String,
    pub relay_urls: Vec<String>,
    pub relay_connections: Vec<RelayConnectionSnapshot>,
    pub connected_relay_count: u64,
    pub all_relays_offline_since_secs: Option<u64>,
    pub syncing: bool,
    pub pending_outbound_count: u64,
    pub pending_group_control_count: u64,
    pub recent_event_count: u64,
    pub recent_log_count: u64,
    pub last_debug_category: Option<String>,
    pub last_debug_detail: Option<String>,
}

#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
pub struct MobilePushSessionSnapshot {
    pub recipient_pubkey_hex: String,
    pub display_name: String,
    pub state_json: String,
    pub tracked_sender_pubkeys: Vec<String>,
    pub has_receiving_capability: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct MobilePushDelayedAuthor {
    pub author_pubkey: String,
    pub since_secs: u64,
}

#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
pub struct MobilePushSyncSnapshot {
    pub call_device_pubkey_hex: Option<String>,
    pub owner_pubkey_hex: Option<String>,
    pub message_author_pubkeys: Vec<String>,
    pub delayed_message_authors: Vec<MobilePushDelayedAuthor>,
    pub background_message_author_pubkeys: Vec<String>,
    pub invite_response_pubkeys: Vec<String>,
    pub sessions: Vec<MobilePushSessionSnapshot>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct PeerProfileDebugSnapshot {
    pub owner_pubkey_hex: String,
    pub owner_npub: String,
    pub direct_send_readiness: String,
    pub roster_device_count: u64,
    pub known_device_count: u64,
    pub active_session_count: u64,
    pub session_count: u64,
    pub receiving_session_count: u64,
    pub tracked_sender_count: u64,
    pub recent_handshake_device_count: u64,
    pub last_handshake_at_secs: Option<u64>,
    pub tracked_for_messages: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct MobilePushNotificationResolution {
    pub should_show: bool,
    pub title: String,
    pub body: String,
    pub payload_json: String,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct MobilePushSubscriptionRequest {
    pub method: String,
    pub url: String,
    pub authorization_header: String,
    pub body_json: Option<String>,
}

/// One row inside the "Messages" section of a search result. Shells
/// use the conversation identity for global results and the author identity
/// for results within a chat. Both are resolved from persisted history so
/// searching older messages does not depend on the loaded timeline.
#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct MessageSearchHit {
    pub chat_id: String,
    pub message_id: String,
    pub chat_display_name: String,
    pub chat_picture_url: Option<String>,
    pub chat_kind: ChatKind,
    pub author_pubkey: String,
    pub author_display_name: String,
    pub author_picture_url: Option<String>,
    pub body: String,
    pub is_outgoing: bool,
    pub created_at_secs: u64,
}

/// A person discovered from local profiles, the account's social graph, or the
/// global profile index. Chat capability is resolved only after selection;
/// shells only render these fields.
#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct FollowedUserSearchResult {
    pub social_connection: Option<SocialConnectionSnapshot>,
    pub owner_pubkey_hex: String,
    pub display_label: String,
    pub profile_label: Option<String>,
    pub picture_url: Option<String>,
    pub about: Option<String>,
    pub user_id: String,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct SearchResultSnapshot {
    pub query: String,
    pub scope_chat_id: Option<String>,
    pub people: Vec<FollowedUserSearchResult>,
    pub contacts: Vec<ChatThreadSnapshot>,
    pub groups: Vec<ChatThreadSnapshot>,
    pub messages: Vec<MessageSearchHit>,
    /// Inline shortcut row to show above the grouped sections when the
    /// query is itself an npub / nprofile / hex pubkey / invite URL.
    /// Shells render this as a single "Start chat with …" / "Accept
    /// invite from …" row that dispatches the carried action on tap.
    pub shortcut: Option<ChatInputShortcut>,
}

/// Classification of the free-text input typed into a chat-action
/// field — the search bar, the "New chat" paste field, the share-link
/// handler. Centralising the parsing here means the UI just looks at
/// the enum and dispatches; no platform has its own ad-hoc
/// `contains("://") && contains("#")` check.
#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum ChatInputShortcut {
    /// Input is a recognized owner pubkey (npub / nprofile / hex).
    /// `display` is the user-presentable short form (npub…).
    DirectPeer {
        peer_input: String,
        display: String,
        npub: String,
        pubkey_hex: String,
    },
    /// Input is a paste of an invite URL. `display` is a short label
    /// suitable for "Accept invite from …" rows.
    Invite {
        invite_input: String,
        display: String,
    },
}

impl SearchResultSnapshot {
    pub fn empty(query: String, scope_chat_id: Option<String>) -> Self {
        Self {
            query,
            scope_chat_id,
            people: Vec::new(),
            contacts: Vec::new(),
            groups: Vec::new(),
            messages: Vec::new(),
            shortcut: None,
        }
    }

    pub fn is_empty(&self) -> bool {
        self.people.is_empty()
            && self.contacts.is_empty()
            && self.groups.is_empty()
            && self.messages.is_empty()
            && self.shortcut.is_none()
    }
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct PublicInviteSnapshot {
    pub url: String,
    pub legacy_url: String,
    pub title: String,
    #[uniffi(default = true)]
    pub open_chat_on_join: bool,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct LinkDeviceSnapshot {
    pub url: String,
    pub device_input: String,
}

#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum RemoteSignerPhase {
    Connecting,
    WaitingForSigner,
    WaitingForApproval,
    Finishing,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct RemoteSignerLoginSnapshot {
    pub connection_uri: Option<String>,
    pub phase: RemoteSignerPhase,
    pub auth_url: Option<String>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct CallSnapshot {
    pub outgoing: bool,
    pub target_bitrate_bps: u32,
    pub key_frame_generation: u32,
    pub media_connected: bool,
    pub max_bitrate_bps: u32,
    pub call_id: String,
    pub chat_id: String,
    pub peer_name: String,
    pub phase: String,
    pub video: bool,
    pub video_capable: bool,
    pub muted: bool,
    pub remote_video: bool,
    pub remote_muted: bool,
    pub started_at_secs: u64,
    pub connected_at_secs: Option<u64>,
    pub end_reason: Option<String>,
}

#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
pub enum DeviceHistorySyncPhase {
    Discovering,
    Transferring,
    Waiting,
    Complete,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct DeviceHistorySyncSnapshot {
    pub phase: DeviceHistorySyncPhase,
    pub imported_messages: u64,
    pub total_messages: Option<u64>,
}

#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
pub struct AppState {
    pub rev: u64,
    pub call: Option<CallSnapshot>,
    pub router: Router,
    pub account: Option<AccountSnapshot>,
    pub device_roster: Option<DeviceRosterSnapshot>,
    pub device_history_sync: Option<DeviceHistorySyncSnapshot>,
    pub busy: BusyState,
    pub chat_list: Vec<ChatThreadSnapshot>,
    pub blocked_people: Vec<FollowedUserSearchResult>,
    /// Invalidates native message pages after visibility policy changes.
    pub message_visibility_revision: u64,
    pub current_chat: Option<CurrentChatSnapshot>,
    pub group_details: Option<GroupDetailsSnapshot>,
    pub public_invite: Option<PublicInviteSnapshot>,
    pub link_device: Option<LinkDeviceSnapshot>,
    pub remote_signer_login: Option<RemoteSignerLoginSnapshot>,
    pub network_status: Option<NetworkStatusSnapshot>,
    pub mobile_push: MobilePushSyncSnapshot,
    pub preferences: PreferencesSnapshot,
    /// Invalidates native People-search caches when discovery inputs, block
    /// exclusions, or discovery loading state change.
    pub user_discovery_revision: u64,
    pub user_discovery_syncing: bool,
    pub toast: Option<String>,
}

impl AppState {
    pub fn empty() -> Self {
        Self {
            rev: 0,
            call: None,
            router: Router {
                default_screen: Screen::Welcome,
                screen_stack: Vec::new(),
            },
            account: None,
            device_roster: None,
            device_history_sync: None,
            busy: BusyState::default(),
            chat_list: Vec::new(),
            blocked_people: Vec::new(),
            message_visibility_revision: 0,
            current_chat: None,
            group_details: None,
            public_invite: None,
            link_device: None,
            remote_signer_login: None,
            network_status: None,
            mobile_push: MobilePushSyncSnapshot::default(),
            preferences: PreferencesSnapshot::default(),
            user_discovery_revision: 0,
            user_discovery_syncing: false,
            toast: None,
        }
    }
}