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    AwaitingDeviceApproval,
20    DeviceRevoked,
21}
22
23#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
24pub struct Router {
25    pub default_screen: Screen,
26    pub screen_stack: Vec<Screen>,
27}
28
29#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
30pub struct BusyState {
31    pub creating_account: bool,
32    pub restoring_session: bool,
33    pub linking_device: bool,
34    pub creating_chat: bool,
35    pub creating_group: bool,
36    pub sending_message: bool,
37    pub updating_roster: bool,
38    pub updating_group: bool,
39    pub creating_invite: bool,
40    pub accepting_invite: bool,
41    pub syncing_network: bool,
42    pub uploading_attachment: bool,
43    pub upload_progress: Option<UploadProgress>,
44}
45
46#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
47pub struct UploadProgress {
48    pub bytes_uploaded: u64,
49    pub total_bytes: u64,
50}
51
52#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
53pub struct PreferencesSnapshot {
54    pub send_typing_indicators: bool,
55    pub send_read_receipts: bool,
56    pub desktop_notifications_enabled: bool,
57    pub invite_acceptance_notifications_enabled: bool,
58    pub startup_at_login_enabled: bool,
59    pub nearby_enabled: bool,
60    pub nearby_bluetooth_enabled: bool,
61    pub nearby_lan_enabled: bool,
62    pub nearby_show_in_chat_list: bool,
63    /// Whether the on-device nearby mailbag actively reads and writes
64    /// store-and-forward records. When off, the local bag is left
65    /// alone (so contents survive a toggle off → on cycle) but no new
66    /// events are stored and the broadcast/replay path skips it.
67    pub nearby_mailbag_enabled: bool,
68    pub nostr_relay_urls: Vec<String>,
69    pub image_proxy_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: true,
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_url: crate::image_proxy::DEFAULT_IMAGE_PROXY_URL.to_string(),
111            image_proxy_key_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_KEY_HEX.to_string(),
112            image_proxy_salt_hex: crate::image_proxy::DEFAULT_IMAGE_PROXY_SALT_HEX.to_string(),
113            muted_chat_ids: Vec::new(),
114            pinned_chat_ids: Vec::new(),
115            blocked_owner_pubkeys: Vec::new(),
116            accepted_owner_pubkeys: Vec::new(),
117            debug_logging_enabled: false,
118            accept_unknown_direct_messages: true,
119            mobile_push_server_url: String::new(),
120        }
121    }
122}
123
124#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
125pub struct OutgoingAttachment {
126    pub file_path: String,
127    pub filename: String,
128}
129
130#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
131pub struct AttachmentDownloadResult {
132    pub data_base64: Option<String>,
133    pub error: Option<String>,
134}
135
136#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
137pub enum DeviceAuthorizationState {
138    Authorized,
139    AwaitingApproval,
140    Revoked,
141}
142
143#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
144pub struct AccountSnapshot {
145    pub public_key_hex: String,
146    pub npub: String,
147    pub display_name: String,
148    pub picture_url: Option<String>,
149    pub about: Option<String>,
150    pub device_public_key_hex: String,
151    pub device_npub: String,
152    pub has_owner_signing_authority: bool,
153    pub authorization_state: DeviceAuthorizationState,
154}
155
156#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
157pub struct DeviceEntrySnapshot {
158    pub device_pubkey_hex: String,
159    pub device_npub: String,
160    pub is_current_device: bool,
161    pub is_authorized: bool,
162    pub is_stale: bool,
163    pub added_at_secs: Option<u64>,
164    pub device_label: Option<String>,
165    pub client_label: Option<String>,
166}
167
168#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
169pub struct DeviceRosterSnapshot {
170    pub owner_public_key_hex: String,
171    pub owner_npub: String,
172    pub current_device_public_key_hex: String,
173    pub current_device_npub: String,
174    pub can_manage_devices: bool,
175    pub authorization_state: DeviceAuthorizationState,
176    pub devices: Vec<DeviceEntrySnapshot>,
177}
178
179#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
180pub enum DeliveryState {
181    Queued,
182    Pending,
183    Sent,
184    Received,
185    Seen,
186    Failed,
187}
188
189#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
190pub enum ChatKind {
191    Direct,
192    Group,
193}
194
195#[derive(uniffi::Enum, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
196pub enum ChatMessageKind {
197    User,
198    System,
199}
200
201#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
202pub struct MessageAttachmentSnapshot {
203    pub nhash: String,
204    pub filename: String,
205    pub filename_encoded: String,
206    pub htree_url: String,
207    pub is_image: bool,
208    pub is_video: bool,
209    pub is_audio: bool,
210}
211
212#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
213pub struct MessageReactionSnapshot {
214    pub emoji: String,
215    pub count: u64,
216    pub reacted_by_me: bool,
217}
218
219#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
220pub struct MessageReactor {
221    /// Hex-encoded pubkey of the user who reacted.
222    pub author: String,
223    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
224    #[serde(default, skip_serializing_if = "String::is_empty")]
225    pub display_name: String,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub picture_url: Option<String>,
228    /// Emoji content of their current (latest) reaction. Empty means unreacted.
229    pub emoji: String,
230}
231
232#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
233pub struct MessageRecipientDeliverySnapshot {
234    /// Hex-encoded owner/user pubkey.
235    pub owner_pubkey_hex: String,
236    /// Core-resolved display name for UI rows. Empty in persisted legacy data.
237    #[serde(default, skip_serializing_if = "String::is_empty")]
238    pub display_name: String,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub picture_url: Option<String>,
241    pub delivery: DeliveryState,
242    pub updated_at_secs: u64,
243}
244
245#[derive(uniffi::Record, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
246#[serde(default)]
247pub struct MessageDeliveryTraceSnapshot {
248    pub outer_event_ids: Vec<String>,
249    pub pending_relay_event_ids: Vec<String>,
250    pub queued_protocol_targets: Vec<String>,
251    pub target_device_ids: 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#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
333pub struct CurrentChatSnapshot {
334    pub chat_id: String,
335    pub kind: ChatKind,
336    pub display_name: String,
337    pub nickname: Option<String>,
338    pub profile_name: Option<String>,
339    pub subtitle: Option<String>,
340    pub picture_url: Option<String>,
341    pub about: Option<String>,
342    pub group_id: Option<String>,
343    pub member_count: u64,
344    pub message_ttl_seconds: Option<u64>,
345    pub is_muted: bool,
346    pub participants: Vec<ChatParticipantSnapshot>,
347    pub messages: Vec<ChatMessageSnapshot>,
348    pub typing_indicators: Vec<TypingIndicatorSnapshot>,
349    /// Same persisted draft text exposed on `ChatThreadSnapshot`. The
350    /// chat screen pre-fills its composer with this on first appear.
351    pub draft: String,
352    /// Mirrors `ChatThreadSnapshot::is_request`. Chat screens replace
353    /// the composer with an Accept / Delete / Block gate when set.
354    pub is_request: bool,
355}
356
357#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
358pub struct GroupMemberSnapshot {
359    pub owner_pubkey_hex: String,
360    pub display_name: String,
361    pub npub: String,
362    pub picture_url: Option<String>,
363    pub is_admin: bool,
364    pub is_creator: bool,
365    pub is_local_owner: bool,
366}
367
368#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
369pub struct GroupDetailsSnapshot {
370    pub group_id: String,
371    pub name: String,
372    pub picture_url: Option<String>,
373    /// Free-text group description (Signal calls this the group description).
374    /// `None` if unset. Admins can edit via `AppAction::UpdateGroupAbout`.
375    pub about: Option<String>,
376    pub created_by_display_name: String,
377    pub created_by_npub: String,
378    pub can_manage: bool,
379    pub is_muted: bool,
380    pub revision: u64,
381    pub members: Vec<GroupMemberSnapshot>,
382}
383
384#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
385pub struct MutualGroupsSnapshot {
386    pub groups: Vec<ChatThreadSnapshot>,
387}
388
389#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
390pub struct RelayConnectionSnapshot {
391    pub url: String,
392    pub status: String,
393}
394
395#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
396pub struct NetworkStatusSnapshot {
397    pub relay_set_id: String,
398    pub relay_urls: Vec<String>,
399    pub relay_connections: Vec<RelayConnectionSnapshot>,
400    pub connected_relay_count: u64,
401    pub all_relays_offline_since_secs: Option<u64>,
402    pub syncing: bool,
403    pub pending_outbound_count: u64,
404    pub pending_group_control_count: u64,
405    pub recent_event_count: u64,
406    pub recent_log_count: u64,
407    pub last_debug_category: Option<String>,
408    pub last_debug_detail: Option<String>,
409}
410
411#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
412pub struct MobilePushSessionSnapshot {
413    pub recipient_pubkey_hex: String,
414    pub display_name: String,
415    pub state_json: String,
416    pub tracked_sender_pubkeys: Vec<String>,
417    pub has_receiving_capability: bool,
418}
419
420#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)]
421pub struct MobilePushSyncSnapshot {
422    pub owner_pubkey_hex: Option<String>,
423    pub message_author_pubkeys: Vec<String>,
424    pub invite_response_pubkeys: Vec<String>,
425    pub sessions: Vec<MobilePushSessionSnapshot>,
426}
427
428#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
429pub struct PeerProfileDebugSnapshot {
430    pub owner_pubkey_hex: String,
431    pub owner_npub: String,
432    pub roster_device_count: u64,
433    pub known_device_count: u64,
434    pub active_session_count: u64,
435    pub session_count: u64,
436    pub receiving_session_count: u64,
437    pub tracked_sender_count: u64,
438    pub recent_handshake_device_count: u64,
439    pub last_handshake_at_secs: Option<u64>,
440    pub tracked_for_messages: bool,
441}
442
443#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
444pub struct MobilePushNotificationResolution {
445    pub should_show: bool,
446    pub title: String,
447    pub body: String,
448    pub payload_json: String,
449}
450
451#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
452pub struct MobilePushSubscriptionRequest {
453    pub method: String,
454    pub url: String,
455    pub authorization_header: String,
456    pub body_json: Option<String>,
457}
458
459/// One row inside the "Messages" section of a search result. Shells
460/// render this as a single conversation list row whose subtitle is the
461/// matched body and whose title is the chat's display name (resolved
462/// here so the UI doesn't have to look up the parent thread).
463#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
464pub struct MessageSearchHit {
465    pub chat_id: String,
466    pub message_id: String,
467    pub chat_display_name: String,
468    pub chat_picture_url: Option<String>,
469    pub chat_kind: ChatKind,
470    pub author_pubkey: String,
471    pub body: String,
472    pub is_outgoing: bool,
473    pub created_at_secs: u64,
474}
475
476#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
477pub struct SearchResultSnapshot {
478    pub query: String,
479    pub scope_chat_id: Option<String>,
480    pub contacts: Vec<ChatThreadSnapshot>,
481    pub groups: Vec<ChatThreadSnapshot>,
482    pub messages: Vec<MessageSearchHit>,
483    /// Inline shortcut row to show above the grouped sections when the
484    /// query is itself an npub / nprofile / hex pubkey / invite URL.
485    /// Shells render this as a single "Start chat with …" / "Accept
486    /// invite from …" row that dispatches the carried action on tap.
487    pub shortcut: Option<ChatInputShortcut>,
488}
489
490/// Classification of the free-text input typed into a chat-action
491/// field — the search bar, the "New chat" paste field, the share-link
492/// handler. Centralising the parsing here means the UI just looks at
493/// the enum and dispatches; no platform has its own ad-hoc
494/// `contains("://") && contains("#")` check.
495#[derive(uniffi::Enum, Clone, Debug, PartialEq, Eq)]
496pub enum ChatInputShortcut {
497    /// Input is a recognized owner pubkey (npub / nprofile / hex).
498    /// `display` is the user-presentable short form (npub…).
499    DirectPeer {
500        peer_input: String,
501        display: String,
502        npub: String,
503        pubkey_hex: String,
504    },
505    /// Input is a paste of an invite URL. `display` is a short label
506    /// suitable for "Accept invite from …" rows.
507    Invite {
508        invite_input: String,
509        display: String,
510    },
511}
512
513impl SearchResultSnapshot {
514    pub fn empty(query: String, scope_chat_id: Option<String>) -> Self {
515        Self {
516            query,
517            scope_chat_id,
518            contacts: Vec::new(),
519            groups: Vec::new(),
520            messages: Vec::new(),
521            shortcut: None,
522        }
523    }
524
525    pub fn is_empty(&self) -> bool {
526        self.contacts.is_empty()
527            && self.groups.is_empty()
528            && self.messages.is_empty()
529            && self.shortcut.is_none()
530    }
531}
532
533#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
534pub struct PublicInviteSnapshot {
535    pub url: String,
536}
537
538#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
539pub struct LinkDeviceSnapshot {
540    pub url: String,
541    pub device_input: String,
542}
543
544#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
545pub struct AppState {
546    pub rev: u64,
547    pub router: Router,
548    pub account: Option<AccountSnapshot>,
549    pub device_roster: Option<DeviceRosterSnapshot>,
550    pub busy: BusyState,
551    pub chat_list: Vec<ChatThreadSnapshot>,
552    pub current_chat: Option<CurrentChatSnapshot>,
553    pub group_details: Option<GroupDetailsSnapshot>,
554    pub public_invite: Option<PublicInviteSnapshot>,
555    pub link_device: Option<LinkDeviceSnapshot>,
556    pub network_status: Option<NetworkStatusSnapshot>,
557    pub mobile_push: MobilePushSyncSnapshot,
558    pub preferences: PreferencesSnapshot,
559    pub toast: Option<String>,
560}
561
562impl AppState {
563    pub fn empty() -> Self {
564        Self {
565            rev: 0,
566            router: Router {
567                default_screen: Screen::Welcome,
568                screen_stack: Vec::new(),
569            },
570            account: None,
571            device_roster: None,
572            busy: BusyState::default(),
573            chat_list: Vec::new(),
574            current_chat: None,
575            group_details: None,
576            public_invite: None,
577            link_device: None,
578            network_status: None,
579            mobile_push: MobilePushSyncSnapshot::default(),
580            preferences: PreferencesSnapshot::default(),
581            toast: None,
582        }
583    }
584}