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