SimpleX Chat iOS -- State Management
Source: ChatModel.swift | ChatTypes.swift
Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage.
Related specs: Architecture | API Reference | README
Related product: Concept Index
Table of Contents
- Overview
- ChatModel -- Primary App State
- ItemsModel -- Per-Chat Message State
- ChatTagsModel -- Tag Filtering State
- ChannelRelaysModel -- Channel Relay State
- Chat -- Single Conversation State
- ChatInfo -- Conversation Metadata
- State Flow
- Preference Storage
1. Overview
The app uses SwiftUI's ObservableObject pattern for reactive state management. The state hierarchy is:
Class: final class ChatModel: ObservableObject
Singleton: ChatModel.shared
Source: Shared/Model/ChatModel.swift
Key Published Properties
App Lifecycle
| Property |
Type |
Description |
Line |
onboardingStage |
OnboardingStage? |
Current onboarding step |
L417 |
chatInitialized |
Bool |
Whether chat has been initialized |
L426 |
chatRunning |
Bool? |
Whether chat engine is running |
L427 |
chatDbChanged |
Bool |
Whether DB was changed externally |
L428 |
chatDbEncrypted |
Bool? |
Whether DB is encrypted |
L429 |
chatDbStatus |
DBMigrationResult? |
DB migration status |
L430 |
ctrlInitInProgress |
Bool |
Whether controller is initializing |
L431 |
migrationState |
MigrationToState? |
Device migration state |
L481 |
User State
| Property |
Type |
Description |
Line |
currentUser |
User? |
Active user profile (triggers theme reapply on change) |
L420 |
users |
[UserInfo] |
All user profiles |
L425 |
v3DBMigration |
V3DBMigrationState |
Legacy DB migration state |
L419 |
Chat List
| Property |
Type |
Description |
Line |
chats |
[Chat] (private set) |
All conversations for current user |
L437 |
deletedChats |
Set<String> |
Chat IDs pending deletion animation |
L438 |
Active Chat
| Property |
Type |
Description |
Line |
chatId |
String? |
Currently open chat ID |
L440 |
chatAgentConnId |
String? |
Agent connection ID for active chat |
L441 |
chatSubStatus |
SubscriptionStatus? |
Active chat subscription status |
L442 |
openAroundItemId |
ChatItem.ID? |
Item to scroll to when opening |
L443 |
chatToTop |
String? |
Chat to scroll to top |
L444 |
groupMembers |
[GMember] |
Members of active group |
L446 |
groupMembersIndexes |
[Int64: Int] |
Member ID to index mapping |
L447 |
membersLoaded |
Bool |
Whether members have been loaded |
L448 |
secondaryIM |
ItemsModel? |
Secondary items model (member support scope, channel comments thread, or content-type filter) |
L499 |
Authentication
| Property |
Type |
Description |
Line |
contentViewAccessAuthenticated |
Bool |
Whether user has passed authentication |
L434 |
laRequest |
LocalAuthRequest? |
Pending authentication request |
L435 |
Notifications
| Property |
Type |
Description |
Line |
deviceToken |
DeviceToken? |
Current APNs device token |
L459 |
savedToken |
DeviceToken? |
Previously saved token |
L460 |
tokenRegistered |
Bool |
Whether token is registered with server |
L461 |
tokenStatus |
NtfTknStatus? |
Token registration status |
L463 |
notificationMode |
NotificationsMode |
Current notification mode (.off/.periodic/.instant) |
L464 |
notificationServer |
String? |
Notification server URL |
L465 |
notificationPreview |
NotificationPreviewMode |
What to show in notifications |
L466 |
notificationResponse |
UNNotificationResponse? |
Pending notification action |
L432 |
ntfContactRequest |
NTFContactRequest? |
Pending contact request from notification |
L468 |
ntfCallInvitationAction |
(ChatId, NtfCallAction)? |
Pending call action from notification |
L469 |
Calls
| Property |
Type |
Description |
Line |
callInvitations |
[ChatId: RcvCallInvitation] |
Pending incoming call invitations |
L471 |
activeCall |
Call? |
Currently active call |
L472 |
callCommand |
WebRTCCommandProcessor |
WebRTC command queue |
L473 |
showCallView |
Bool |
Whether to show full-screen call UI |
L474 |
activeCallViewIsCollapsed |
Bool |
Whether call view is in PiP mode |
L475 |
Remote Desktop
| Property |
Type |
Description |
Line |
remoteCtrlSession |
RemoteCtrlSession? |
Active remote desktop session |
L478 |
Misc
| Property |
Type |
Description |
Line |
userAddress |
UserContactLink? |
User's SimpleX address |
L455 |
chatItemTTL |
ChatItemTTL |
Global message TTL |
L456 |
appOpenUrl |
URL? |
URL opened while app active |
L457 |
appOpenUrlLater |
URL? |
URL opened while app inactive |
L458 |
showingInvitation |
ShowingInvitation? |
Currently displayed invitation |
L480 |
draft |
ComposeState? |
Saved compose draft |
L484 |
draftChatId |
String? |
Chat ID for saved draft |
L485 |
networkInfo |
UserNetworkInfo |
Current network type and status |
L486 |
conditions |
ServerOperatorConditions |
Server usage conditions |
L488 |
stopPreviousRecPlay |
URL? |
Currently playing audio source |
L483 |
Non-Published Properties
| Property |
Type |
Description |
Line |
messageDelivery |
[Int64: () -> Void] |
Pending delivery confirmation callbacks |
L490 |
filesToDelete |
Set<URL> |
Files queued for deletion |
L492 |
im |
ItemsModel |
Reference to ItemsModel.shared |
L496 |
Key Methods
| Method |
Description |
Line |
getUser(_ userId:) |
Find user by ID |
L519 |
updateUser(_ user:) |
Update user in list and current |
L530 |
removeUser(_ user:) |
Remove user from list |
L540 |
getChat(_ id:) |
Find chat by ID |
L551 |
addChat(_ chat:) |
Add chat to list |
L606 |
updateChatInfo(_ cInfo:) |
Update chat metadata |
L620 |
replaceChat(_ id:, _ chat:) |
Replace chat in list |
L672 |
getCIItemsModel(_ cInfo:_ ci:) |
Resolve which ItemsModel an incoming item belongs to (primary im, secondary secondaryIM, or nil). For the channel-comments thread it routes via ci.parentChatItemId == parent.id. |
L747 |
removeChat(_ id:) |
Remove chat from list |
L1290 |
popChat(_ id:) |
Move chat to top of list |
L1266 |
totalUnreadCountForAllUsers() |
Sum unread across all users |
L1166 |
Class: class ItemsModel: ObservableObject
Primary singleton: ItemsModel.shared
Secondary instances: Created via ItemsModel.loadSecondaryChat() for scope-based views (member support chat, channel comments thread, reports filter).
Source: Shared/Model/ChatModel.swift
Properties
| Property |
Type |
Description |
Line |
reversedChatItems |
[ChatItem] |
Messages in reverse chronological order (newest first) |
L86 |
itemAdded |
Bool |
Flag indicating a new item was added |
L89 |
chatState |
ActiveChatState |
Pagination splits and loaded ranges |
L93 |
isLoading |
Bool |
Whether messages are currently loading |
L97 |
showLoadingProgress |
ChatId? |
Chat ID showing loading spinner |
L98 |
preloadState |
PreloadState |
State for infinite-scroll preloading |
L83 |
secondaryIMFilter |
SecondaryItemsModelFilter? |
Filter for secondary instances |
L82 |
Computed Properties
| Property |
Type |
Description |
Line |
lastItemsLoaded |
Bool |
Whether the oldest messages have been loaded |
L103 |
contentTag |
MsgContentTag? |
Content type filter (if secondary) |
L212 |
groupScopeInfo |
GroupChatScopeInfo? |
Group scope filter (if secondary) |
L220 |
Throttling
ItemsModel uses a custom publisher throttle (0.2 seconds) to batch rapid updates to reversedChatItems and prevent excessive SwiftUI re-renders:
Direct @Published properties (isLoading, showLoadingProgress) bypass throttling for immediate UI response.
Key Methods
| Method |
Description |
Line |
loadOpenChat(_ chatId:) |
Load chat with 250ms navigation delay |
L170 |
loadOpenChatNoWait(_ chatId:, _ openAroundItemId:) |
Load chat without delay |
L196 |
loadSecondaryChat(_ chatId:, chatFilter:) |
Create secondary ItemsModel instance. For .groupChannelMsgContext it fetches via apiGetChat(parentItemId:) and injects a local-only ChannelMsgInfo into the returned ChatInfo.group. |
L116 |
Used for secondary chat views (e.g., group member support scope, content type filter, channel comments thread):
.groupChannelMsgContext is the channel-comments-thread scope. The associated parent carries the channel post being commented on; the comments-thread view routes inbound items via ci.parentChatItemId == parent.id. The parent's meta.itemSharedMsgId is required before opening the thread (the owner-side parent may briefly lack a shared id during send).
Opening a comments thread bypasses the standard loadOpenChat flow because the comments scope is not represented on the wire as a GroupChatScope. Instead:
- The caller invokes
ItemsModel.loadSecondaryChat with chatFilter: .groupChannelMsgContext(parent:).
- Items are fetched via
apiGetChat(..., parentItemId: parent.id) (see apiGetChat in spec/api.md §2.3).
- The returned
ChatInfo.group has its third associated value rewritten by injectChannelMsgInfo to embed a local-only ChannelMsgInfo carrier so toolbar and routing logic can read the parent without a second lookup.
getCIItemsModel gains a new branch (cInfo.channelMsgInfo() != nil) that routes events into the secondary ItemsModel when ci.parentChatItemId == parent.id.
- Gating sites that previously checked
cInfo.groupChatScope() == nil to decide whether to update the main chat preview now also check cInfo.channelMsgInfo() == nil, so comment items do not bubble into the main chat list.
The carrier is not serialized: ChannelMsgInfo is Decodable only so it nests cleanly into ChatInfo.group, but the server never sends this field — it is injected exclusively by loadSecondaryChat. Inbound XMsgNew items with a parent come back over the regular event stream; they are routed by ci.parentChatItemId alone.
Class: class ChatTagsModel: ObservableObject
Singleton: ChatTagsModel.shared
Source: Shared/Model/ChatModel.swift
Properties
| Property |
Type |
Description |
Line |
userTags |
[ChatTag] |
User-defined tags |
L245 |
activeFilter |
ActiveFilter? |
Currently active filter tab |
L246 |
presetTags |
[PresetTag: Int] |
Preset tag counts (groups, contacts, favorites, etc.) |
L247 |
unreadTags |
[Int64: Int] |
Unread count per user tag |
L248 |
Class: class ChannelRelaysModel: ObservableObject
Singleton: ChannelRelaysModel.shared
Source: Shared/Model/ChatModel.swift
Holds runtime relay state for the currently viewed channel. Used by ChannelRelaysView to display and manage relays. Reset when the view is dismissed.
Properties
| Property |
Type |
Description |
Line |
groupId |
Int64? |
Group ID of the channel whose relays are loaded |
L391 |
groupRelays |
[GroupRelay] |
Current relay instances for the channel |
L392 |
Methods
| Method |
Description |
Line |
set(groupId:groupRelays:) |
Populate all properties at once |
L394 |
reset() |
Clear all properties to nil/empty |
L409 |
Class: final class Chat: ObservableObject, Identifiable, ChatLike
Source: Shared/Model/ChatModel.swift
Represents a single conversation in the chat list. Each Chat is an independent observable object.
Properties
| Property |
Type |
Description |
Line |
chatInfo |
ChatInfo |
Conversation type and metadata |
L1381 |
chatItems |
[ChatItem] |
Preview items (typically last message) |
L1382 |
chatStats |
ChatStats |
Unread counts and min unread item ID |
L1383 |
created |
Date |
Creation timestamp |
L1384 |
Computed Properties
| Property |
Description |
Line |
id |
Chat ID from chatInfo.id |
L1415 |
viewId |
Unique view identity including creation time |
L1417 |
unreadTag |
Whether chat counts as "unread" based on notification settings |
L1407 |
supportUnreadCount |
Unread count for group support scope |
L1419 |
Enum: public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable
Source: SimpleXChat/ChatTypes.swift
Represents the type and metadata of a conversation:
Cases
| Case |
Associated Value |
Description |
.direct |
Contact |
One-to-one conversation |
.group |
GroupInfo, GroupChatScopeInfo?, ChannelMsgInfo? |
Group conversation. Optional GroupChatScopeInfo selects a member support thread; optional ChannelMsgInfo (set client-side only) selects a channel-comments thread under a specific post. The two are mutually exclusive in practice. |
.local |
NoteFolder |
Local notes (self-chat) |
.contactRequest |
UserContactRequest |
Incoming contact request |
.contactConnection |
PendingContactConnection |
Pending connection |
.invalidJSON |
Data? |
Undecodable chat data |
Local-only carrier for the comments-thread view. Holds the parent channel post (channelMsgItem) and its shared message id (channelMsgSharedId, hoisted from parent.meta.itemSharedMsgId). The struct is Decodable so it can sit inside the ChatInfo.group enum case, but the Haskell core never emits this field — ItemsModel.loadSecondaryChat injects it after fetching the thread by parentItemId=. See §3 Channel comments thread.
Key Computed Properties on ChatInfo
| Property |
Type |
Description |
chatType |
ChatType |
.direct, .group, .local, .contactRequest, .contactConnection |
id |
ChatId |
Prefixed ID (e.g., "@1" for direct, "#5" for group) |
displayName |
String |
Contact/group name |
image |
String? |
Profile image (base64) |
chatSettings |
ChatSettings? |
Notification/favorite settings |
chatTags |
[Int64]? |
Assigned tag IDs |
Relay-Related Data Model (Channels)
A channel is a group with groupInfo.useRelays == true. These types support the relay/channel infrastructure:
New Fields on Existing Types
| Type |
Field |
Type |
Description |
Line |
User |
userChatRelay |
Bool |
Whether user acts as a chat relay |
L46 |
GroupInfo |
useRelays |
Bool |
Whether group uses relay infrastructure (channel mode) |
L2448 |
GroupInfo |
relayOwnStatus |
RelayStatus? |
Current user's relay status in this group |
L2449 |
GroupProfile |
publicGroup |
PublicGroupProfile? |
Channel-specific profile data (type, link, ID) |
L2588 |
New Types
| Type |
Kind |
Description |
Line |
RelayStatus |
enum |
Relay lifecycle: .rsNew, .rsInvited, .rsAccepted, .rsActive, .rsInactive, .rsRejected |
L2659 |
RelayStatus.text |
extension |
Localized display text: New/Invited/Accepted/Active/Inactive/Rejected |
L2731 |
GroupRelay |
struct |
Relay instance for a group (ID, member ID, relay status). Fetched at runtime via apiGetGroupRelays (owner only) |
L2722 |
UserChatRelay |
struct |
User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) |
L2675 |
New Enum Cases
| Enum |
Case |
Description |
Line |
GroupMemberRole |
.relay |
Role for relay members (below .observer) |
L2974 |
CIDirection |
.channelRcv |
Message direction for channel-received messages (via relay) |
L3737 |
A comment is a ChatItem whose parentChatItemId references a channel post. The parent post is the only ChatItem that carries commentsTotal/commentsDisabled. Comments live in a secondary ItemsModel keyed by .groupChannelMsgContext(parent:); the channel-post-list view (slice 4) uses commentsTotal to render the comments-button counter.
New Fields on Existing Types
| Type |
Field |
Type |
Description |
Line |
ChatItem |
parentChatItemId |
Int64? |
Parent post id if this item is a comment. Hoisted from CIMeta.parentChatItemId on decode for caller ergonomics; nil means the item is not a comment. |
L3289 |
ChatItem |
commentsTotal |
Int |
Total comments under this item (only meaningful when this item is a channel post). Defaults to 0 via decodeIfPresent ?? 0, mirroring Haskell omittedField. |
L3290 |
ChatItem |
commentsDisabled |
Bool |
Whether the channel owner has disabled comments under this post. Defaults to false via decodeIfPresent ?? false. |
L3291 |
CIMeta |
itemSharedMsgId |
String? |
Shared message id (SharedMsgId) used to address this item across members. Needed to open a comments thread under an owner-side parent that has just been sent. |
L3783 |
The three ChatItem fields are wire-encoded inside the nested meta object (Haskell CIMeta). The iOS ChatItem.init(from decoder:) hoists them to ChatItem level so that view code can write ci.parentChatItemId and ci.commentsTotal directly without descending into ci.meta. Encoding round-trips remain wire-compatible because the iOS ChatItem is Decodable-only.
New Types
| Type |
Kind |
Description |
Line |
ChannelMsgInfo |
struct |
Local-only carrier for the comments-thread view. See §7 ChannelMsgInfo. |
L2028 |
8. State Flow
App Start
Opening a Chat
Receiving a Message (Event)
Sending a Message
9. Preference Storage
UserDefaults (via @AppStorage)
App-level UI settings stored in UserDefaults.standard:
| Key Constant |
Type |
Description |
DEFAULT_PERFORM_LA |
Bool |
Enable local authentication |
DEFAULT_PRIVACY_PROTECT_SCREEN |
Bool |
Hide screen in app switcher |
DEFAULT_SHOW_LA_NOTICE |
Bool |
Show LA setup notice |
DEFAULT_NOTIFICATION_ALERT_SHOWN |
Bool |
Notification permission alert shown |
DEFAULT_CALL_KIT_CALLS_IN_RECENTS |
Bool |
Show CallKit calls in recents |
GroupDefaults
Settings shared between main app and extensions (NSE, SE) via app group UserDefaults:
| Key |
Description |
appStateGroupDefault |
Current app state (.active/.suspended/.stopped) |
dbContainerGroupDefault |
Database container location (.group/.documents) |
ntfPreviewModeGroupDefault |
Notification preview mode |
storeDBPassphraseGroupDefault |
Whether to store DB passphrase |
callKitEnabledGroupDefault |
Whether CallKit is enabled |
onboardingStageDefault |
Current onboarding stage |
currentThemeDefault |
Current theme name |
systemDarkThemeDefault |
Dark mode theme name |
themeOverridesDefault |
Custom theme overrides |
currentThemeIdsDefault |
Active theme override IDs |
Keychain (KeyChain wrapper)
Sensitive data stored in iOS Keychain:
| Key |
Description |
kcDatabasePassword |
SQLite database encryption key |
kcAppPassword |
App lock password |
kcSelfDestructPassword |
Self-destruct trigger password |
Haskell DB (via apiSaveSettings / apiGetSettings)
Chat-level preferences stored in the SQLite database (managed by Haskell core):
- Per-contact preferences (timed messages, voice, calls, etc.)
- Per-group preferences
- Per-user notification settings
- Network configuration
- Server lists
Source Files