Skip to main content

iris_chat_core/
state.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
4pub enum Screen {
5    Welcome,
6    CreateAccount,
7    RestoreAccount,
8    AddDevice,
9    ChatList,
10    NewChat,
11    NewGroup,
12    CreateInvite,
13    JoinInvite,
14    Settings,
15    Chat { chat_id: String },
16    DirectChatInfo { chat_id: String },
17    GroupDetails { group_id: String },
18    DeviceRoster,
19    DeviceRevoked,
20}
21
22#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
23pub struct Router {
24    pub default_screen: Screen,
25    pub screen_stack: Vec<Screen>,
26}
27
28#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
29pub struct BusyState {
30    pub creating_account: bool,
31    pub restoring_session: bool,
32    pub linking_device: bool,
33    pub creating_chat: bool,
34    pub creating_group: bool,
35    pub sending_message: bool,
36    pub updating_roster: bool,
37    pub updating_group: bool,
38    pub creating_invite: bool,
39    pub accepting_invite: bool,
40    pub syncing_network: bool,
41    pub uploading_attachment: bool,
42    pub upload_progress: Option<UploadProgress>,
43}
44
45#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
46pub struct UploadProgress {
47    pub bytes_uploaded: u64,
48    pub total_bytes: u64,
49}
50
51#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
52pub struct PreferencesSnapshot {
53    pub send_typing_indicators: bool,
54    pub send_read_receipts: bool,
55    pub desktop_notifications_enabled: bool,
56    pub invite_acceptance_notifications_enabled: bool,
57    pub startup_at_login_enabled: bool,
58    pub nearby_enabled: bool,
59    pub nearby_bluetooth_enabled: bool,
60    pub nearby_lan_enabled: bool,
61    pub nearby_show_in_chat_list: bool,
62    /// Whether the on-device nearby mailbag actively reads and writes
63    /// store-and-forward records. When off, the local bag is left
64    /// alone (so contents survive a toggle off → on cycle) but no new
65    /// events are stored and the broadcast/replay path skips it.
66    pub nearby_mailbag_enabled: bool,
67    pub nostr_relay_urls: Vec<String>,
68    pub image_proxy_enabled: bool,
69    pub image_proxy_fallback_enabled: bool,
70    pub image_proxy_url: String,
71    pub image_proxy_key_hex: String,
72    pub image_proxy_salt_hex: String,
73    pub muted_chat_ids: Vec<String>,
74    pub pinned_chat_ids: Vec<String>,
75    /// Owner pubkeys (hex) the local user has blocked. Blocking drops
76    /// the peer from the nostr relay subscription, the mobile push
77    /// subscription, and hides their thread from the chat list; any
78    /// further messages from them are also discarded at ingest.
79    pub blocked_owner_pubkeys: Vec<String>,
80    /// Owner pubkeys (hex) for which the user has accepted a message
81    /// request (Signal-style "whitelist"). A direct chat is treated as
82    /// a `is_request` thread until the peer is in this set; sending an
83    /// outgoing message implicitly adds them.
84    pub accepted_owner_pubkeys: Vec<String>,
85    pub debug_logging_enabled: bool,
86    pub accept_unknown_direct_messages: bool,
87    /// User-configurable notification server URL. Empty string means
88    /// "use the platform default" (notifications.iris.to in release,
89    /// notifications-sandbox.iris.to in debug). When non-empty, the
90    /// shells should pass this as the override to
91    /// `build_mobile_push_*_subscription_request`.
92    pub mobile_push_server_url: String,
93}
94
95impl Default for PreferencesSnapshot {
96    fn default() -> Self {
97        Self {
98            send_typing_indicators: false,
99            send_read_receipts: false,
100            desktop_notifications_enabled: true,
101            invite_acceptance_notifications_enabled: true,
102            startup_at_login_enabled: true,
103            nearby_enabled: true,
104            nearby_bluetooth_enabled: false,
105            nearby_lan_enabled: false,
106            nearby_show_in_chat_list: true,
107            nearby_mailbag_enabled: true,
108            nostr_relay_urls: crate::core::configured_relays(),
109            image_proxy_enabled: true,
110            image_proxy_fallback_enabled: false,
111            image_proxy_url: crate::image_proxy::DEFAULT_IMAGE_PROXY_URL.to_string(),
112            image_proxy_key_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_KEY_HEX.to_string(),
113            image_proxy_salt_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_SALT_HEX.to_string(),
114            muted_chat_ids: Vec::new(),
115            pinned_chat_ids: Vec::new(),
116            blocked_owner_pubkeys: Vec::new(),
117            accepted_owner_pubkeys: Vec::new(),
118            debug_logging_enabled: false,
119            accept_unknown_direct_messages: true,
120            mobile_push_server_url: String::new(),
121        }
122    }
123}
124
125#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
126pub struct OutgoingAttachment {
127    pub file_path: String,
128    pub filename: String,
129}
130
131#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
132pub struct AttachmentDownloadResult {
133    pub data_base64: Option<String>,
134    pub error: Option<String>,
135}
136
137#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
138pub enum DeviceAuthorizationState {
139    Authorized,
140    AwaitingApproval,
141    Revoked,
142}
143
144#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
145pub struct AccountSnapshot {
146    pub public_key_hex: String,
147    pub npub: String,
148    pub display_name: String,
149    pub picture_url: Option<String>,
150    pub about: Option<String>,
151    pub device_public_key_hex: String,
152    pub device_npub: String,
153    pub has_owner_signing_authority: bool,
154    pub authorization_state: DeviceAuthorizationState,
155}
156
157#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
158pub struct DeviceEntrySnapshot {
159    pub device_pubkey_hex: String,
160    pub device_npub: String,
161    pub is_current_device: bool,
162    pub is_authorized: bool,
163    pub is_stale: bool,
164    pub added_at_secs: Option<u64>,
165    pub device_label: Option<String>,
166    pub client_label: Option<String>,
167}
168
169#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
170pub struct DeviceRosterSnapshot {
171    pub owner_public_key_hex: String,
172    pub owner_npub: String,
173    pub current_device_public_key_hex: String,
174    pub current_device_npub: String,
175    pub can_manage_devices: bool,
176    pub authorization_state: DeviceAuthorizationState,
177    pub devices: Vec<DeviceEntrySnapshot>,
178}
179
180#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
181pub enum DeliveryState {
182    Queued,
183    Pending,
184    Sent,
185    Received,
186    Seen,
187    Failed,
188}
189
190#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
191pub enum ChatKind {
192    Direct,
193    Group,
194}
195
196#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
197pub enum ChatMessageKind {
198    User,
199    System,
200}
201
202#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
203pub struct MessageAttachmentSnapshot {
204    pub nhash: String,
205    pub filename: String,
206    pub filename_encoded: String,
207    pub htree_url: String,
208    pub is_image: bool,
209    pub is_video: bool,
210    pub is_audio: bool,
211}
212
213#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
214pub struct MessageReactionSnapshot {
215    pub emoji: String,
216    pub count: u64,
217    pub reacted_by_me: bool,
218}
219
220#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
221pub struct MessageReactor {
222    /// Hex-encoded pubkey of the user who reacted.
223    pub author: String,
224    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
225    #[serde(default, skip_serializing_if = "String::is_empty")]
226    pub display_name: String,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub picture_url: Option<String>,
229    /// Emoji content of their current (latest) reaction. Empty means unreacted.
230    pub emoji: String,
231}
232
233#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
234pub struct MessageRecipientDeliverySnapshot {
235    /// Hex-encoded owner/user pubkey.
236    pub owner_pubkey_hex: String,
237    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
238    #[serde(default, skip_serializing_if = "String::is_empty")]
239    pub display_name: String,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub picture_url: Option<String>,
242    pub delivery: DeliveryState,
243    pub updated_at_secs: u64,
244}
245
246#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
247#[serde(default)]
248pub struct MessageDeliveryTraceSnapshot {
249    pub outer_event_ids: Vec<String>,
250    pub pending_relay_event_ids: Vec<String>,
251    pub queued_protocol_targets: Vec<String>,
252    pub transport_channels: Vec<String>,
253    pub last_transport_error: Option<String>,
254}
255
256#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
257pub struct ChatMessageSnapshot {
258    pub id: String,
259    pub chat_id: String,
260    pub kind: ChatMessageKind,
261    pub author: String,
262    /// Hex-encoded owner/user pubkey for the author when known.
263    pub author_owner_pubkey_hex: Option<String>,
264    pub author_picture_url: Option<String>,
265    pub body: String,
266    pub attachments: Vec<MessageAttachmentSnapshot>,
267    pub reactions: Vec<MessageReactionSnapshot>,
268    pub reactors: Vec<MessageReactor>,
269    pub is_outgoing: bool,
270    pub created_at_secs: u64,
271    pub expires_at_secs: Option<u64>,
272    pub delivery: DeliveryState,
273    pub recipient_deliveries: Vec<MessageRecipientDeliverySnapshot>,
274    pub delivery_trace: MessageDeliveryTraceSnapshot,
275    /// Hex ID of the outer relay event that carried this rumor. The
276    /// notification extension joins on this to find a body the
277    /// foreground app already decrypted, so it can render a real
278    /// preview instead of "New activity". `None` for messages that
279    /// didn't come over the wire (system notices, locally-composed
280    /// outgoing rumors).
281    pub source_event_id: Option<String>,
282}
283
284#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
285pub struct TypingIndicatorSnapshot {
286    pub chat_id: String,
287    pub display_name: String,
288    pub expires_at_secs: u64,
289}
290
291#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
292pub struct ChatThreadSnapshot {
293    pub chat_id: String,
294    pub kind: ChatKind,
295    pub display_name: String,
296    pub nickname: Option<String>,
297    pub profile_name: Option<String>,
298    pub subtitle: Option<String>,
299    pub picture_url: Option<String>,
300    pub about: Option<String>,
301    pub member_count: u64,
302    pub last_message_preview: Option<String>,
303    pub last_message_at_secs: Option<u64>,
304    pub last_message_is_outgoing: Option<bool>,
305    pub last_message_delivery: Option<DeliveryState>,
306    pub unread_count: u64,
307    pub is_typing: bool,
308    pub is_muted: bool,
309    pub is_pinned: bool,
310    /// Unsent composer text the user typed in this thread, persisted
311    /// across navigation / suspend / relaunch. Chat list rows render
312    /// "Draft: …" in place of the last message preview when this is
313    /// non-empty (Signal pattern).
314    pub draft: String,
315    /// True when this is a direct chat where the user has not yet
316    /// replied to a stranger — i.e. incoming messages exist but no
317    /// outgoing ones do. Shells render an Accept / Delete / Block
318    /// gate (Signal-style "message request"), suppress read /
319    /// delivery / typing receipts, and treat the conversation as
320    /// untrusted until the user accepts.
321    pub is_request: bool,
322}
323
324#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
325pub struct ChatParticipantSnapshot {
326    pub owner_pubkey_hex: String,
327    pub display_name: String,
328    pub picture_url: Option<String>,
329    pub is_local_owner: bool,
330}
331
332/// Capability of the selected direct-chat peer. Profile search results are
333/// locators only; the composer unlocks after current owner-signed evidence is
334/// resolved. Group chats expose `None`.
335#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
336pub enum DirectChatCapabilityState {
337    Checking,
338    Available,
339    Unavailable,
340    CheckFailed,
341}
342
343#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
344pub struct CurrentChatSnapshot {
345    pub chat_id: String,
346    pub kind: ChatKind,
347    pub display_name: String,
348    pub nickname: Option<String>,
349    pub profile_name: Option<String>,
350    pub subtitle: Option<String>,
351    pub picture_url: Option<String>,
352    pub about: Option<String>,
353    pub group_id: Option<String>,
354    pub member_count: u64,
355    pub message_ttl_seconds: Option<u64>,
356    pub is_muted: bool,
357    pub participants: Vec<ChatParticipantSnapshot>,
358    pub messages: Vec<ChatMessageSnapshot>,
359    pub typing_indicators: Vec<TypingIndicatorSnapshot>,
360    /// Same persisted draft text exposed on `ChatThreadSnapshot`. The
361    /// chat screen pre-fills its composer with this on first appear.
362    pub draft: String,
363    /// Mirrors `ChatThreadSnapshot::is_request`. Chat screens replace
364    /// the composer with an Accept / Delete / Block gate when set.
365    pub is_request: bool,
366    pub direct_chat_capability: Option<DirectChatCapabilityState>,
367}
368
369#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
370pub struct GroupMemberSnapshot {
371    pub owner_pubkey_hex: String,
372    pub display_name: String,
373    pub npub: String,
374    pub picture_url: Option<String>,
375    pub is_admin: bool,
376    pub is_creator: bool,
377    pub is_local_owner: bool,
378}
379
380#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
381pub struct GroupDetailsSnapshot {
382    pub group_id: String,
383    pub name: String,
384    pub picture_url: Option<String>,
385    /// Free-text group description (Signal calls this the group description).
386    /// `None` if unset. Admins can edit via `AppAction::UpdateGroupAbout`.
387    pub about: Option<String>,
388    pub created_by_display_name: String,
389    pub created_by_npub: String,
390    pub can_manage: bool,
391    pub is_muted: bool,
392    pub revision: u64,
393    pub members: Vec<GroupMemberSnapshot>,
394}
395
396#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
397pub struct MutualGroupsSnapshot {
398    pub groups: Vec<ChatThreadSnapshot>,
399}
400
401#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
402pub struct RelayConnectionSnapshot {
403    pub url: String,
404    pub status: String,
405}
406
407#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
408pub struct NetworkStatusSnapshot {
409    pub relay_set_id: String,
410    pub relay_urls: Vec<String>,
411    pub relay_connections: Vec<RelayConnectionSnapshot>,
412    pub connected_relay_count: u64,
413    pub all_relays_offline_since_secs: Option<u64>,
414    pub syncing: bool,
415    pub pending_outbound_count: u64,
416    pub pending_group_control_count: u64,
417    pub recent_event_count: u64,
418    pub recent_log_count: u64,
419    pub last_debug_category: Option<String>,
420    pub last_debug_detail: Option<String>,
421}
422
423#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
424pub struct MobilePushSessionSnapshot {
425    pub recipient_pubkey_hex: String,
426    pub display_name: String,
427    pub state_json: String,
428    pub tracked_sender_pubkeys: Vec<String>,
429    pub has_receiving_capability: bool,
430}
431
432#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
433pub struct MobilePushSyncSnapshot {
434    pub owner_pubkey_hex: Option<String>,
435    pub message_author_pubkeys: Vec<String>,
436    pub invite_response_pubkeys: Vec<String>,
437    pub sessions: Vec<MobilePushSessionSnapshot>,
438}
439
440#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
441pub struct PeerProfileDebugSnapshot {
442    pub owner_pubkey_hex: String,
443    pub owner_npub: String,
444    pub direct_send_readiness: String,
445    pub roster_device_count: u64,
446    pub known_device_count: u64,
447    pub active_session_count: u64,
448    pub session_count: u64,
449    pub receiving_session_count: u64,
450    pub tracked_sender_count: u64,
451    pub recent_handshake_device_count: u64,
452    pub last_handshake_at_secs: Option<u64>,
453    pub tracked_for_messages: bool,
454}
455
456#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
457pub struct MobilePushNotificationResolution {
458    pub should_show: bool,
459    pub title: String,
460    pub body: String,
461    pub payload_json: String,
462}
463
464#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
465pub struct MobilePushSubscriptionRequest {
466    pub method: String,
467    pub url: String,
468    pub authorization_header: String,
469    pub body_json: Option<String>,
470}
471
472/// One row inside the "Messages" section of a search result. Shells
473/// render this as a single conversation list row whose subtitle is the
474/// matched body and whose title is the chat's display name (resolved
475/// here so the UI doesn't have to look up the parent thread).
476#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
477pub struct MessageSearchHit {
478    pub chat_id: String,
479    pub message_id: String,
480    pub chat_display_name: String,
481    pub chat_picture_url: Option<String>,
482    pub chat_kind: ChatKind,
483    pub author_pubkey: String,
484    pub body: String,
485    pub is_outgoing: bool,
486    pub created_at_secs: u64,
487}
488
489/// A person discovered from local profiles, the account's social graph, or the
490/// global profile index. Chat capability is resolved only after selection;
491/// shells only render these fields.
492#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
493pub struct FollowedUserSearchResult {
494    pub owner_pubkey_hex: String,
495    pub display_label: String,
496    pub profile_label: Option<String>,
497    pub picture_url: Option<String>,
498    pub about: Option<String>,
499    pub user_id: String,
500}
501
502#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
503pub struct SearchResultSnapshot {
504    pub query: String,
505    pub scope_chat_id: Option<String>,
506    pub people: Vec<FollowedUserSearchResult>,
507    pub contacts: Vec<ChatThreadSnapshot>,
508    pub groups: Vec<ChatThreadSnapshot>,
509    pub messages: Vec<MessageSearchHit>,
510    /// Inline shortcut row to show above the grouped sections when the
511    /// query is itself an npub / nprofile / hex pubkey / invite URL.
512    /// Shells render this as a single "Start chat with …" / "Accept
513    /// invite from …" row that dispatches the carried action on tap.
514    pub shortcut: Option<ChatInputShortcut>,
515}
516
517/// Classification of the free-text input typed into a chat-action
518/// field — the search bar, the "New chat" paste field, the share-link
519/// handler. Centralising the parsing here means the UI just looks at
520/// the enum and dispatches; no platform has its own ad-hoc
521/// `contains("://") && contains("#")` check.
522#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
523pub enum ChatInputShortcut {
524    /// Input is a recognized owner pubkey (npub / nprofile / hex).
525    /// `display` is the user-presentable short form (npub…).
526    DirectPeer {
527        peer_input: String,
528        display: String,
529        npub: String,
530        pubkey_hex: String,
531    },
532    /// Input is a paste of an invite URL. `display` is a short label
533    /// suitable for "Accept invite from …" rows.
534    Invite {
535        invite_input: String,
536        display: String,
537    },
538}
539
540impl SearchResultSnapshot {
541    pub fn empty(query: String, scope_chat_id: Option<String>) -> Self {
542        Self {
543            query,
544            scope_chat_id,
545            people: Vec::new(),
546            contacts: Vec::new(),
547            groups: Vec::new(),
548            messages: Vec::new(),
549            shortcut: None,
550        }
551    }
552
553    pub fn is_empty(&self) -> bool {
554        self.people.is_empty()
555            && self.contacts.is_empty()
556            && self.groups.is_empty()
557            && self.messages.is_empty()
558            && self.shortcut.is_none()
559    }
560}
561
562#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
563pub struct PublicInviteSnapshot {
564    pub url: String,
565}
566
567#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
568pub struct LinkDeviceSnapshot {
569    pub url: String,
570    pub device_input: String,
571}
572
573#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
574pub struct AppState {
575    pub rev: u64,
576    pub router: Router,
577    pub account: Option<AccountSnapshot>,
578    pub device_roster: Option<DeviceRosterSnapshot>,
579    pub busy: BusyState,
580    pub chat_list: Vec<ChatThreadSnapshot>,
581    pub current_chat: Option<CurrentChatSnapshot>,
582    pub group_details: Option<GroupDetailsSnapshot>,
583    pub public_invite: Option<PublicInviteSnapshot>,
584    pub link_device: Option<LinkDeviceSnapshot>,
585    pub network_status: Option<NetworkStatusSnapshot>,
586    pub mobile_push: MobilePushSyncSnapshot,
587    pub preferences: PreferencesSnapshot,
588    /// Invalidates native People-search caches when discovery inputs, block
589    /// exclusions, or discovery loading state change.
590    pub user_discovery_revision: u64,
591    pub user_discovery_syncing: bool,
592    pub toast: Option<String>,
593}
594
595impl AppState {
596    pub fn empty() -> Self {
597        Self {
598            rev: 0,
599            router: Router {
600                default_screen: Screen::Welcome,
601                screen_stack: Vec::new(),
602            },
603            account: None,
604            device_roster: None,
605            busy: BusyState::default(),
606            chat_list: Vec::new(),
607            current_chat: None,
608            group_details: None,
609            public_invite: None,
610            link_device: None,
611            network_status: None,
612            mobile_push: MobilePushSyncSnapshot::default(),
613            preferences: PreferencesSnapshot::default(),
614            user_discovery_revision: 0,
615            user_discovery_syncing: false,
616            toast: None,
617        }
618    }
619}