17 KiB
SimpleX Chat iOS -- Chat List Module
Technical specification for the conversation list, filtering, search, swipe actions, and user picker.
Related specs: Chat View | Navigation | State Management | README Related product: Chat List View
Source: ChatListView.swift
Table of Contents
- Overview
- ChatListView
- ChatPreviewView
- ChatListNavLink
- Filtering & Tags
- Search
- Swipe Actions
- UserPicker
- Floating Action Button
- Crowdfunding Banner
1. Overview
The chat list is the main screen of the app, displaying all conversations for the current user. It provides:
- Conversation previews with unread badges
- Filter tabs (All, Unread, Favorites, Groups, Contacts, Business, user-defined tags)
- Search across chat names and message content
- Swipe actions for quick operations
- User profile switcher
- Floating action button for new conversations
ChatListView
├── Navigation Bar
│ ├── User avatar (tap → UserPicker)
│ └── Filter tabs (TagListView)
├── Search bar (on pull-down or tap)
├── Chat List (List/LazyVStack)
│ └── ChatListNavLink (per conversation)
│ └── ChatPreviewView
│ ├── Avatar
│ ├── Chat name + last message preview
│ ├── Timestamp
│ └── Unread badge
├── FAB (New Chat button)
└── Pending connection cards
2. ChatListView
File: Shared/Views/ChatList/ChatListView.swift
The root list view. Key responsibilities:
Data Source
- Reads
ChatModel.shared.chats(all conversations) - Applies active filter from
ChatTagsModel.shared.activeFilter - Applies search query filtering via
filteredChats() - Sorts by last activity (most recent first), with pinned chats at top
Layout
- Uses SwiftUI
ListwithForEachover filtered chats - Each row is a
ChatListNavLinkwrapping aChatPreviewView - Pull-to-refresh triggers
updateChats()API call - Empty state:
ChatHelpview with getting-started guidance
Connection Cards
- Pending contact connections (
ChatInfo.contactConnection) shown as cards - Contact requests (
ChatInfo.contactRequest) shown with accept/reject UI viaContactRequestView
Key Functions
| Function | Line | Description |
|---|---|---|
body |
183 | Main view body |
filteredChats() |
555 | Applies active filter and search to chat list |
searchString() |
598 | Normalizes search text for comparison |
unreadBadge() |
529 | Renders unread count circle badge |
stopAudioPlayer() |
549 | Stops any playing voice message |
3. ChatPreviewView
File: Shared/Views/ChatList/ChatPreviewView.swift
Renders a single row in the chat list. Shows:
| Element | Source | Description |
|---|---|---|
| Avatar | chatInfo.image |
Profile image or default icon |
| Chat name | chatInfo.displayName |
Contact name, group name, or connection label |
| Last message | chat.chatItems.last |
Preview text of most recent message |
| Timestamp | chat.chatItems.last?.timestampText |
Relative time of last message |
| Unread badge | chat.chatStats.unreadCount |
Circular badge with unread count |
| Mute icon | chatInfo.chatSettings?.enableNtfs |
Bell-slash icon if notifications muted |
| Pin icon | -- | Pin indicator for pinned chats |
| Incognito icon | Contact.contactConnIncognito | Incognito mode indicator |
| Delivery status | Last sent item's meta.itemStatus |
Check marks for delivery confirmation |
Preview Text Rendering
- Text messages: first line of message content
- Images: camera icon + caption (if any)
- Files: paperclip icon + filename
- Voice: microphone icon + duration
- Calls: phone icon + call status
- Group events: system event description
- Encrypted/deleted: placeholder text
4. ChatListNavLink
File: Shared/Views/ChatList/ChatListNavLink.swift
Wraps ChatPreviewView in a navigation link with tap and swipe behavior:
Tap Behavior
- Direct chat: navigates to
ChatViewviaItemsModel.loadOpenChat(chatId)--contactNavLink()L93 - Group chat: navigates to
ChatView--groupNavLink()L214 - Contact request: shows
ContactRequestViewwith accept/reject --contactRequestNavLink()L486 - Contact connection: shows
ContactConnectionInfo--contactConnectionNavLink()L520 - Notes folder: navigates to
ChatView--noteFolderNavLink()L298
Navigation
- Uses
NavigationLink(iOS 15) or programmatic navigation (iOS 16+) - Sets
ChatModel.chatIdto trigger navigation ItemsModel.loadOpenChat()loads messages with a 250ms navigation delay for smooth animation
Channel Adaptations in ChatListNavLink
When groupInfo.useRelays == true:
| Change | Behavior |
|---|---|
| Swipe "Leave" | Hidden when useRelays && isOwner |
| Context menu "Leave" | Hidden under same condition |
deleteGroupAlert label |
"Delete channel?" |
leaveGroupAlert title |
"Leave channel?" |
leaveGroupAlert message |
"You will stop receiving messages from this channel. Chat history will be preserved." |
ServerSettings
ServerSettings struct (defined in ChatListView.swift) includes serverWarnings: [UserServersWarning] field, initialized to []. This field stores validation warnings from validateServers and is consumed by NetworkAndServers views.
5. Filtering & Tags
Filter Tabs (TagListView)
File: Shared/Views/ChatList/TagListView.swift
Horizontal scrolling tab bar below the navigation bar. Tabs:
| Tab | Filter | Shows |
|---|---|---|
| All | nil |
All conversations |
| Unread | .unread |
Conversations with unread messages |
| Favorites | .presetTag(.favorites) |
Favorited conversations |
| Groups | .presetTag(.groups) |
Group conversations |
| Contacts | .presetTag(.contacts) |
Direct conversations |
| Business | .presetTag(.business) |
Business conversations |
| Group Reports | .presetTag(.groupReports) |
Groups with pending reports |
| User tags | .userTag(ChatTag) |
User-defined custom tags |
Filter matching is handled by presetTagMatchesChat() and the in-view TagsView struct.
ChatTagsModel State
Filtering state is managed by ChatTagsModel (ChatModel.swift L183):
class ChatTagsModel: ObservableObject {
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
@Published var presetTags: [PresetTag: Int] = [:] // count per preset tag
@Published var unreadTags: [Int64: Int] = [:] // unread count per user tag
}
presetTagscounts are updated wheneverchatschanges viaupdateChatTags()(L197)- Tags with zero matching chats are auto-hidden
- Active filter is auto-cleared when its tag has no matching chats
Supporting Types
| Type | File | Line | Description |
|---|---|---|---|
PresetTag |
ChatListView.swift | 36 | Enum of built-in filter categories |
ActiveFilter |
ChatListView.swift | 53 | Enum wrapping preset, user-tag, or unread filter |
setActiveFilter() |
ChatListView.swift | 1113 | Applies a filter and persists selection |
Tag Management Commands
apiCreateChatTag(tag: ChatTagData)-- create tagapiSetChatTags(type:, id:, tagIds:)-- assign tags to a chatapiDeleteChatTag(tagId:)-- delete tagapiUpdateChatTag(tagId:, tagData:)-- rename tagapiReorderChatTags(tagIds:)-- reorder tags
6. Search
Search is available via pull-down gesture or search button in the navigation bar.
Search bar UI: ChatListSearchBar
Filtering Logic
- Filters
ChatModel.chatsby matching search text against:chatInfo.displayName(contact/group name)chatInfo.localAlias(local alias)chatInfo.fullName(full name)
- For deeper message content search, uses
apiGetChat(chatId:, search:)parameter - Core logic in
filteredChats()andsearchString()
Search Results
- Matching chats are displayed in the same list format
- Results update as the user types (debounced)
- Clearing search restores the full filtered list
7. Swipe Actions
ChatListNavLink provides swipe actions on each row:
Leading Swipe (left-to-right)
| Action | Icon | Handler | Line | API | Condition |
|---|---|---|---|---|---|
| Pin / Unpin | pin | toggleFavoriteButton() |
347 | apiSetChatSettings (favorite) |
Always |
| Read / Unread | envelope | markReadButton() |
328 | apiChatRead / apiChatUnread |
Always |
Trailing Swipe (right-to-left)
| Action | Icon | Handler | Line | API | Condition |
|---|---|---|---|---|---|
| Mute / Unmute | bell.slash | toggleNtfsButton() |
365 | apiSetChatSettings (enableNtfs) |
Always |
| Clear | trash | clearChatButton() |
385 | apiClearChat |
Has messages |
| Delete | trash.fill | -- | -- | apiDeleteChat |
Not active chat |
| Tag | tag | -- | -- | apiSetChatTags |
Always |
8. UserPicker
File: Shared/Views/ChatList/UserPicker.swift
Triggered by tapping the user avatar in the navigation bar. Presented as a sheet with:
| Section | Contents |
|---|---|
| User list | All non-hidden users with unread counts |
| Active user | Highlighted with checkmark |
| Actions | Settings, Your SimpleX address, User profiles |
User Switching
- Tapping a different user calls
apiSetActiveUser(userId:) - Triggers
apiGetChatsfor the new user ChatModel.currentUserupdates, causing full UI refresh- Hidden users are not shown (require password entry via settings)
9. Floating Action Button
The FAB (floating action button) in the bottom-right corner opens the new chat flow:
- Tap: opens
NewChatViewsheet for creating a new contact connection or group - Shows options: Create link, Scan QR code, Paste link, Create group
10. GetStakeBanner
File: Shared/Views/ChatList/GetStakeBanner.swift
Gradient card inviting the user to invest on Wefunder. Shown only when isInUS — the same condition that gates the Wefunder row in settings — and only while DEFAULT_GET_STAKE_BANNER_DISMISSED is false.
Placement
| Where | Condition | Layout |
|---|---|---|
| Chat list | rendered whenever chatListContent is, in the List after OneHandUICard and before the chats |
.padding(.vertical, 3), flipped for one-hand UI, .zIndex(1) |
| Onboarding | below ConnectOnboardingView when shouldShowOnboarding |
.padding(.horizontal, 20) (the onboarding cards' margin), .padding(.bottom, 8) |
The list has a single banner slot, filled by an if/else if chain in priority order: the support-ended alert (supportEnded), the renewal-failure alert (badgeIssueFailed), the pitch, then the Wefunder banner. Each banner records itself in ChatModel.chatListBanner (.badgeExpired, .badgeIssueFailed, .badgePitch, .getStake) in its onAppear, and the pitch and Wefunder conditions start with chatModel.bannerSlotFree(for:) — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires noShownBadge, false until BadgeModel holds the current user's state, so it cannot take the slot from a supporter whose badge loads a moment later. The onboarding placement applies the same bannerSlotFree check and records .getStake.
In the onboarding branch the .scaleEffect and ThemedBackground are applied to the enclosing VStack rather than to each child, so the banner stays below the pages in both toolbar modes.
Dismissal
| Default | Set by | Effect |
|---|---|---|
DEFAULT_GET_STAKE_BANNER_TAPPED |
openGetStake() |
the dismiss X appears from then on, while there are chats |
DEFAULT_GET_STAKE_BANNER_DISMISSED |
the dismiss X | hides the banner in both placements |
DEFAULT_SUPPORTER_BANNER_TAPPED |
tapping the supporter pitch | the pitch's dismiss X appears from then on |
DEFAULT_SUPPORTER_BANNER_SHOWN |
the pitch's dismiss X, through its "You can support SimpleX later in Settings." alert, and a successful code redemption | hides the pitch |
The two badge alert banners always offer the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen.
Both are in hintDefaults, so "Reset all hints" in the developer settings restores the banner. The X is never offered in the onboarding branch, so the banner cannot be dismissed before the user has a chat.
Tapping the card opens GetStakeView as an appSheet.
Shared card chrome
BannerCard (a ViewModifier: paddings, minimum height scaled by Dynamic Type, gradient background, rounded corners) and BannerDismissButton are declared separately from GetStakeBanner so other banners can adopt the same chrome. The gradient reuses OnboardingCardView.gradientPoints, lightStops and darkStops.
Source Files
| File | Path | Key struct | Line |
|---|---|---|---|
| Chat list view | ChatListView.swift |
ChatListView |
154 |
| Chat preview row | ChatPreviewView.swift |
ChatPreviewView |
12 |
| Navigation link wrapper | ChatListNavLink.swift |
ChatListNavLink |
43 |
| Tag filter tabs | TagListView.swift |
TagListView |
19 |
| User picker sheet | UserPicker.swift |
UserPicker |
9 |
| Getting started help | ChatHelp.swift |
||
| Contact request view | ContactRequestView.swift |
||
| Contact connection info | ContactConnectionInfo.swift |
||
| Contact connection view | ContactConnectionView.swift |
||
| Server summary | ServersSummaryView.swift |
||
| One-hand UI card | OneHandUICard.swift |
||
| Crowdfunding banner | GetStakeBanner.swift |
GetStakeBanner |
13 |