docs: describe the new online search in the iOS and Android/desktop specs and product docs

Directory-only search with "Connect to …" kept, the Directory search switch and its alert, one list of results drawn from their store-only plans with Open, the load row's count, the long-press menu, Open going straight to the prepared chat, and the directory response's moreCount and pageSize. The name-search gaps go; the gap for groups joined without a prepared group and the count's upper bound are added.

Run: 011-search-redesign, slice 6
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-21 05:02:02 +00:00
1 parent 6b9e3b3f28
commit 717f699507
22 files changed
+286 -289

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ This document provides a structured mapping between product-level concepts, thei
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
| 32 | Online Search (Directory and Names) | [views/chat-list.md](views/chat-list.md), [views/new-chat.md](views/new-chat.md), [rules.md](rules.md) (RULE-24 to RULE-28) | [spec/client/chat-list.md](../spec/client/chat-list.md) (section 11), [spec/client/navigation.md](../spec/client/navigation.md) (section 12), [spec/api.md](../spec/api.md) (sections 2.18, 8) | `Shared/Views/ChatList/DirectorySearchView.swift` (`OnlineSearchModel`, `NameSearchRow`, `DirectorySearchRow`, `confirmDirectorySearch`), `Shared/Model/DirectorySearch.swift` (`DirectorySearchEntry`, `simplexLinkType`, `planLocalChatId`, `NameSearchResult`, `OnlineSearchRows`), `Shared/Views/ChatList/ChatListView.swift` (`ChatListSearchBar` triggers, `nameRows`, `directoryRows`), `Shared/Views/NewChat/NewChatMenuButton.swift` (`NewChatSheet` online search, `ContactsList.shownChats`), `Shared/Model/SimpleXAPI.swift` (`apiSearchDirectory`, `apiConnectPlan` `onFailure`), `Shared/Model/ChatModel.swift` (`ConnectProgressOwner`), `SimpleXChat/APITypes.swift` (`JSONValue`) | `Controller.hs` (`APISendServiceRequest`, `APIConnectPlan`); directory service `../simplex-directory-service/src/Directory/Rpc.hs` |
| 32 | Online Search (SimpleX Directory) | [views/chat-list.md](views/chat-list.md), [views/new-chat.md](views/new-chat.md), [rules.md](rules.md) (RULE-24 to RULE-28) | [spec/client/chat-list.md](../spec/client/chat-list.md) (section 11), [spec/client/navigation.md](../spec/client/navigation.md) (section 12), [spec/api.md](../spec/api.md) (sections 2.18, 8) | `Shared/Views/ChatList/DirectorySearchView.swift` (`OnlineSearchModel`, `DirectorySearchRow`, `DirectoryLoadMoreRow`, `DirectoryResultMenu`, `openDirectoryResult`, `confirmDirectorySearch`), `Shared/Model/DirectorySearch.swift` (`DirectorySearchEntry`, `simplexLinkType`, `planLocalChatId`, `DirectoryResultState`, `OnlineSearchRows`), `Shared/Views/ChatList/ChatListView.swift` (`ChatListSearchBar` triggers, `directoryRows`), `Shared/Views/NewChat/NewChatMenuButton.swift` (`NewChatSheet` online search, `ContactsList.shownChats`), `Shared/Views/NewChat/NewChatView.swift` (`PrepareGroupMode`, `prepareAndOpenGroup`), `Shared/Views/UserSettings/PrivacySettings.swift` ("Directory search"), `Shared/Model/SimpleXAPI.swift` (`apiSearchDirectory`, `apiConnectPlan` `onFailure`), `Shared/Model/ChatModel.swift` (`ConnectProgressOwner`), `SimpleXChat/APITypes.swift` (`JSONValue`) | `Controller.hs` (`APISendServiceRequest`, `APIConnectPlan`); directory service `../simplex-directory-service/src/Directory/Rpc.hs` |
---
+10 -13
View File
@@ -64,25 +64,22 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages
## Online Search
### GAP: Groups joined through another link still appear as directory results
### GAP: Groups joined without a prepared group still appear as results with Open
**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
A directory entry is matched to a local chat by a store-only lookup of its link. An entry the app finds this way opens as a chat: the screen's chat row is drawn in place of the entry. A regular group's entry carries the directory's own group link (short link only), so a group the user joined through a different link, through the directory's full link, or by invitation is not found and still shows as a directory entry with "Join group". Tapping it goes through `planAndConnect`, which handles the known group. Accepted.
A directory entry is matched to a local chat by a store-only plan of its link (`resolve=never`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row:
Related: the note "All results on this page are already in your chats" is not shown when a page's only results are hidden because a name search resolved to the same link (that row is on screen, but not one of the user's chats); the Directory part then shows only "Show more" when there are more pages, and is not shown otherwise.
- a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert;
- a member added by invitation, or one who joined through a link the owner has since replaced: the online plan is "ok", so Open prepares and opens a second copy of the group without asking (before, the "Open group?" confirm led to the same copy).
**REC:** Have the directory send a stable group identifier the core can match locally, or let the core match a directory short link against groups joined through the corresponding full link.
Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable only for entries the directory sends with a full link, since a short link's connecting plan needs a network fetch. A core test pins the store-only contract, including the `/c` case.
### GAP: First-search alert does not mention the server hosting a resolved address
**REC:** Let the store-only plan recognise joins made without a prepared group (a lookup by the connection's `via_short_link_contact`, mapped as the online plan maps a join request); it would cover `/c` joins, not invited members.
### GAP: The count of results left can include listings the directory never sends
**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
Resolving a SimpleX name with `PlanResolveMode.unknown` also loads the resolved address's link data from the SMP server that hosts it. The search text itself is not sent there, and the alert names only SimpleX Directory and the name-resolving server.
The directory's `moreCount` counts the matching listings after the page, including listings without a public link, which it never sends. "Load more · N left" can therefore promise more results than loading brings, and a last page can come back empty.
**REC:** Decide whether the alert copy should also say that the address a name points to is loaded.
### GAP: New chat does not narrow contacts by name while typing
**Source:** [spec/client/navigation.md](../spec/client/navigation.md#12-new-chat-online-search)
The chat list resolves a typed SimpleX name locally on each keystroke to narrow the list to the matching chat; New chat does not. A known contact whose display name does not contain the typed name is therefore not in "Your Contacts" and appears as a Names result instead (tapping it opens the existing chat).
**REC:** Reuse the chat list's local name resolution in `ContactsListSearchBar` if this proves confusing.
**REC:** Count only listings with a public link in the directory's count query.
---
+7 -7
View File
@@ -152,7 +152,7 @@
## Online Search
### RULE-24: Online results are never saved
**Rule:** Name and directory results MUST NOT be stored, and MUST live only as long as the search text and the active profile: any text change or profile switch clears them, and a reply that arrives after that (or after the search was cancelled) MUST NOT be shown.
**Rule:** Directory results MUST NOT be stored, and MUST live only as long as the search text and the active profile: any text change or profile switch clears them, and a reply that arrives after that (or after the search was cancelled) MUST NOT be shown.
**Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.reset()` bumps `generation`; every publish checks it); `ChatListView.swift` (`ChatListSearchBar` resets on `searchText` and `currentUser?.userId` changes); `NewChatMenuButton.swift` (`NewChatSheet` resets on the same changes).
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
@@ -162,16 +162,16 @@
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### RULE-26: Online lookups never show alerts
**Rule:** Resolving a SimpleX name online and looking up directory entries locally MUST NOT show any alert, including the network retry alert; a failure gives no row.
**Enforced by:** `SimpleXAPI.swift` (`apiConnectPlan(onFailure: .log)`); `DirectorySearchView.swift` (`inProgress: BoxedValue(false)` for every lookup).
**Rule:** Looking up directory entries locally MUST NOT show any alert, including the network retry alert; a failed lookup shows the entry with Open, whose online plan shows the real error.
**Enforced by:** `SimpleXAPI.swift` (`apiConnectPlan(onFailure: .log)`); `DirectorySearchView.swift` (`inProgress: BoxedValue(false)` for every lookup); `DirectorySearch.swift` (`directoryResultState` maps no plan to `.open`).
**Spec:** [spec/api.md](../spec/api.md#8-directory-search-and-connection-plans)
### RULE-27: Nothing is shown twice
**Rule:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. A directory result whose link refers to a chat the screen doesn't list MUST be drawn as that screen's chat row, which opens the chat, never as "Join …". When every result of the last page loaded is hidden as a chat the user has, the Directory part MUST say so ("All results on this page are already in your chats"); a result hidden only because a name was resolved through its link does not count. Limit: a regular group joined through another link, the directory's full link or by invitation is not found by the lookup and still appears as a directory result with "Join group".
**Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.rows(shownChatIds:)`, `pageInChats`); `DirectorySearch.swift` (`planLocalChatId`, `NameSearchResult.resolvedLink`); `ChatListView.swift` (`directoryRows`, `chatListNavLink`); `NewChatMenuButton.swift` (`onlineSearchSections`); `ContactListNavLink.swift` (`groupNavLink`).
**Rule:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link or joined with the full link the directory sends. A group joined without a prepared group (`/c`, an older app, a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking.
**Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.rows(shownChatIds:)`, `pageInChats`; `request` drops entries already shown); `DirectorySearch.swift` (`planLocalChatId`, `directoryResultState`); `ChatListView.swift` (`directoryRows`, `chatListNavLink`); `NewChatMenuButton.swift` (`onlineSearchSection`); `ContactListNavLink.swift` (`groupNavLink`).
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### RULE-28: Consent before the first online search
**Rule:** The search text MUST NOT be sent online until the user has confirmed the first-search alert, which names both recipients (SimpleX Directory and, for a SimpleX name, the server that resolves names), unless the user chose not to be asked again.
**Enforced by:** `DirectorySearchView.swift` (`confirmDirectorySearch`, `DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN`); both triggers in `ChatListView.swift` and `NewChatMenuButton.swift` go through it.
**Rule:** The search text MUST NOT be sent online while the "Directory search" switch in Privacy settings is off, unless the user confirms the "Enable Directory search?" alert, which says the text goes to SimpleX Directory and turns the switch on. Cancel MUST send nothing. The search never resolves SimpleX names, so the directory is its only recipient.
**Enforced by:** `DirectorySearchView.swift` (`confirmDirectorySearch`, `DEFAULT_PRIVACY_DIRECTORY_SEARCH`); `PrivacySettings.swift` (the switch); both triggers in `ChatListView.swift` and `NewChatMenuButton.swift` go through it.
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
+20 -14
View File
@@ -33,24 +33,29 @@ The toolbar supports two layout modes:
- When active, `searchMode = true` hides the navigation bar and shows inline search
- Filters chat list in real-time by contact/group name and message content
- Detects pasted SimpleX links (`searchShowingSimplexLink`) and offers to connect
- For non-empty text that is not a link, offers "Search in Directory"; the keyboard Search key searches online (see below)
- For non-empty text that is not a link, offers "Search in Directory"; the keyboard Search key does the same search (see below)
- The field doesn't autocorrect or capitalize, so the Search key searches exactly what was typed
### Online Search (Names and Directory)
### Online Search (SimpleX Directory)
Searching beyond the user's own chats happens only when the user asks, and the results are never saved: they disappear when the search text changes or the user switches profile.
| Element | Behavior |
|---|---|
| "Search in Directory" row | Shown for non-empty text that is not a SimpleX link. With the one-hand UI it takes the place of the tags above the search field; with the top bar it is below the field. Searches SimpleX Directory for public groups and channels |
| Keyboard Search key | Searches the directory and, when the text is a SimpleX name (e.g. `alice` or `#news.testing`), resolves the name online. For a bare name both the contact (`@`) and the channel (`#`) form are tried |
| "Connect to …" row | Shown for a SimpleX name, as before; hidden once the online search found anything for that name |
| First-search alert | Before the first online search: "Search in Directory?" — the text goes to SimpleX Directory, and a SimpleX name also to the user's server that resolves names; no contact is created and the profile is not sent. Cancel / Search / Search and don't show again ("Reset all hints" brings it back) |
| Names part | Below the local chats, headed "Names": avatar, profile name, the SimpleX name in parentheses, description. Tapping connects (or opens the chat if it is already known) |
| Directory part | Below the Names part, headed "Directory": avatar, `#` name, the directory's SimpleX name, description, member or subscriber count, and "Join channel" / "Join group". Tapping connects; "Show more" loads the next page; a failed request shows "Search failed, tap to retry". An entry for a group or channel the user already has, that the list above doesn't show (its name doesn't match the typed text), is shown as the regular chat row and opens the chat, never "Join …". When every result of the last page loaded is a chat already in the list or found by name, the part shows "All results on this page are already in your chats" (secondary colour), followed by "Show more" when there are more pages; nothing loads more pages automatically |
| "Search in Directory" row | Shown for non-empty text that is not a SimpleX link, until results for that text show or load. With the one-hand UI it takes the place of the tags above the search field (the tags don't come back while the text is there); with the top bar it is below the field. Searches SimpleX Directory for public groups and channels |
| Keyboard Search key | The same directory search. It never looks up SimpleX names |
| "Connect to …" row | Shown for a SimpleX name (e.g. `alice` or `#news.testing`), and stays next to the results; it is the only way to look up a name |
| "Directory search" switch | In Settings › Privacy › Chats, after "Send link previews"; off by default |
| "Enable Directory search?" alert | Every search asks while the switch is off: search text is sent to SimpleX Directory, each search separately and without the user's profile, so it can't build a search history. Cancel sends nothing; Enable turns the switch on and searches. Once it is on, searches don't ask |
| Results | One list without headers below the local chats. First your chats among the results that the list above doesn't show (for example because the name doesn't match the typed text), as their regular chat rows; then the other results, with the same avatar size as chat rows: avatar, `#` name, the directory's SimpleX name, description, then the member or subscriber count and "Open" on the right. A join still in progress without a chat shows "Already joining" in place of the count and no "Open" |
| Open | Tapping a result (or its "Open") loads the group's or channel's profile, with the spinner in the search field, and opens the chat straight away, with its Join button and no "Open channel?" question; a channel's banner shows its subscriber count. The user joins from there. A result that is already joining shows the same sheet or alert as pasting its link would |
| Long press | On iOS 16 and later, a long press on a result shows a menu with Open, previewing the row; iOS 15 has no menu |
| Load row | The last row of the results: "Load more · N left" when the directory says how many matches are left, "Load more" otherwise; greyed while a page loads. A failed request turns it into "Search failed, tap to retry". Nothing loads more pages automatically |
| Page note | When every result of the last page loaded is a chat already in the list, "All results on this page are already in your chats" (secondary colour), followed by the load row when there are more pages |
A result is not shown twice: a name or directory entry for a chat already shown in the list is hidden, and a directory entry is hidden when the name search found the same channel (matched by its link). A group the user joined through a link other than the directory's, or by invitation, is not recognised: it still appears in the Directory part with "Join group". Directory entries that are not group or channel links are ignored. Looking up a name never shows an error; a name that is not found simply gives no row, and "Connect to …" stays.
A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without preparing it first (with an older app, by `/c`, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat. Directory entries that are not group or channel links are ignored.
With the one-hand UI the whole list is mirrored: going up from the search field at the bottom come the local chats, then Names, then Directory, each part's header above its rows. This order is intended, so the user's own chats stay nearest the field.
With the one-hand UI the whole list is mirrored: going up from the search field at the bottom come the local chats, then your chats from the results, then the other results, with the load row at the top, farthest from the field. This order is intended, so the user's own chats stay nearest the field.
With no conversations yet (onboarding), the online results replace the onboarding pages while a search runs or has results. With conversations, while an online search runs or after it has run, even one that found nothing, the list hides the banner (Wefunder or badges), the one-hand UI card and the "New 1-time link" / "Paste link / Scan" cards; filtering the chats by typing keeps them, and they come back when the search text is cleared (× or Cancel).
@@ -128,8 +133,9 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve
| Connection in progress | `ConnectProgressManager` overlay with connecting text |
| Search with no results | "No chats found" in the middle of the list, unless an online search is running or shows results |
| Online search running | Progress indicator in the search field (after about a second); tapping a result or clearing the search cancels it |
| Directory search failed | "Search failed, tap to retry" row at the end of the Directory part |
| Online search found nothing | No Names or Directory part; "No chats found" when no local chat matches either (in onboarding, a "No chats found" row) |
| Loading more results | The "Load more" row is greyed and can't be tapped until the page arrives |
| Directory search failed | "Search failed, tap to retry" row at the end of the results, in place of the load row |
| Online search found nothing | No result rows; "No chats found" when no local chat matches either (in onboarding, a "No chats found" row) |
## Related Specs
@@ -153,5 +159,5 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve
- `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card
- `Shared/Views/ChatList/GetStakeBanner.swift` -- Wefunder crowdfunding banner and shared banner card chrome
- `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary
- `Shared/Views/ChatList/DirectorySearchView.swift` -- Online search model, Names and Directory rows, first-search alert
- `Shared/Model/DirectorySearch.swift` -- Directory response parsing and online result types
- `Shared/Views/ChatList/DirectorySearchView.swift` -- Online search model, result, load and retry rows, long-press menu, Open, "Enable Directory search?" alert
- `Shared/Model/DirectorySearch.swift` -- Directory response parsing and result states
+8 -8
View File
@@ -71,17 +71,17 @@ Accessed via `NewChatMenuButton` dropdown:
### Search (New Chat Sheet)
The search field at the top of the New chat sheet filters "Your Contacts" and offers the same online search as the chat list (see [Chat List, Online Search](chat-list.md#online-search-names-and-directory)):
The search field at the top of the New chat sheet filters "Your Contacts" and offers the same online search as the chat list (see [Chat List, Online Search](chat-list.md#online-search-simplex-directory)):
| Element | Behavior |
|---|---|
| "Connect to …" row | Below the field for a SimpleX name; hidden once the online search found anything for that name |
| "Search in Directory" row | Below the field for non-empty text that is not a SimpleX link; searches SimpleX Directory |
| Keyboard Search key | Searches the directory and, for a SimpleX name, resolves the name online |
| Names and Directory sections | After "Your Contacts", with headers in the same style. Tapping a result connects and closes the sheet, as other New chat actions do. A result for a contact already listed in "Your Contacts" is not repeated. A directory entry for a group or channel the user already has is shown as a contact-style chat row (avatar, name, favourite star, incognito mask) that opens the chat and closes the sheet, never "Join …". When every result of the last page loaded is a chat the user has, the Directory section shows "All results on this page are already in your chats", followed by "Show more" when there are more pages |
| "Connect to …" row | Below the field for a SimpleX name; stays next to the results |
| "Search in Directory" row | Below the field for non-empty text that is not a SimpleX link, until results for that text show or load; searches SimpleX Directory |
| Keyboard Search key | The same directory search; it never looks up names |
| Results card | One card without a header after "Your Contacts". First your chats among the results that "Your Contacts" doesn't list, as contact-style chat rows (avatar, name, favourite star, incognito mask) that open the chat and close the sheet; then the other results with New chat's small icons, each with "Open" (or "Already joining"), and the same long-press menu as on the chat list. Open opens the prepared chat and closes the sheet, as other New chat actions do. When every result of the last page loaded is a chat the user has, the card shows "All results on this page are already in your chats". The card ends with the load row ("Load more · N left" or "Load more") or the retry row |
| Contacts empty state | "No filtered contacts" / "No contacts" are not shown while the online search runs or shows results |
The first online search asks for consent with the same alert as on the chat list. Results disappear when the text changes or the user switches profile. Archived contacts keep their own search field without the Search key or online results.
While the "Directory search" switch is off, every online search asks with the same "Enable Directory search?" alert as on the chat list. Results disappear when the text changes or the user switches profile. Archived contacts keep their own search field without the Search key or online results.
## Loading / Error States
@@ -93,7 +93,7 @@ The first online search asks for consent with the same alert as on the chat list
| Connection in progress | Chat list shows pending connection entry |
| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options |
| Online search running | Progress indicator in the search field; closing the sheet cancels the search |
| Directory search failed | "Search failed, tap to retry" row at the end of the Directory section |
| Directory search failed | "Search failed, tap to retry" row at the end of the results card |
## Create Channel (`AddChannelView`)
@@ -162,7 +162,7 @@ When preparing a channel link, `groupShortLinkInfo.groupRelays` (hostnames) are
## Source Files
- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation
- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel); New chat sheet search, contacts list and online search sections
- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel); New chat sheet search, contacts list and online search results card
- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display
- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form
- `Shared/Views/NewChat/AddChannelView.swift` -- Channel creation wizard (3 steps)
+1 -1
View File
@@ -40,7 +40,7 @@ SimpleXApp (root entry point)
| [State Management](state.md) | ChatModel, ItemsModel, Chat, ChatInfo, preference storage |
| [Database & Storage](database.md) | SQLite databases, encryption, file storage, export/import |
| [Chat View](client/chat-view.md) | Message rendering, chat item types, context menu actions |
| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions, online search (SimpleX Directory and names) |
| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions, online search (SimpleX Directory) |
| [Message Composition](client/compose.md) | Compose bar, attachments, reply/edit/forward modes, voice recording |
| [Navigation](client/navigation.md) | Navigation stack, deep linking, sheet presentation, call overlay, New chat online search |
| [Push Notifications](services/notifications.md) | NtfManager, NSE, notification modes, token lifecycle |
+3 -3
View File
@@ -610,9 +610,9 @@ Wrappers in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift)
| Function | Description | Source |
|----------|-------------|--------|
| `apiSearchDirectory(_ text:, cursor:)` | Sends `apiSendServiceRequest` to `DIRECTORY_SERVICE_LINK` with `DIRECTORY_SEARCH_TIMEOUT_SEC` and the search request; returns `parseDirectorySearchResponse(responseData)`, or nil on any error (logged). Blocks for up to the timeout | [L1044](../Shared/Model/SimpleXAPI.swift#L1044) |
| `apiConnectPlan(connLink:, resolveMode:, linkOwnerSig:, inProgress:, onFailure:)` | Plans a connection; returns `ConnectionPlanResult?`. `notResolvedLocally` (a `.never` lookup that matched nothing) returns nil silently; any other failure is handled by `onFailure` | [L1064](../Shared/Model/SimpleXAPI.swift#L1064) |
| `ConnectPlanFailure` | `.alert` (default, every existing caller): `apiConnectResponseAlert`; `.log`: debug log only, used by the online search so name resolution and per-entry lookups never raise alerts | [L1060](../Shared/Model/SimpleXAPI.swift#L1060) |
| `apiSearchDirectory(_ text:, cursor:)` | Sends `apiSendServiceRequest` to `DIRECTORY_SERVICE_LINK` with `DIRECTORY_SEARCH_TIMEOUT_SEC` and the search request; returns `parseDirectorySearchResponse(responseData)` (the entries, the cursor and `moreCount`), or nil on any error (logged). Blocks for up to the timeout | [L1044](../Shared/Model/SimpleXAPI.swift#L1044) |
| `apiConnectPlan(connLink:, resolveMode:, linkOwnerSig:, inProgress:, onFailure:)` | Plans a connection; returns `ConnectionPlanResult?`. `notResolvedLocally` (a `.never` lookup that matched nothing) returns nil silently; any other failure is handled by `onFailure`. The online search reads nil from a `.never` plan of a directory result as "not one of your chats" | [L1064](../Shared/Model/SimpleXAPI.swift#L1064) |
| `ConnectPlanFailure` | `.alert` (default, every existing caller): `apiConnectResponseAlert`; `.log`: debug log only, used by the online search so per-entry lookups never raise alerts | [L1060](../Shared/Model/SimpleXAPI.swift#L1060) |
A service response is carried as [`JSONValue`](../SimpleXChat/APITypes.swift#L722) (any JSON), so the framework does not depend on a service's own types; callers decode it with [`decodeJSONValue`](../SimpleXChat/APITypes.swift#L760).
+70 -62
View File
@@ -64,12 +64,12 @@ 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()`](../../Shared/Views/ChatList/ChatListView.swift#L761)
- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L723)
- Sorts by last activity (most recent first), with pinned chats at top
### Layout
- Uses SwiftUI `List` with `ForEach` over filtered chats
- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView`, drawn with its list modifiers by [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L676), which also draws known chats among the Directory results (see [Online Search](#11-online-search))
- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView`, drawn with its list modifiers by [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L638), which also draws your chats among the online search results (see [Online Search](#11-online-search))
- Pull-to-refresh triggers `updateChats()` API call
- Empty state: `ChatHelp` view with getting-started guidance
@@ -81,11 +81,11 @@ The root list view. Key responsibilities:
| Function | Line | Description |
|----------|------|-------------|
| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L188) | 188 | Main view body |
| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L761) | 761 | Applies active filter and search to chat list |
| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L804) | 804 | Normalizes search text for comparison |
| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L735) | 735 | Renders unread count circle badge |
| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L755) | 755 | Stops any playing voice message |
| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L189) | 189 | Main view body |
| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L723) | 723 | Applies active filter and search to chat list |
| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L766) | 766 | Normalizes search text for comparison |
| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L697) | 697 | Renders unread count circle badge |
| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L717) | 717 | Stops any playing voice message |
---
@@ -173,7 +173,7 @@ Horizontal scrolling tab bar below the navigation bar. Tabs:
| Group Reports | `.presetTag(.groupReports)` | Groups with pending reports |
| User tags | `.userTag(ChatTag)` | User-defined custom tags |
Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1378) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1172) struct.
Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1335) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1129) struct.
### ChatTagsModel State
@@ -198,7 +198,7 @@ class ChatTagsModel: ObservableObject {
|------|------|------|-------------|
| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 36 | Enum of built-in filter categories |
| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L53) | ChatListView.swift | 53 | Enum wrapping preset, user-tag, or unread filter |
| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1357) | ChatListView.swift | 1357 | Applies a filter and persists selection |
| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1314) | ChatListView.swift | 1314 | Applies a filter and persists selection |
### Tag Management Commands
- `apiCreateChatTag(tag: ChatTagData)` -- create tag
@@ -213,7 +213,7 @@ class ChatTagsModel: ObservableObject {
Search is available via pull-down gesture or search button in the navigation bar.
**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L868)
**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L830)
### Filtering Logic
- Filters `ChatModel.chats` by matching search text against:
@@ -221,13 +221,13 @@ Search is available via pull-down gesture or search button in the navigation bar
- `chatInfo.localAlias` (local alias)
- `chatInfo.fullName` (full name)
- For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter
- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L761) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L804)
- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L723) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L766)
### 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
- Searching SimpleX Directory and resolving SimpleX names online happen only on request, below the local results: see [Online Search](#11-online-search)
- Searching SimpleX Directory online happens only on request, below the local results: see [Online Search](#11-online-search)
---
@@ -292,7 +292,7 @@ Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS`
| Where | Condition | Layout |
|-------|-----------|--------|
| Chat list | rendered with [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L551) while no online results are shown ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L554)), in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` |
| Chat list | rendered with [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L514) while no online results are shown ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L517)), in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` |
| Onboarding | below [`ConnectOnboardingView`](../../Shared/Views/NewChat/OnboardingCards.swift#L135) when `shouldShowOnboarding` | `.padding(.horizontal, 20)` (the onboarding cards' margin), `.padding(.bottom, 8)` |
The list has a single banner slot, empty while online search results are shown (see [Rendering](#rendering)), and otherwise 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`.
@@ -303,7 +303,7 @@ In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied t
| Default | Set by | Effect |
|---------|--------|--------|
| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L546) | the dismiss X appears from then on, while there are chats |
| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L509) | 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 |
@@ -324,37 +324,35 @@ Tapping the card opens [`GetStakeView`](../../Shared/Views/Onboarding/WhatsNewVi
**Files**: `Shared/Views/ChatList/DirectorySearchView.swift` (model and rows), `Shared/Model/DirectorySearch.swift` (response and result types), `Shared/Views/ChatList/ChatListView.swift` (triggers and rendering)
The search text can be sent online on request: to SimpleX Directory, to find public groups and channels, and, when the text is a SimpleX name, to the user's name-resolving server. Results are shown below the local chats in two parts, Names then Directory. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L868) calls `reset()` on every change of `searchText` ([L957](../../Shared/Views/ChatList/ChatListView.swift#L957)) and of `currentUser?.userId` ([L960](../../Shared/Views/ChatList/ChatListView.swift#L960)). The New chat sheet uses the same model and rows (see [Navigation, New Chat Online Search](navigation.md#12-new-chat-online-search)).
The search text can be sent online on request to SimpleX Directory, to find public groups and channels. Results are shown below the local chats as one list without headers: your chats first, then the other results. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L830) calls `reset()` on every change of `searchText` ([L921](../../Shared/Views/ChatList/ChatListView.swift#L921)) and of `currentUser?.userId` ([L924](../../Shared/Views/ChatList/ChatListView.swift#L924)). The New chat sheet uses the same model and rows (see [Navigation, New Chat Online Search](navigation.md#12-new-chat-online-search)).
### Triggers
| Trigger | Handler | Runs |
|---------|---------|------|
| Keyboard Search key (`.submitLabel(.search)`) | [`runOnlineSearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1068) | `search(text, name: connectNameCandidate)`: the directory's first page, and name resolution when the text is a name candidate |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L211)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1062) | `searchDirectory(text)`: the directory only |
| Keyboard Search key (`.submitLabel(.search)`) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1026) | `searchDirectory(text)` |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L175)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1026) | `searchDirectory(text)` |
Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link, and both go through [`confirmDirectorySearch()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L351), which runs the search at once when `DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN` is set and otherwise shows the three-action alert ([`showDirectorySearchAlert`](../../Shared/Views/ChatList/DirectorySearchView.swift#L359)): Cancel / Search / Search and don't show again. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The key is in `hintDefaults`, so "Reset all hints" brings the alert back.
Both triggers run the same directory search, which never resolves SimpleX names: a name is looked up only by "Connect to …" (`ConnectByNameRow`). The field neither autocorrects nor capitalizes, so the Search key searches what was typed. Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link, and both go through [`confirmDirectorySearch()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L342), which runs the search at once when the "Directory search" switch is on (`DEFAULT_PRIVACY_DIRECTORY_SEARCH`, default false, in Privacy settings › Chats after "Send link previews") and otherwise shows the alert [`showDirectorySearchAlert`](../../Shared/Views/ChatList/DirectorySearchView.swift#L350): "Enable Directory search?", with the message "Search text is sent to SimpleX Directory, each search separately and without your profile, so it can't build your search history." and two actions, Cancel (sends nothing) and Enable (turns the switch on, then searches). So every search asks while the switch is off. The key is not in `hintDefaults`: "Reset all hints" leaves the switch as it is.
The rows above or below the search field follow the toolbar mode:
The rows above or below the search field follow the toolbar mode. "Search in Directory" hides while results for the current text show or load (`!onlineSearch.showResults`); "Connect to …" stays next to them:
| Search text | One-hand UI (slot above the field, [L890](../../Shared/Views/ChatList/ChatListView.swift#L890)) | Top bar (below the field, [L941](../../Shared/Views/ChatList/ChatListView.swift#L941)) |
| Search text | One-hand UI (slot above the field, [L852](../../Shared/Views/ChatList/ChatListView.swift#L852)) | Top bar (below the field, [L905](../../Shared/Views/ChatList/ChatListView.swift#L905)) |
|-------------|-----------------------------------|------------------------------|
| Empty | Tags | Tags above; nothing below |
| Plain text | "Search in Directory" | Tags above; "Search in Directory" below |
| Name candidate, no name results yet | "Connect to …" and "Search in Directory" | Tags above; "Connect to …" and "Search in Directory" below |
| Name candidate with name results | "Search in Directory" | Tags above; "Search in Directory" below |
| Plain text | "Search in Directory"; nothing while results show (the tags don't come back) | Tags above; "Search in Directory" below, nothing while results show |
| Name candidate | "Connect to …", and "Search in Directory" until results show | Tags above; "Connect to …" below, and "Search in Directory" until results show |
| Pasted SimpleX link | Tags | Tags above; nothing below |
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L209)
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L173)
`@MainActor` `ObservableObject`, owned by `ChatListView` as `@StateObject onlineSearch` and passed to `ChatListSearchBar`.
| State | Description |
|-------|-------------|
| `names: [NameSearchResult]` | Name results for the current text |
| `directory: [DirectoryResult]` | Directory entries shown so far, each with the local chat its link refers to |
| `directory: [DirectoryResult]` | Directory entries shown so far, each with its `DirectoryResultState` (see Directory requests) |
| `moreCount: Int?` | The count of matches left that the last successful page carried; nil when the directory sent none or an invalid one |
| `loading` | A directory request is in flight |
| `resolvingNames` | Name resolution is in flight |
| `failed` | The last directory request failed |
| `searched` | A directory request for the current text completed, successfully or not (tells "not searched" from "found nothing") |
| `cursor` (private) | Opaque `JSONValue` from the last page, echoed back for the next one; nil when there are no more |
@@ -363,71 +361,81 @@ The rows above or below the search field follow the toolbar mode:
| Computed | Definition |
|----------|------------|
| [`searching`](../../Shared/Views/ChatList/DirectorySearchView.swift#L34) | `loading \|\| resolvingNames` |
| [`showResults`](../../Shared/Views/ChatList/DirectorySearchView.swift#L35) | `searching \|\| searched \|\| !names.isEmpty` |
| [`footer`](../../Shared/Views/ChatList/DirectorySearchView.swift#L36) | `DirectoryFooter.retry` when `failed`, else `.showMore` when a cursor exists, else nil |
| [`searching`](../../Shared/Views/ChatList/DirectorySearchView.swift#L33) | `loading` |
| [`showResults`](../../Shared/Views/ChatList/DirectorySearchView.swift#L34) | `loading \|\| searched` |
| [`footer`](../../Shared/Views/ChatList/DirectorySearchView.swift#L35) | `DirectoryFooter.retry` when `failed`, else `.loadMore` when a cursor exists, else nil |
| Method | Description |
|--------|-------------|
| [`reset()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L38) | Clears all results and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L52) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay |
| [`search(_:name:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L58) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`async let`, with the generation captured before the child task starts) |
| [`searchDirectory(_:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L75) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page |
| [`loadMore()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L91) | "Show more": the next page, appended |
| [`retry()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L97) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L105) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshLocalChatIds()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L126) | The `cleanup` of a connection started from a result row, run when its flow ends: looks the local chat up again, store-only, for every row that has none (a name row by its target, a directory row by its link), unless the generation changed. A chat opened from a result is created during that flow, so its row is then hidden like any shown chat, or drawn as a chat row when the list doesn't show it |
| [`reset()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L37) | Clears all results, `moreCount` and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L50) | The spinner's `onCancel`: bumps `generation` and clears `loading`; results already shown stay |
| [`searchDirectory(_:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L55) | Both triggers: ignored for empty text or while `loading` for the same text; for the same text it clears the results, `moreCount`, cursor and flags, for new text it resets; then the first page |
| [`loadMore()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L72) | The load row: the next page, appended |
| [`retry()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L78) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L86) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshStates()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L113) | The `cleanup` of a connection started from a result row, run when its flow ends: plans every result that is not `.yourChat` again, store-only, unless the generation changed. A chat opened from a result is created during that flow, so its result then becomes `.yourChat`: hidden like any shown chat, or drawn as its chat row when the list doesn't show it |
**Spinner.** `loading` and `resolvingNames` have a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L195), which starts the "Searching directory…" progress with owner `.directorySearch` when `searching` becomes true and stops it when it becomes false (`spinnerShown` keeps the last state, so repeated writes do nothing). The search field shows it as a spinner once it has run for a second. The spinner is shared with connections: [`ConnectProgressOwner`](../../Shared/Model/ChatModel.swift#L303) records whose it is, [`startConnectProgress`](../../Shared/Model/ChatModel.swift#L316) does not let a search take it from a running connection, and [`stopConnectProgress`](../../Shared/Model/ChatModel.swift#L327) ignores a stop from another owner. Tapping a result calls `planAndConnect`, whose `cancelConnectProgress()` ([L336](../../Shared/Model/ChatModel.swift#L336)) cancels the search through its `onCancel` before the connection takes the spinner.
**Spinner.** `loading` has a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L159), which starts the "Searching directory…" progress with owner `.directorySearch` when `searching` becomes true and stops it when it becomes false (`spinnerShown` keeps the last state, so repeated writes do nothing). The search field shows it as a spinner once it has run for a second. The spinner is shared with connections: [`ConnectProgressOwner`](../../Shared/Model/ChatModel.swift#L303) records whose it is, [`startConnectProgress`](../../Shared/Model/ChatModel.swift#L316) does not let a search take it from a running connection, and [`stopConnectProgress`](../../Shared/Model/ChatModel.swift#L327) ignores a stop from another owner. Tapping a result calls `planAndConnect`, whose `cancelConnectProgress()` ([L336](../../Shared/Model/ChatModel.swift#L336)) cancels the search through its `onCancel` before the connection takes the spinner.
### Directory requests
[`request(append:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L170) calls [`apiSearchDirectory`](../../Shared/Model/SimpleXAPI.swift#L1044), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../Shared/Model/DirectorySearch.swift#L14) with a [10-second timeout](../../Shared/Model/DirectorySearch.swift#L17) and the request built by [`directorySearchRequestJSON`](../../Shared/Model/DirectorySearch.swift#L84) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../Shared/Model/DirectorySearch.swift#L92) accepts only a `searchResults` response and decodes its entries one at a time, so an entry the app cannot decode is dropped and the rest of the page is kept.
[`request(append:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L133) calls [`apiSearchDirectory`](../../Shared/Model/SimpleXAPI.swift#L1044), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../Shared/Model/DirectorySearch.swift#L14) with a [10-second timeout](../../Shared/Model/DirectorySearch.swift#L17) and the request built by [`directorySearchRequestJSON`](../../Shared/Model/DirectorySearch.swift#L86) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../Shared/Model/DirectorySearch.swift#L94) accepts only a `searchResults` response and decodes its entries one at a time, so an entry the app cannot decode is dropped and the rest of the page is kept.
The directory's response (`DRSearchResults`, apps/simplex-directory-service/src/Directory/Rpc.hs) is `{"type": "searchResults", "entries": […], "searchCursor": …, "moreCount": N, "pageSize": N}`. `searchCursor` is absent when there are no more results. `moreCount` is the number of matches after the cursor, 0 without one. `pageSize` is the directory's page size, which the app does not read: a page is cut to fit the service envelope, so it often carries fewer entries than that. The app keeps `moreCount` only when it is a JSON number that is an integer in 0…`Int32.max` ([L102](../../Shared/Model/DirectorySearch.swift#L102)); anything else becomes nil without failing the response. It is only shown as text in the load row. An older directory sends no count, and the row then reads "Load more".
[GAP] `moreCount` counts the matching listings, including ones without a public link that the directory never sends, so the load row can promise more results than it brings.
[`DirectorySearchEntry`](../../Shared/Model/DirectorySearch.swift#L29-L64) has a custom [`init(from:)`](../../Shared/Model/DirectorySearch.swift#L46): its `link` is the wire `groupLink.connShortLink`, else `connFullLink`, and decoding throws unless [`simplexLinkType`](../../Shared/Model/DirectorySearch.swift#L66) classifies it as `.group` or `.channel`. `simplexLinkType` returns the link type only when [`strConnectTarget`](../../Shared/Views/NewChat/NewChatView.swift#L860) finds exactly one SimpleX link whose text is the whole string. `link` is also the entry's `id`. The directory sends only the short link when an entry has one.
For each new entry of a page (not already shown, not repeated in the page), the request looks up the local chat its link refers to with `apiConnectPlan(connLink: entry.link, resolveMode: .never, onFailure: .log)` and [`planLocalChatId`](../../Shared/Model/DirectorySearch.swift#L102), building a [`DirectoryResult`](../../Shared/Model/DirectorySearch.swift#L173). The link-type check makes this lookup store-only: the core plans contact-mode links under `.never` from the store, but would fetch an invitation link from its server. The page is published, appended or replacing, only if the generation did not change.
For each new entry of a page (not already shown, not repeated in the page), the request plans its link with `apiConnectPlan(connLink: entry.link, resolveMode: .never, onFailure: .log)` ([`resultState`](../../Shared/Views/ChatList/DirectorySearchView.swift#L128)) and maps the plan with [`directoryResultState`](../../Shared/Model/DirectorySearch.swift#L136) to a [`DirectoryResultState`](../../Shared/Model/DirectorySearch.swift#L129), building a [`DirectoryResult`](../../Shared/Model/DirectorySearch.swift#L144) `{entry, state}`:
### Name resolution
| State | Plan | Drawn as |
|-------|------|----------|
| `.yourChat(chatId)` | [`planLocalChatId`](../../Shared/Model/DirectorySearch.swift#L106) names a chat (a known or prepared group, your own group, or a pending join with its chat) | the chat's own row |
| `.joining` | `.groupLink(.connectingConfirmReconnect)` or `.groupLink(.connectingProhibit(nil))`: a pending join without a chat | a result with "Already joining" |
| `.open` | anything else: `.groupLink(.ok)`, or no plan (`notResolvedLocally` for a link the store doesn't know, which `apiConnectPlan` returns as nil without an alert, or a logged failure) | a result with Open |
[`resolveNames(_:generation:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L154) resolves `[name]` for a candidate starting with `@` or `#`, else `["@" + name, "#" + name]` (the candidate comes from `nameSearchCandidate`, as on the typing path). Each target is planned with `apiConnectPlan(connLink: target, resolveMode: .unknown, onFailure: .log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult.init?(target:result:)`](../../Shared/Model/DirectorySearch.swift#L138) maps the plan with one exhaustive switch: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName.shortStr`, `localChatId` comes from `planLocalChatId`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; `inProgress: BoxedValue(false)` also suppresses the network retry alert.
The link-type check makes this lookup store-only: the core plans contact-mode links under `.never` from the store, but would fetch an invitation link from its server. A short link reaches a connecting plan only after a network fetch, and the directory sends the short link whenever an entry has one, so `.joining` is reachable only for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change.
### De-duplication
[`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L105) returns an [`OnlineSearchRows`](../../Shared/Model/DirectorySearch.swift#L180) at render time, so pages added by "Show more" are filtered too:
[`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L86) returns an [`OnlineSearchRows`](../../Shared/Model/DirectorySearch.swift#L151) `{yourChats, results, pageInChats, footer}` at render time, so pages added by "Load more" are handled too:
- a name result is hidden when its `localChatId` is among the shown local chats;
- a directory result is hidden when its `localChatId` is among the shown local chats or any name result's `localChatId`, or when its `entry.link` equals any name result's `resolvedLink` (all name results, shown or not);
- `pageInChats` is true when the last page's results (`lastPageIds`) are not empty and each one's `localChatId` is among the shown local chats or the name results' chat ids. A result hidden only because a name result resolved to its link is not a chat the user has, so it does not count, and the part keeps its usual look;
- `yourChats`: the chat ids of `.yourChat` results whose chat is loaded (`ChatModel.shared.getChat`) and not among the shown local chats, in directory order, each id once (two entries with different links can name one group);
- `results`: the other results, in directory order; a `.yourChat` result whose chat is not loaded becomes `.open`, and Open's online plan then finds the known chat;
- `pageInChats` is true when the last page's results (`lastPageIds`) are not empty and each one is `.yourChat` with its chat among the shown local chats. A result the filter didn't show joins `yourChats`, which is a visible change, so it does not count;
- `footer` passes through.
Only store-derived chat ids and the link the core resolved can hide a row, never a field the directory or a name's owner wrote. A mismatch in link encoding can only produce a duplicate row.
So nothing is shown twice: a result that is a chat the list shows is hidden, a chat the list doesn't show (the local filter doesn't match it) is drawn once as its own row, and a link repeated across pages was already dropped by the request. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row.
A directory result that is a chat the user has but that the screen doesn't list (the local filter doesn't match it) stays in `rows.directory` and is drawn as a chat row (see Rendering). When its chat id is not among the loaded chats (`getChat` returns nil), the Directory row is drawn as before, and tapping it goes through `planAndConnect`, which opens the known chat.
[GAP] A regular group the user joined through a different link, through the directory's full link, or by invitation, is not found by the store lookup and still appears as a Directory row with "Join group"; tapping it goes through `planAndConnect`.
[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row. For a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `.ok`, so Open prepares and opens a second copy of the group without asking. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins this.
### Rendering
| Where | Rows | Empty state |
|-------|------|-------------|
| [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L551) | `rows(shownChatIds:)` with the ids of the shown chats ([L553](../../Shared/Views/ChatList/ChatListView.swift#L553)); after the local chats, [`nameRows`](../../Shared/Views/ChatList/ChatListView.swift#L483) then [`directoryRows`](../../Shared/Views/ChatList/ChatListView.swift#L499). While `showResults` ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L554) false), the list leaves out `OneHandUICard`, the banner slot and `ConnectBannerCard`; they return when the search text is cleared, which resets the model | `noChatsView()` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([L668](../../Shared/Views/ChatList/ChatListView.swift#L668)) |
| Onboarding branch of [`chatList`](../../Shared/Views/ChatList/ChatListView.swift#L434) | While `showResults`, a list of the same parts with `shownChatIds: []` ([L449](../../Shared/Views/ChatList/ChatListView.swift#L449)) in place of the onboarding pages | A centred "No chats found" row when not searching and `rows.isEmpty` |
| [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L514) | `rows(shownChatIds:)` with the ids of the shown chats ([L516](../../Shared/Views/ChatList/ChatListView.swift#L516)); after the local chats, [`directoryRows`](../../Shared/Views/ChatList/ChatListView.swift#L483). While `showResults` ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L517) false), the list leaves out `OneHandUICard`, the banner slot and `ConnectBannerCard`; they return when the search text is cleared, which resets the model | `noChatsView()` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([L630](../../Shared/Views/ChatList/ChatListView.swift#L630)) |
| Onboarding branch of [`chatList`](../../Shared/Views/ChatList/ChatListView.swift#L435) | While `showResults`, a list of the same rows with `shownChatIds: []` ([L451](../../Shared/Views/ChatList/ChatListView.swift#L451)) in place of the onboarding pages | A centred "No chats found" row when not searching and `rows.isEmpty` |
Each part renders only when it has rows: the Names part when `rows.names` is not empty, the Directory part when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set. Each has a header row ([`searchPartHeader`](../../Shared/Views/ChatList/ChatListView.swift#L536): callout font, secondary colour, `.isHeader` trait, no separator) rather than a sticky `Section` header, since the one-hand list is flipped. [`searchPart`](../../Shared/Views/ChatList/ChatListView.swift#L526) places it before the part's rows with the top bar and after them with the one-hand UI, so it is drawn above them in both layouts, the one-hand list being flipped. Every row carries the per-row flip and a clear background.
`directoryRows` draws one list without headers, in this order: your chats (`rows.yourChats`), the results, the note, then the footer row. So the load or retry row is the last row of the results: at the bottom with the top bar, and at the top with the one-hand UI, the list being flipped, which puts it farthest from the field. Every row carries the per-row flip and a clear background.
| Row | View | Tap |
|-----|------|-----|
| Name | [`NameSearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L286): avatar, profile name in bold, the SimpleX name in parentheses (secondary), description (2 lines) | `planAndConnect(result.target, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Directory entry | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L233): avatar, `#` + name, the directory's SimpleX name claim as plain text, description (2 lines), member or subscriber count, and the verb "Join channel" / "Join group" (one line, fixed size) | `planAndConnect(result.entry.link, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Known chat | When `result.localChatId` names a loaded chat (`chatModel.getChat`), in place of the directory entry: the chat list's own row, [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L676) (`ChatListNavLink` with the local chats' modifiers) | as a local chat: opens it |
| Note | [`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L313) via [`directoryPageInChatsRow()`](../../Shared/Views/ChatList/ChatListView.swift#L694), after the directory rows when `rows.pageInChats`: "All results on this page are already in your chats", secondary colour | none |
| Retry | [`DirectoryRetryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L337) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L706): "Search failed, tap to retry" | `retry()` |
| Show more | [`DirectoryShowMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L322) via [`directoryShowMoreRow()`](../../Shared/Views/ChatList/ChatListView.swift#L700): "Show more", disabled and in the secondary colour while `loading` | `loadMore()` |
| Your chat | Each loaded chat of `rows.yourChats`, drawn with the chat list's own row, [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L638) (`ChatListNavLink` with the local chats' modifiers) | as a local chat: opens it |
| Result | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L197) with `imageSize: dynamicSize(userFont).profileImageSize`, the chat rows' avatar size (63 pt at the default text size): avatar, `#` + name, the directory's SimpleX name claim as plain text, description (2 lines), then the caption: the member or subscriber count, or "Already joining" for `.joining` (secondary colour); trailing "Open" (primary colour, one line, fixed size), except for `.joining` | [`openResult(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L504): `openDirectoryResult(result, dismiss: false)`, `cleanup`: `refreshStates()` (see Opening a result) |
| Note | [`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L259) via [`directoryPageInChatsRow()`](../../Shared/Views/ChatList/ChatListView.swift#L656), after the results when `rows.pageInChats`: "All results on this page are already in your chats", secondary colour | none |
| Load more | [`DirectoryLoadMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L268) via [`directoryLoadMoreRow()`](../../Shared/Views/ChatList/ChatListView.swift#L662): `plus.magnifyingglass` and "Load more · %d left" when `moreCount` is above 0, else "Load more"; disabled and in the secondary colour while `loading` | `loadMore()` |
| Retry | [`DirectoryRetryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L294) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L668): `arrow.clockwise` and "Search failed, tap to retry" | `retry()` |
A name or directory row tap first sets `searchFocussed = false`. Text from the directory and from name owners is rendered with `Text(String)` / `Text(verbatim:)` (never parsed as markdown) with line limits.
On iOS 16 and later a long press on a result shows a context menu, [`DirectoryResultMenu`](../../Shared/Views/ChatList/DirectorySearchView.swift#L311), with one action, Open (`message` icon), which does what a tap does. Its preview is the row without its Open, padded, the screen width less 32 pt, on the theme background. The modifier is applied before the row's flip, so in the one-hand UI the preview is drawn upright (checked in the iOS simulator tests). iOS 15 has no menu; a tap still opens.
[GAP] Resolving a SimpleX name with `.unknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server.
A result tap first sets `searchFocussed = false`. Text from the directory is rendered with `Text(String)` (never parsed as markdown) with line limits.
### Opening a result
[`openDirectoryResult(_:theme:dismiss:cleanup:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L337) calls [`planAndConnect`](../../Shared/Views/NewChat/NewChatView.swift#L1342) with the result's link and `prepareGroup: .open`, or `.confirm` (the default) for `.joining`. `planAndConnect` makes the online plan, with the spinner in the field. [`PrepareGroupMode`](../../Shared/Views/NewChat/NewChatView.swift#L1337) is read only in the `.groupLink(.ok)` branch with link data ([L1551](../../Shared/Views/NewChat/NewChatView.swift#L1551)): `.confirm` shows the "Open channel?" / "Open group?" alert ([`showPrepareGroupAlert`](../../Shared/Views/NewChat/NewChatView.swift#L1167)), as for a pasted link, and `.open` calls [`prepareAndOpenGroup`](../../Shared/Views/NewChat/NewChatView.swift#L1217) at once, the function the alert's confirm also calls: `apiPrepareGroup`, the channel's relay hostnames, `addChat`, then `openKnownChat`. So Open goes straight to the prepared chat with its Join button. Every other plan shows what a pasted link shows: the "Open group" alert for a known group, the repeat-join sheet or the connecting alert for a pending join, and the no-relays and update-required alerts. A `.joining` result keeps the default mode, so whatever its online plan turns out to be, it shows what a pasted link would.
The chat banner of a prepared channel (`nextConnectPrepared && useRelays`) shows its subscriber count, `subscriberCountStr(groupSummary.publicMemberCount)` from the link data, after the full name ([ChatView.swift L1030](../../Shared/Views/Chat/ChatView.swift#L1030)), in `.subheadline` and the secondary colour.
---
+11 -11
View File
@@ -198,7 +198,7 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh
}
```
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1319).
URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1342).
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL.
@@ -351,6 +351,8 @@ When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare ale
- `apiPrepareGroup` call passes `directLink: false`
- Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames`
The alert's confirm calls `prepareAndOpenGroup`, which prepares the group, stores the relays, adds the chat and opens it. `planAndConnect(…, prepareGroup: .open)`, which the online search's Open uses, calls `prepareAndOpenGroup` without the alert (see [Chat List, Opening a result](chat-list.md#opening-a-result)); the default `.confirm` keeps the alert.
### Own Link Confirmation (`showOwnGroupLinkConfirmConnectSheet`)
For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options.
@@ -367,20 +369,18 @@ For channels (`groupInfo.useRelays`): the title is "Open channel"; for groups, "
| Element | Where | Behavior |
|---------|-------|----------|
| Search key | [`ContactsListSearchBar`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L440) with `onSearch` ([L450](../../Shared/Views/NewChat/NewChatMenuButton.swift#L450)) | `.submitLabel(.search)` and `.onSubmit` run [`runOnlineSearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L250): `search(text, name: connectNameCandidate)` |
| "Connect to …" row | below the field ([L99](../../Shared/Views/NewChat/NewChatMenuButton.swift#L99)) | shown for a name candidate until name results arrive |
| "Search in Directory" row | below the field ([L109](../../Shared/Views/NewChat/NewChatMenuButton.swift#L109)) | shown for non-empty, non-link text; runs [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L244): `searchDirectory(text)` |
| Names and Directory sections | after `ContactsList`, [`onlineSearchSections(_:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L195) | `Section`s with headers styled as "Your Contacts" ([`onlineSearchHeader`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L234)); the Directory section renders when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set, and ends with the note row ([`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L313), when `rows.pageInChats`) and then the retry or "Show more" row. A directory result whose `localChatId` names a loaded chat (`chatModel.getChat`) is drawn as [`ContactListNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L12)`(chat:showDeletedChatIcon: true)`, whose [`groupNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L170) opens the group and closes the sheet (`dismissAllSheets` then `loadOpenChat`; avatar, name, favourite star, incognito mask; no swipe actions); other rows tap `planAndConnect(…, dismiss: true)` |
| Search key | [`ContactsListSearchBar`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L414) with `onSearch` ([L424](../../Shared/Views/NewChat/NewChatMenuButton.swift#L424)) | `.submitLabel(.search)` and `.onSubmit` run [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L225): `searchDirectory(text)`, the directory only; the field neither autocorrects nor capitalizes |
| "Connect to …" row | below the field ([L99](../../Shared/Views/NewChat/NewChatMenuButton.swift#L99)) | shown whenever there is a name candidate, results or not |
| "Search in Directory" row | below the field ([L109](../../Shared/Views/NewChat/NewChatMenuButton.swift#L109)) | shown for non-empty, non-link text while no results show or load (`!onlineSearch.showResults`); runs [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L225) |
| Results card | after `ContactsList`, [`onlineSearchSection(_:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L195) | One `Section` without a header, rendered when `!rows.isEmpty`: your chats (`rows.yourChats`, the loaded ones), then the results, the note row ([`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L259), when `rows.pageInChats`), then the retry or load row. Your chats are drawn as [`ContactListNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L12)`(chat:showDeletedChatIcon: true)`, whose [`groupNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L170) opens the group and closes the sheet (`dismissAllSheets` then `loadOpenChat`; avatar, name, favourite star, incognito mask; no swipe actions). Results are `DirectorySearchRow(imageSize: 30)`, New chat's 30-pt icons, with Open or "Already joining" and the same long-press menu as in the chat list; a tap or the menu's Open runs [`openResult(_:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L216): `openDirectoryResult(result, dismiss: true)`, so the opened chat closes the sheet |
Both triggers do nothing for empty text or a pasted link and go through `confirmDirectorySearch`. The list is `insetGrouped` and not flipped, so the sections use ordinary `Section` headers and default row backgrounds.
Both triggers do nothing for empty text or a pasted link and go through `confirmDirectorySearch`, which asks "Enable Directory search?" while the switch is off. The list is `insetGrouped` and not flipped, so the card is an ordinary `Section` with default row backgrounds, and its load row is the last row.
**De-duplication against the contacts list.** Before `ContactsList(…)` the list builder computes [`ContactsList.shownChats(…)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L397) with the same inputs `ContactsList` gets (`contactListChatPredicate`, the search state, `DEFAULT_SHOW_UNREAD_AND_FAVORITES`) and passes the ids of the shown contacts to `rows(shownChatIds:)` ([L173](../../Shared/Views/NewChat/NewChatMenuButton.swift#L173)). `shownChats` returns the contacts matching the predicate and the filtered, sorted list the section shows; `ContactsList.body` uses the same function.
**De-duplication against the contacts list.** Before `ContactsList(…)` the list builder computes [`ContactsList.shownChats(…)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L371) with the same inputs `ContactsList` gets (`contactListChatPredicate`, the search state, `DEFAULT_SHOW_UNREAD_AND_FAVORITES`) and passes the ids of the shown contacts to `rows(shownChatIds:)` ([L173](../../Shared/Views/NewChat/NewChatMenuButton.swift#L173)). `shownChats` returns the contacts matching the predicate and the filtered, sorted list the section shows; `ContactsList.body` uses the same function.
**Empty state.** [`ContactsList`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L308) has `showsEmptyState` (default `true`, [L319](../../Shared/Views/NewChat/NewChatMenuButton.swift#L319)); the sheet passes `!onlineSearch.searching && rows.isEmpty`, so "No filtered contacts" / "No contacts" are omitted while the online search runs or shows rows.
**Empty state.** [`ContactsList`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L282) has `showsEmptyState` (default `true`, [L293](../../Shared/Views/NewChat/NewChatMenuButton.swift#L293)); the sheet passes `!onlineSearch.searching && rows.isEmpty`, so "No filtered contacts" / "No contacts" are omitted while the online search runs or shows rows.
**Archived contacts.** [`DeletedChats`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L568) passes neither `onSearch` nor `showsEmptyState`: its search field keeps the `.return` label with a no-op submit, and its empty states are unchanged.
[GAP] New chat does not resolve a typed name locally while typing, so a known contact whose name does not contain the typed text is not in "Your Contacts" and appears as a Names row; tapping it opens the existing chat.
**Archived contacts.** [`DeletedChats`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L544) passes neither `onSearch` nor `showsEmptyState`: its search field keeps the `.return` label with a no-op submit, and its empty states are unchanged.
---
+8 -8
View File
@@ -41,7 +41,7 @@
| PC29 | User Address |
| PC30 | Member Support Chat |
| PC31 | Channels (Relays) |
| PC32 | Online Search (Directory and Names) |
| PC32 | Online Search (SimpleX Directory) |
---
@@ -52,10 +52,10 @@
| Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access |
| Shared/SimpleXApp.swift | PC1 through PC32 | High | App entry point — initialization affects everything |
| Shared/AppDelegate.swift | PC18 | Medium | Push notification registration |
| Shared/Views/ChatList/ChatListView.swift | PC1, PC28, PC32 | High | Main screen rendering and filtering; online search triggers and result parts |
| Shared/Views/ChatList/ChatListView.swift | PC1, PC28, PC32 | High | Main screen rendering and filtering; online search triggers and result rows |
| Shared/Views/ChatList/GetStakeBanner.swift | PC1 | Low | Crowdfunding banner and shared banner card chrome |
| Shared/Views/ChatList/DirectorySearchView.swift | PC1, PC32 | Medium | Online search model (generation, spinner, de-duplication), result rows, consent alert |
| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering |
| Shared/Views/ChatList/DirectorySearchView.swift | PC1, PC32 | Medium | Online search model (generation, spinner, result states, de-duplication), result rows, load row, long-press menu, Open, consent alert |
| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31, PC32 | High | Core conversation UI — most messaging features, channel message rendering; the prepared channel's banner count that Open leads to |
| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup |
| Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components |
| Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification |
@@ -65,13 +65,13 @@
| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow |
| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing |
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members |
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels |
| Shared/Views/NewChat/NewChatView.swift | PC12, PC31, PC32 | High | New connection creation — onramp for all contacts and channels; `planAndConnect`'s `prepareGroup` mode, which the online search's Open uses |
| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility |
| Shared/Views/NewChat/NewChatMenuButton.swift | PC12, PC14, PC31, PC32 | Medium | New chat sheet: actions, contacts list, online search sections; contacts list shared with archived contacts |
| Shared/Views/NewChat/NewChatMenuButton.swift | PC12, PC14, PC31, PC32 | Medium | New chat sheet: actions, contacts list, online search results card; contacts list shared with archived contacts |
| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering |
| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle |
| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management |
| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29, PC32 | Medium | Settings navigation hub; app defaults and hint defaults (incl. the online search alert) |
| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29, PC32 | Medium | Settings navigation hub; app defaults (incl. the Directory search switch) and hint defaults |
| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI |
| Shared/Views/UserSettings/NetworkAndServers/ | PC25, PC31 | High | Server configuration — affects connectivity and relay validation |
| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management |
@@ -82,7 +82,7 @@
| Shared/Model/ChatModel.swift | PC1 through PC32 | High | Central state — all features depend on it |
| Shared/Model/SimpleXAPI.swift | PC1 through PC32 | High | FFI bridge — all commands flow through here |
| Shared/Model/AppAPITypes.swift | PC1 through PC32 | High | Command/response types — all API communication |
| Shared/Model/DirectorySearch.swift | PC32 | High | Directory response parsing and link-type validation of untrusted entries; online result types |
| Shared/Model/DirectorySearch.swift | PC32 | High | Directory response parsing (entries, lenient `moreCount`) and link-type validation of untrusted entries; result states and rows |
| Shared/Model/NtfManager.swift | PC18 | High | Notification delivery |
| Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling |
| Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine |
+1 -1
View File
@@ -468,7 +468,7 @@ App-level UI settings stored in `UserDefaults.standard`:
| `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 |
| `DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN` | `Bool` | The user chose "Search and don't show again" in the online search alert; in `hintDefaults` (see [Chat List, Online Search](client/chat-list.md#11-online-search)) |
| `DEFAULT_PRIVACY_DIRECTORY_SEARCH` | `Bool` | "Directory search" in Privacy settings, default false; while it is off, every online search asks "Enable Directory search?", and Enable turns it on; not in `hintDefaults` (see [Chat List, Online Search](client/chat-list.md#11-online-search)) |
### GroupDefaults
+1 -1
View File
@@ -50,7 +50,7 @@ This document provides a structured mapping between product-level concepts, thei
| PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) |
| PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
| PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) |
| PC32 | Online Search (Directory and Names) | [views/chat-list.md](views/chat-list.md), [views/new-chat.md](views/new-chat.md), [rules.md](rules.md) (RULE-19 to RULE-23) | [spec/client/chat-list.md](../spec/client/chat-list.md) (section 11), [spec/client/navigation.md](../spec/client/navigation.md) (section 10), [spec/api.md](../spec/api.md) (sections 2.12, 7) | `common/.../views/chatlist/DirectorySearchView.kt` (`OnlineSearchModel`, `NameSearchRow`, `DirectorySearchRow`, `confirmDirectorySearch`), `common/.../model/DirectorySearch.kt` (`DirectorySearchEntry`, `simplexLinkType`, `planLocalChatId`, `NameSearchResult`, `OnlineSearchRows`), `common/.../views/chatlist/ChatListView.kt` (`ChatListSearchBar` triggers, `TagsOrConnectByName`, `nameRows`, `directoryRows`, onboarding branch), `common/.../views/newchat/NewChatSheet.kt` (`NewChatSheetLayout` online search), `common/.../views/helpers/SearchTextField.kt` (`onSearch`), `common/.../model/SimpleXAPI.kt` (`apiSearchDirectory`, `apiConnectPlan` `onFailure`, `directorySearchAlertShown`), `common/.../model/ChatModel.kt` (`ConnectProgressOwner`) | `Controller.hs` (`APISendServiceRequest`, `APIConnectPlan`); directory service `../../apps/simplex-directory-service/src/Directory/Rpc.hs` |
| PC32 | Online Search (SimpleX Directory) | [views/chat-list.md](views/chat-list.md), [views/new-chat.md](views/new-chat.md), [rules.md](rules.md) (RULE-19 to RULE-23) | [spec/client/chat-list.md](../spec/client/chat-list.md) (section 11), [spec/client/navigation.md](../spec/client/navigation.md) (section 10), [spec/api.md](../spec/api.md) (sections 2.12, 7) | `common/.../views/chatlist/DirectorySearchView.kt` (`OnlineSearchModel`, `DirectorySearchRow`, `DirectoryLoadMoreRow`, `DirectoryResultNavLink`, `openDirectoryResult`, `confirmDirectorySearch`), `common/.../model/DirectorySearch.kt` (`DirectorySearchEntry`, `simplexLinkType`, `planLocalChatId`, `DirectoryResultState`, `OnlineSearchRows`), `common/.../views/chatlist/ChatListView.kt` (`ChatListSearchBar` triggers, `TagsOrConnectByName`, `directoryRows`, onboarding branch), `common/.../views/newchat/NewChatSheet.kt` (`NewChatSheetLayout` online search), `common/.../views/newchat/ConnectPlan.kt` (`PrepareGroupMode`, `prepareAndOpenGroup`), `common/.../views/usersettings/PrivacySettings.kt` ("Directory search"), `common/.../views/helpers/SearchTextField.kt` (`onSearch`), `common/.../model/SimpleXAPI.kt` (`apiSearchDirectory`, `apiConnectPlan` `onFailure`, `privacyDirectorySearch`), `common/.../model/ChatModel.kt` (`ConnectProgressOwner`) | `Controller.hs` (`APISendServiceRequest`, `APIConnectPlan`); directory service `../../apps/simplex-directory-service/src/Directory/Rpc.hs` |
**Legend for abbreviated paths:**
- `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/`
+19 -42
View File
@@ -15,9 +15,8 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des
7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented)
8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration)
9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search)
10. [Online Search: Groups Joined Through Another Link Are Not Recognised](#gap-10-online-search-groups-joined-through-another-link-are-not-recognised)
11. [Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address](#gap-11-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address)
12. [Online Search: New Chat Does Not Narrow Contacts by Name While Typing](#gap-12-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing)
10. [Online Search: Groups Joined Without a Prepared Group Still Appear as Results With Open](#gap-10-online-search-groups-joined-without-a-prepared-group-still-appear-as-results-with-open)
11. [Online Search: The Count of Results Left Can Include Listings the Directory Never Sends](#gap-11-online-search-the-count-of-results-left-can-include-listings-the-directory-never-sends)
---
@@ -355,7 +354,7 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag
---
## GAP-10: Online Search: Groups Joined Through Another Link Are Not Recognised
## GAP-10: Online Search: Groups Joined Without a Prepared Group Still Appear as Results With Open
**Severity:** Low
**Category:** UI / UX
@@ -363,53 +362,30 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag
### Description
A directory entry is matched to a local chat by a store-only lookup of its link (`apiConnectPlan` with `PlanResolveMode.PRMNever`, then `planLocalChatId`). An entry the app finds this way opens as a chat: the screen's chat row is drawn in place of the entry. A regular group's entry carries the directory's own group link (short link only), so a group the user joined through a different link, through the directory's full link, or by invitation is not found and still shows as a directory entry with "Join group". Tapping it goes through `planAndConnect`, which handles the known group. Accepted.
A directory entry is matched to a local chat by a store-only plan of its link (`apiConnectPlan` with `PlanResolveMode.PRMNever`, then `directoryResultState`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row:
Related: the note "All results on this page are already in your chats" is not shown when a page's only results are hidden because a name search resolved to the same link (that row is on screen, but not one of the user's chats); the Directory part then shows only "Show more" when there are more pages, and is not shown otherwise.
- a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert;
- a member added by invitation, or one who joined through a link the owner has since replaced: the online plan is `Ok`, so Open prepares and opens a second copy of the group without asking (before, the "Open group?" confirm led to the same copy).
Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable only for entries the directory sends with a full link, since a short link's connecting plan needs a network fetch. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins the store-only contract, including the `/c` case.
### Affected Locations
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.request`, `OnlineSearchModel.rows`
- `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` -- `planLocalChatId`
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.resultState`, `OnlineSearchModel.rows`, `openDirectoryResult`
- `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` -- `planLocalChatId`, `directoryResultState`
- See [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### Impact
A group the user is already in can appear as a directory result with "Join group". Joining is not repeated: the connection plan opens the known group.
A group the user is already in can appear as a result with Open. For a group joined with `/c`, Open shows the known group's alert and joining is not repeated; for an invited member, Open adds a second, prepared copy of the group to the chat list.
### Recommendation
Have the directory send a stable group identifier the core can match locally, or let the core match a directory short link against groups joined through the corresponding full link.
Let the store-only plan recognise joins made without a prepared group (a lookup by the connection's `via_short_link_contact`, mapped as the online plan maps a join request); it would cover `/c` joins, not invited members.
---
## GAP-11: Online Search: First-Search Alert Does Not Mention the Server Hosting a Resolved Address
**Severity:** Low
**Category:** Privacy / UX
**Platforms:** Android, Desktop
### Description
Resolving a SimpleX name with `PlanResolveMode.PRMUnknown` also loads the resolved address's link data from the SMP server that hosts it. The search text itself is not sent there, and the first-search alert ("Search in Directory?") names only SimpleX Directory and the name-resolving server.
### Affected Locations
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `showDirectorySearchAlert`, `OnlineSearchModel.resolveNames`
- `common/src/commonMain/moko-resources/base/strings.xml` -- `search_in_directory_alert_desc`
- See [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### Impact
The alert under-describes which servers learn of a name search: the server hosting the resolved address sees a link data request.
### Recommendation
Decide whether the alert copy should also say that the address a name points to is loaded.
---
## GAP-12: Online Search: New Chat Does Not Narrow Contacts by Name While Typing
## GAP-11: Online Search: The Count of Results Left Can Include Listings the Directory Never Sends
**Severity:** Low
**Category:** UI / UX
@@ -417,17 +393,18 @@ Decide whether the alert copy should also say that the address a name points to
### Description
The chat list resolves a typed SimpleX name locally on each keystroke (debounced, `PlanResolveMode.PRMNever`) to narrow the list to the matching chat; the New chat sheet does not (its `ContactsSearchBar` resolves a name only when the "Connect to …" row is tapped). A known contact whose display name does not contain the typed name is therefore not in "Your contacts" and appears as a Names result instead; tapping it opens the existing chat.
The directory's `moreCount` counts the matching listings after the page, including listings without a public link, which it never sends. "Load more · N left" can therefore promise more results than loading brings, and a last page can come back empty.
### Affected Locations
- `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt` -- `ContactsSearchBar` typing collector, `NewChatSheetLayout` online search
- See [spec/client/navigation.md](../spec/client/navigation.md#10-new-chat-online-search)
- `apps/simplex-directory-service/src/Directory/Rpc.hs` -- `searchResultsPage` (`moreCount`)
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `DirectoryLoadMoreRow`
- See [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### Impact
A contact the user already has can show up as an online Names result rather than in the contacts list.
The load row's count can be higher than the number of results still to come.
### Recommendation
Reuse the chat list's local name resolution in `ContactsSearchBar` if this proves confusing.
Count only listings with a public link in the directory's count query.
+14 -12
View File
@@ -259,9 +259,9 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-19: Online Results Are Never Saved
**Invariant:** Name and directory results MUST NOT be stored, and MUST live only as long as the search text and the active profile: any text change or profile switch clears them, and a reply that arrives after that (or after the search was cancelled) MUST NOT be shown.
**Invariant:** Directory results MUST NOT be stored, and MUST live only as long as the search text and the active profile: any text change or profile switch clears them, and a reply that arrives after that (or after the search was cancelled) MUST NOT be shown.
**Enforcement:** `OnlineSearchModel.reset()` bumps `generation`, and every publish in `resolveNames` and `request` checks it; `cancelRequest()` bumps it too. `ChatListSearchBar` and `NewChatSheetLayout` call `reset()` from `KeyChangeEffect`s on the search text and on `chatModel.currentUser.value?.userId`. The model is kept in Compose `remember` only, so it is also dropped when its screen leaves composition (e.g. an Android rotation).
**Enforcement:** `OnlineSearchModel.reset()` bumps `generation`, and every publish in `request` and `refreshStates` checks it; `cancelRequest()` bumps it too. `ChatListSearchBar` and `NewChatSheetLayout` call `reset()` from `KeyChangeEffect`s on the search text and on `chatModel.currentUser.value?.userId`. The model is kept in Compose `remember` only, so it is also dropped when its screen leaves composition (e.g. an Android rotation).
**Location:**
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel`
@@ -286,13 +286,14 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-21: Online Lookups Never Show Alerts
**Invariant:** Resolving a SimpleX name online and looking up directory entries locally MUST NOT show any alert, including the network retry alert; a failure gives no row.
**Invariant:** Looking up directory entries locally MUST NOT show any alert, including the network retry alert; a failed lookup shows the entry with Open, whose online plan shows the real error.
**Enforcement:** Every lookup of the online search calls `apiConnectPlan(…, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)`: `Log` only logs the failure, and a false `inProgress` also keeps `sendCmdWithRetry` from showing its retry alert.
**Enforcement:** Every lookup of the online search (`OnlineSearchModel.resultState`) calls `apiConnectPlan(…, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)`: `Log` only logs the failure, and a false `inProgress` also keeps `sendCmdWithRetry` from showing its retry alert. `directoryResultState` maps no plan to `Open`.
**Location:**
- `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `ChatController.apiConnectPlan()`, `ConnectPlanFailure`
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.resolveNames()`, `OnlineSearchModel.request()`
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.resultState()`, `OnlineSearchModel.request()`, `OnlineSearchModel.refreshStates()`
- `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` -- `directoryResultState()`
**Spec:** [spec/api.md](../spec/api.md#7-directory-search-and-connection-plans)
@@ -300,13 +301,13 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-22: Nothing Is Shown Twice
**Invariant:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. A directory result whose link refers to a chat the screen doesn't list MUST be drawn as that screen's chat row, which opens the chat, never as "Join …". When every result of the last page loaded is hidden as a chat the user has, the Directory part MUST say so ("All results on this page are already in your chats"); a result hidden only because a name was resolved through its link does not count. Limit: a regular group joined through another link, the directory's full link or by invitation is not found by the lookup and still appears as a directory result with "Join group" (GAP-10).
**Invariant:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link or joined with the full link the directory sends. A group joined without a prepared group (`/c`, an older app, a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking (GAP-10).
**Enforcement:** `OnlineSearchModel.rows(shownChatIds)` filters at render time with the ids of the chats each screen shows and computes `pageInChats`; `planLocalChatId` derives a result's local chat id from the connection plan, and `NameSearchResult.resolvedLink` is the link the core resolved. `directoryRows` (chat list) and `onlineSearchSections` (New chat) draw a result whose chat is loaded with `ChatListNavLinkView` / `ContactListNavLinkView`.
**Enforcement:** `OnlineSearchModel.request` drops entries already shown; `OnlineSearchModel.rows(shownChatIds)` splits the results at render time with the ids of the chats each screen shows into `yourChats` (each chat id once) and `results`, and computes `pageInChats`; `directoryResultState` derives a result's state from the store-only plan through `planLocalChatId`. `directoryRows` (chat list) and `onlineSearchSection` (New chat) draw `yourChats` with `ChatListNavLinkView` / `ContactListNavLinkView`.
**Location:**
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.rows()`
- `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` -- `planLocalChatId()`, `NameSearchResult`
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `OnlineSearchModel.rows()`, `OnlineSearchModel.request()`
- `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` -- `planLocalChatId()`, `directoryResultState()`
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
@@ -314,12 +315,13 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-23: Consent Before the First Online Search
**Invariant:** The search text MUST NOT be sent online until the user has confirmed the first-search alert, which names both recipients (SimpleX Directory and, for a SimpleX name, the server that resolves names), unless the user chose not to be asked again.
**Invariant:** The search text MUST NOT be sent online while the "Directory search" switch in Privacy settings is off, unless the user confirms the "Enable Directory search?" alert, which says the text goes to SimpleX Directory and turns the switch on. Cancel MUST send nothing. The search never resolves SimpleX names, so the directory is its only recipient.
**Enforcement:** Both triggers (the "Search in Directory" row and the Search key / Enter) on the chat list and in New chat go through `confirmDirectorySearch()`, which runs the search at once only when `AppPreferences.directorySearchAlertShown` is set and otherwise shows the alert. The preference is in `AppPreferences.hintPreferences`, so "Reset all hints" asks again.
**Enforcement:** Both triggers (the "Search in Directory" row and the Search key / Enter) on the chat list and in New chat go through `confirmDirectorySearch()`, which runs the search at once only when `AppPreferences.privacyDirectorySearch` is on and otherwise shows the alert; Enable sets the preference, then searches. The preference is a setting, not a hint, so "Reset all hints" leaves it as it is.
**Location:**
- `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt` -- `confirmDirectorySearch()`, `showDirectorySearchAlert()`
- `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `AppPreferences.directorySearchAlertShown`
- `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `AppPreferences.privacyDirectorySearch`
- `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt` -- the switch
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
+19 -13
View File
@@ -51,28 +51,33 @@ Behavior:
- Filters chat list in real-time by contact/group name via `filteredChats()`
- Detects pasted SimpleX links (`strHasSingleSimplexLink`) and triggers `planAndConnect()` connection dialogue
- In one-hand UI mode, search bar appears below tag filters with IME spacer; in standard mode, above tag filters
- For non-empty text that is not a link, offers "Search in Directory"; the keyboard Search key (Enter on desktop) searches online (see below)
- For non-empty text that is not a link, offers "Search in Directory"; the keyboard Search key (Enter on desktop) does the same search (see below)
- The field doesn't autocorrect or capitalize, so the Search key searches exactly what was typed
### Online Search (Names and Directory)
### Online Search (SimpleX Directory)
Searching beyond the user's own chats happens only when the user asks, and the results are never saved: they disappear when the search text changes or the user switches profile. On Android, rotating the screen also drops them while the search text stays.
| Element | Behavior |
|---|---|
| "Search in Directory" row | Shown for non-empty text that is not a SimpleX link. On Android with the one-hand UI it takes the place of the tags above the search field; with the top bar the tags stay and the row follows them below the field. On desktop the tags always stay and the row is shown next to them, on the search field's side. Searches SimpleX Directory for public groups and channels |
| Keyboard Search key | On Android the keyboard's Search action, on desktop Enter. Searches the directory and, when the text is a SimpleX name (e.g. `alice` or `#news.testing`), resolves the name online. For a bare name both the contact (`@`) and the channel (`#`) form are tried |
| "Connect to …" row | Shown for a SimpleX name, as before (on Android with the top bar it takes the tags' place); hidden once the online search found anything for that name |
| First-search alert | Before the first online search: "Search in Directory?" — the text goes to SimpleX Directory, and a SimpleX name also to the user's server that resolves names; no contact is created and the profile is not sent. Search / Search and don't show again / Cancel ("Reset all hints" brings it back) |
| Names part | Below the local chats, headed "Names": avatar, profile name, the SimpleX name in parentheses, description. Tapping connects (or opens the chat if it is already known) |
| Directory part | Below the Names part, headed "Directory": avatar, `#` name, the directory's SimpleX name, description, member or subscriber count, and "Join channel" / "Join group". Tapping connects; "Show more" loads the next page; a failed request shows "Search failed, tap to retry". An entry for a group or channel the user already has, that the list above doesn't show (its name doesn't match the typed text), is shown as the regular chat row and opens the chat, never "Join …". When every result of the last page loaded is a chat already in the list or found by name, the part shows "All results on this page are already in your chats" (secondary colour), followed by "Show more" when there are more pages; nothing loads more pages automatically |
| "Search in Directory" row | Shown for non-empty text that is not a SimpleX link, until results for that text show or load. On Android with the one-hand UI it takes the place of the tags above the search field (the tags don't come back while the text is there); with the top bar the tags stay and the row follows them below the field. On desktop the tags always stay and the row is shown next to them, on the search field's side. Searches SimpleX Directory for public groups and channels |
| Keyboard Search key | On Android the keyboard's Search action, on desktop Enter. The same directory search; it never looks up SimpleX names |
| "Connect to …" row | Shown for a SimpleX name (e.g. `alice` or `#news.testing`; on Android with the top bar it takes the tags' place), and stays next to the results; it is the only way to look up a name |
| "Directory search" switch | In Settings › Privacy › Chats, after "Send link previews"; off by default |
| "Enable Directory search?" alert | Every search asks while the switch is off: search text is sent to SimpleX Directory, each search separately and without the user's profile, so it can't build a search history. Enable turns the switch on and searches; Cancel sends nothing. Once it is on, searches don't ask |
| Results | One list without headers below the local chats. First your chats among the results that the list above doesn't show (for example because the name doesn't match the typed text), as their regular chat rows; then the other results, laid out as chat rows with the same avatar size: avatar, `#` name, the directory's SimpleX name, description, then the member or subscriber count and "Open" on the right. A join still in progress without a chat shows "Already joining" in place of the count and no "Open" |
| Open | Tapping a result loads the group's or channel's profile and opens the chat straight away, with its Join button and no "Open channel?" question; a channel's banner shows its subscriber count. The user joins from there. A result that is already joining shows the same alert as pasting its link would |
| Result menu | A long press (or a right click on desktop) on a result shows a menu with Open |
| Load row | The last row of the results: "Load more · N left" when the directory says how many matches are left, "Load more" otherwise; greyed while a page loads. A failed request turns it into "Search failed, tap to retry". Nothing loads more pages automatically |
| Page note | When every result of the last page loaded is a chat already in the list, "All results on this page are already in your chats" (secondary colour), followed by the load row when there are more pages |
A result is not shown twice: a name or directory entry for a chat already shown in the list is hidden, and a directory entry is hidden when the name search found the same channel (matched by its link). A group the user joined through a link other than the directory's, or by invitation, is not recognised: it still appears in the Directory part with "Join group". Directory entries that are not group or channel links are ignored. Looking up a name never shows an error; a name that is not found simply gives no row, and "Connect to …" stays.
A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without preparing it first (with an older app, by `/c`, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat (see GAP-10). Directory entries that are not group or channel links are ignored.
With the one-hand UI the whole list is reversed: going up from the search field at the bottom come the local chats, then Names, then Directory, each part's header above its rows. This order is intended, so the user's own chats stay nearest the field.
With the one-hand UI the whole list is reversed: going up from the search field at the bottom come the local chats, then your chats from the results, then the other results, with the load row at the top, farthest from the field. This order is intended, so the user's own chats stay nearest the field.
While an online search runs or has results, the list shows no banner, no one-hand UI card and no connect cards. Typing alone, which only filters the chats, keeps them; they come back when the results go (the text changes or is cleared) or when a search is cancelled before showing anything.
With no conversations yet (onboarding), the search bar is shown above the onboarding pages (below them with the one-hand UI) on Android, and the online results replace the pages while a search runs or has results. On desktop the onboarding pages are in the centre pane, so the chat list pane shows the search bar and the results only. Once a result is joined and the onboarding state ends, the search is cleared.
With no conversations yet (onboarding), the search bar is shown above the onboarding pages (below them with the one-hand UI) on Android, and the online results replace the pages while a search runs or has results. On desktop the onboarding pages are in the centre pane, so the chat list pane shows the search bar and the results only. Once a result is opened and the onboarding state ends, the search is cleared.
### Chat Filter Tags (`TagsView`)
@@ -148,8 +153,9 @@ Each chat type provides specific dropdown menu items:
| No filtered chats | "No chats in list [tag name]" or "No unread chats" with clickable filter reset |
| No search results | "No chats found" centered text, unless an online search is running or shows results |
| Online search running | Progress indicator in the toolbar (after about a second); tapping a result or clearing the search cancels it |
| Directory search failed | "Search failed, tap to retry" row at the end of the Directory part |
| Online search found nothing | No Names or Directory part; "No chats found" when no local chat matches either (in onboarding, a "No chats found" row) |
| Loading more results | The "Load more" row is greyed and can't be tapped until the page arrives |
| Directory search failed | "Search failed, tap to retry" row at the end of the results, in place of the load row |
| Online search found nothing | No result rows; "No chats found" when no local chat matches either (in onboarding, a "No chats found" row) |
## Source Files
+7 -7
View File
@@ -85,19 +85,19 @@ Group creation flow:
### Search (New Chat Sheet)
The search field of the New chat sheet filters "Your contacts" and offers the same online search as the chat list (see [Chat List, Online Search](chat-list.md#online-search-names-and-directory)):
The search field of the New chat sheet filters "Your contacts" and offers the same online search as the chat list (see [Chat List, Online Search](chat-list.md#online-search-simplex-directory)):
| Element | Behavior |
|---|---|
| "Connect to …" row | Below the field for a SimpleX name; hidden once the online search found anything for that name |
| "Search in Directory" row | Below the field (after "Connect to …") for non-empty text that is not a SimpleX link; searches SimpleX Directory |
| Keyboard Search key | On Android the keyboard's Search action, on desktop Enter. Searches the directory and, for a SimpleX name, resolves the name online |
| Names and Directory sections | After the contacts, each with a section header in the style of "Your contacts". Tapping a result connects and closes the sheet, as other New chat actions do. A result for a contact already listed is not repeated. A directory entry for a group or channel the user already has is shown as a contact-style chat row (avatar, name, favourite star, incognito mask) that opens the chat and closes the sheet, never "Join …". When every result of the last page loaded is a chat the user has, the Directory section shows "All results on this page are already in your chats", followed by "Show more" when there are more pages. With the one-hand UI the list is reversed, and each header is still shown above its rows |
| "Connect to …" row | Below the field for a SimpleX name; stays next to the results |
| "Search in Directory" row | Below the field (after "Connect to …") for non-empty text that is not a SimpleX link, until results for that text show or load; searches SimpleX Directory |
| Keyboard Search key | On Android the keyboard's Search action, on desktop Enter. The same directory search; it never looks up names |
| Results card | One group of rows without a header after the contacts. First your chats among the results that the contacts list doesn't show, as contact-style chat rows (avatar, name, favourite star, incognito mask) that open the chat and close the sheet; then the other results with the contact rows' avatar size, each with "Open" (or "Already joining") and the same long-press / right-click menu as on the chat list. Open opens the prepared chat and closes the sheet, as other New chat actions do. When every result of the last page loaded is a chat the user has, the card shows "All results on this page are already in your chats". The card ends with the load row ("Load more · N left" or "Load more") or the retry row, which is the row farthest from the field in both layouts: at the bottom with the top bar, at the top of the reversed list with the one-hand UI |
| Contacts empty state | "No filtered contacts" is not shown while the online search runs or shows results |
| Online search running | Progress indicator in the search bar; closing the sheet cancels the search |
| Directory search failed | "Search failed, tap to retry" row at the end of the Directory section |
| Directory search failed | "Search failed, tap to retry" row at the end of the results card |
The first online search asks for consent with the same alert as on the chat list. Results disappear when the text changes or the user switches profile (which also closes the sheet), and when a sheet opened from New chat covers it. Archived contacts keep their own search field without online results; their Search key does nothing.
While the "Directory search" switch is off, every online search asks with the same "Enable Directory search?" alert as on the chat list. Results disappear when the text changes or the user switches profile (which also closes the sheet), and when a sheet opened from New chat covers it. Archived contacts keep their own search field without online results; their Search key does nothing.
## Source Files
+1 -1
View File
@@ -89,7 +89,7 @@ Common Module (commonMain)
| Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore |
| Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis |
| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions |
| Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags, online search (SimpleX Directory and names) |
| Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags, online search (SimpleX Directory) |
| Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions |
| Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows, New chat online search |
| Calls | [spec/services/calls.md](services/calls.md) | WebRTC call lifecycle, signaling, platform-specific call views |
+6 -6
View File
@@ -182,7 +182,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k
| `apiAddContact` | `rh: Long?, incognito: Boolean` | Create a one-time invitation link for a new contact | [L1613](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1613) |
| `apiSetConnectionIncognito` | `rh: Long?, connId: Long, incognito: Boolean` | Toggle incognito on a pending connection | [L1624](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1624) |
| `apiChangeConnectionUser` | `rh: Long?, connId: Long, userId: Long` | Change the user profile on a pending connection | [L1633](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1633) |
| `apiConnectPlan` | `rh: Long?, connLink: String, resolveMode: PlanResolveMode = PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>, onFailure: ConnectPlanFailure = Alert` | Analyze a connection link (or resolve a SimpleX name) before connecting; returns `ConnectionPlanResult?`. Failure handling: see [section 7](#7-directory-search-and-connection-plans) | [L1654](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1654-L1668) |
| `apiConnectPlan` | `rh: Long?, connLink: String, resolveMode: PlanResolveMode = PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>, onFailure: ConnectPlanFailure = Alert` | Analyze a connection link (or resolve a SimpleX name) before connecting; returns `ConnectionPlanResult?`. Failure handling: see [section 7](#7-directory-search-and-connection-plans) | [L1687](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1687-L1700) |
| `apiConnect` | `rh: Long?, incognito: Boolean, connLink: CreatedConnLink` | Connect via an invitation or address link | [L1669](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1669) |
| `apiPrepareContact` | `rh, connLink, contactShortLinkData` | Prepare a contact chat from a short link (before connecting) | [L1808](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1808) |
| `apiPrepareGroup` | `rh, connLink, groupShortLinkData` | Prepare a group chat from a short link (before connecting) | [L1817](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1817) |
@@ -294,7 +294,7 @@ class ArchiveConfig(
| Command | Parameters | Description | Line |
|---------|-----------|-------------|------|
| `apiSearchDirectory` | `rh: Long?, text: String, cursor: JsonObject?` | Sends [`CC.APISendServiceRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4069) (`userId, target, timeoutSec: Double?, request: JsonObject`; `/_service_request`) to SimpleX Directory and waits up to the timeout for its response, `CRServiceResponse`; see [section 7](#7-directory-search-and-connection-plans) | [L1645](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652) |
| `apiSearchDirectory` | `rh: Long?, text: String, cursor: JsonObject?` | Sends [`CC.APISendServiceRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4069) (`userId, target, timeoutSec: Double?, request: JsonObject`; `/_service_request`) to SimpleX Directory and waits up to the timeout for its response, `CRServiceResponse`; see [section 7](#7-directory-search-and-connection-plans) | [L1678](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1678-L1685) |
---
@@ -441,7 +441,7 @@ sealed class ArchiveError {
| Cryptor.kt | Platform-expect encryption interface | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` |
| Cryptor.android.kt | Android: AndroidKeyStore AES-GCM encryption | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` |
| Cryptor.desktop.kt | Desktop: placeholder (no-op) encryption | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` |
| DirectorySearch.kt | Directory request and response, online search result types | `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` |
| DirectorySearch.kt | Directory request and response, online search result states | `common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt` |
---
@@ -451,9 +451,9 @@ sealed class ArchiveError {
| Function | Description | Line |
|----------|-------------|------|
| `apiSearchDirectory(rh, text, cursor)` | Sends `CC.APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with [`DIRECTORY_SEARCH_TIMEOUT_SEC`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L71-L75); returns [`parseDirectorySearchResponse`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L79-L88)`(responseData)`, or null on any error (logged with `responseType` and `details`). Blocks for up to the timeout; uses plain `sendCmd`, so a timeout gives a retry row rather than the alert `sendCmdWithRetry` would show | [L1645](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652) |
| `apiConnectPlan(rh, connLink, resolveMode, linkOwnerSig, inProgress, onFailure)` | Plans a connection; returns `ConnectionPlanResult?`. `NotResolvedLocally` (a `PRMNever` lookup that matched nothing) returns null silently; any other failure is handled by `onFailure` | [L1654](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1654-L1668) |
| `ConnectPlanFailure` | `Alert` (default, every existing caller): `apiConnectResponseAlert`, only while `inProgress.value` is true, as before; `Log`: a debug log only, used by the online search so name resolution and per-entry lookups never raise alerts | [L7522](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7522) |
| `apiSearchDirectory(rh, text, cursor)` | Sends `CC.APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L14) with [`DIRECTORY_SEARCH_TIMEOUT_SEC`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L17) and the request built by [`directorySearchRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L70-L74); returns [`parseDirectorySearchResponse`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L78-L89)`(responseData)` (the entries, the cursor and `moreCount`), or null on any error (logged with `responseType` and `details`). Blocks for up to the timeout; uses plain `sendCmd`, so a timeout gives a retry row rather than the alert `sendCmdWithRetry` would show | [L1678](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1678-L1685) |
| `apiConnectPlan(rh, connLink, resolveMode, linkOwnerSig, inProgress, onFailure)` | Plans a connection; returns `ConnectionPlanResult?`. `NotResolvedLocally` (a `PRMNever` lookup that matched nothing) returns null silently; any other failure is handled by `onFailure`. The online search reads null from a `PRMNever` plan of a directory result as "not one of your chats" | [L1687](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1687-L1700) |
| `ConnectPlanFailure` | `Alert` (default, every existing caller): `apiConnectResponseAlert`, only while `inProgress.value` is true, as before; `Log`: a debug log only, used by the online search so per-entry lookups never raise alerts | [L7609](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7609) |
A service response is carried as a kotlinx `JsonObject` (any JSON), so the API layer does not depend on a service's own types; `parseDirectorySearchResponse` reads its fields and decodes each entry with `json.decodeFromJsonElement`.
+60 -54
View File
@@ -153,7 +153,7 @@ The `filteredChats` function (line ~1689) applies filters in this order:
- Text input with search icon.
- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search.
- Unread filter toggle button (right side, when search is empty).
- Searching SimpleX Directory and resolving SimpleX names online happen only on request, below the local results: see [Online Search](#11-online-search).
- Searching SimpleX Directory online happens only on request, below the local results: see [Online Search](#11-online-search).
---
@@ -227,7 +227,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re
### TagsView
**Location:** [`ChatListView.kt#L1488`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1488)
**Location:** [`ChatListView.kt#L1424`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1424)
Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable).
@@ -319,8 +319,8 @@ Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow`
| `TagListView.kt` | Tag management and assignment view |
| `UserPicker.kt` | User switching side panel |
| `GetStakeBanner.kt` | Crowdfunding banner and the shared banner card chrome |
| `DirectorySearchView.kt` | Online search model, Names and Directory rows, first-search alert |
| `model/DirectorySearch.kt` | Directory response parsing and online result types |
| `DirectorySearchView.kt` | Online search model, result, load and retry rows, result menu, Open, "Enable Directory search?" alert |
| `model/DirectorySearch.kt` | Directory response parsing and result states |
---
@@ -374,27 +374,26 @@ Tapping the card runs `openGetStake()`, which opens `GetStakeView(showFirstImage
**Files:** `views/chatlist/DirectorySearchView.kt` (model and rows), `model/DirectorySearch.kt` (response and result types), `views/chatlist/ChatListView.kt` (triggers and rendering), `views/helpers/SearchTextField.kt` (Search key)
The search text can be sent online on request: to SimpleX Directory, to find public groups and channels, and, when the text is a SimpleX name, to the user's name-resolving server. Results are shown below the local chats in two parts, Names then Directory. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L924-L1028) calls `reset()` from a `KeyChangeEffect` on `searchText.value.text` and another on `chatModel.currentUser.value?.userId` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L969-L974)). The model is a plain `remember` in [`ChatListView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L246), so an Android rotation (which recreates the Activity) drops the results while the `rememberSaveable` search text stays. The New chat sheet uses the same model and rows (see [Navigation, New Chat Online Search](navigation.md#10-new-chat-online-search)).
The search text can be sent online on request to SimpleX Directory, to find public groups and channels. Results are shown below the local chats as one list without headers: your chats first, then the other results. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L866-L970) calls `reset()` from a `KeyChangeEffect` on `searchText.value.text` and another on `chatModel.currentUser.value?.userId` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L911-L916)). The model is a plain `remember` in [`ChatListView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L246), so an Android rotation (which recreates the Activity) drops the results while the `rememberSaveable` search text stays. The New chat sheet uses the same model and rows (see [Navigation, New Chat Online Search](navigation.md#10-new-chat-online-search)).
### Triggers
| Trigger | Handler | Runs |
|---------|---------|------|
| Search key: the keyboard's Search action on Android, Enter (key up) on desktop, through [`SearchTextField`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L33-L43)`(onSearch)` | [`runOnlineSearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1053-L1058) | `search(text, name = connectNameCandidate.value)`: the directory's first page, and name resolution when the text is a name candidate |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L233-L248)) | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1046-L1051) | `searchDirectory(text)`: the directory only |
| Search key: the keyboard's Search action on Android, Enter (key up) on desktop, through [`SearchTextField`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L33-L43)`(onSearch)` | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L989-L993) | `searchDirectory(text)` |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L199-L214)) | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L989-L993) | `searchDirectory(text)` |
`SearchTextField` handles the key per platform: on Android through `KeyboardActions(onSearch)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L116-L121)), on desktop through `onPreviewKeyEvent` for `Key.Enter` / `Key.NumPadEnter` on `KeyUp` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L95-L102)), so one key press runs the search once. Every caller keeps `ImeAction.Search`; a caller without `onSearch` gets no handler.
`SearchTextField` handles the key per platform: on Android through `KeyboardActions(onSearch)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L116-L121)), on desktop through `onPreviewKeyEvent` for `Key.Enter` / `Key.NumPadEnter` on `KeyUp` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L95-L102)), so one key press runs the search once. Every caller keeps `ImeAction.Search`; a caller without `onSearch` gets no handler. The field neither autocorrects nor capitalizes (`KeyboardOptions(capitalization = None, autoCorrect = false)`), so the Search key searches what was typed; being shared, this reaches every `SearchTextField`, unlike iOS, which changed only the chat list and New chat fields.
Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link ([`searchTrimmed`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1043-L1044), `searchShowingSimplexLink`), and both go through [`confirmDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L369-L375), which runs the search at once when `appPrefs.directorySearchAlertShown` is set and otherwise shows the three-button alert ([`showDirectorySearchAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L377-L405), `showAlertDialogButtonsColumn` with `parseHtml = false` so the paragraph break shows): Search / Search and don't show again / Cancel. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The preference is in `hintPreferences`, so "Reset all hints" brings the alert back. Each trigger launches the model method with `withApi`.
Both triggers run the same directory search, which never resolves SimpleX names: a name is looked up only by "Connect to …" (`ConnectByNameRow`). Both do nothing for empty (trimmed) text or a pasted SimpleX link ([`searchTrimmed`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L985-L986), `searchShowingSimplexLink`), and both go through [`confirmDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L350-L356), which runs the search at once when the "Directory search" switch is on (`appPrefs.privacyDirectorySearch`, key "PrivacyDirectorySearch", default false, in Privacy settings › Chats after "Send link previews") and otherwise shows the alert [`showDirectorySearchAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L358-L380) (`showAlertDialogButtonsColumn` with `parseHtml = false`): "Enable Directory search?", with the message "Search text is sent to SimpleX Directory, each search separately and without your profile, so it can't build your search history." and two buttons, Enable (turns the switch on, then searches) and Cancel (sends nothing), in that order, as this alert's column already placed Cancel last. So every search asks while the switch is off. The preference is not in `hintPreferences`: "Reset all hints" leaves the switch as it is. Each trigger launches the model method with `withApi`.
The rows next to the search field share one slot with the tags, laid out by [`TagsOrConnectByName`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1357-L1411) and [`ChatListSearchBarWithTags`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1278-L1303):
The rows next to the search field share one slot with the tags, laid out by [`TagsOrConnectByName`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1291-L1347) and [`ChatListSearchBarWithTags`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1212-L1237). "Search in Directory" hides while results for the current text show or load (`!onlineSearch.showResults`); "Connect to …" stays next to them:
| Search text | Android, one-hand UI (slot above the field) | Android, top bar (slot below the field) | Desktop (either mode) |
|-------------|-----------------------------------|------------------------------|------|
| Empty | Tags | Tags | Tags |
| Plain text | "Search in Directory" | Tags, then "Search in Directory" | Tags and "Search in Directory" |
| Name candidate, no name results yet | "Connect to …" and "Search in Directory" | "Connect to …" (in the tags' place), then "Search in Directory" | Tags, "Connect to …" and "Search in Directory" |
| Name candidate with name results | "Search in Directory" | Tags, then "Search in Directory" | Tags and "Search in Directory" |
| Plain text | "Search in Directory"; nothing while results show (the tags don't come back) | Tags, then "Search in Directory" until results show | Tags and "Search in Directory"; tags only while results show |
| Name candidate | "Connect to …", and "Search in Directory" until results show | "Connect to …" (in the tags' place), then "Search in Directory" until results show | Tags, "Connect to …", and "Search in Directory" until results show |
| Pasted SimpleX link | Tags | Tags | Tags |
On desktop the rows are passed to the caller's `desktopView`, which places them between the tags and the field: above the field with the bottom toolbar, below it with the top toolbar.
@@ -403,16 +402,15 @@ On desktop the rows are passed to the caller's `desktopView`, which places them
### OnlineSearchModel
**Location:** [`OnlineSearchModel`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L32-L230)
**Location:** [`OnlineSearchModel`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L34-L196)
A plain class holding Compose state, owned by `ChatListView` as `remember { OnlineSearchModel() }` and passed through `ChatListWithLoadingScreen` to `ChatList`, `ChatListSearchBarWithTags`, `ChatListSearchBar` and `TagsOrConnectByName`. Every method runs on the main thread: the triggers and rows launch them with `withApi` (`Dispatchers.Main`), and the spinner's `onCancel` hops back with `withApi { cancelRequest() }`; the API calls move the FFI work to `Dispatchers.IO` themselves. This keeps every read and write of `generation` on one thread.
| State | Description |
|-------|-------------|
| `names: List<NameSearchResult>` | Name results for the current text |
| `directory: List<DirectoryResult>` | Directory entries shown so far, each with the local chat its link refers to |
| `directory: List<DirectoryResult>` | Directory entries shown so far, each with its `DirectoryResultState` (see Directory requests) |
| `moreCount: Int?` | The count of matches left that the last successful page carried; null when the directory sent none or an invalid one |
| `loading` | A directory request is in flight |
| `resolvingNames` | Name resolution is in flight |
| `failed` | The last directory request failed |
| `searched` | A directory request for the current text completed, successfully or not (tells "not searched" from "found nothing") |
| `cursor` (private) | Opaque `JsonObject?` from the last page, echoed back for the next one; null when there are no more. Snapshot state, because `footer` reads it |
@@ -423,70 +421,78 @@ The public state has private setters; `searchedText`, `generation` and `spinnerS
| Computed | Definition |
|----------|------------|
| [`searching`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L58) | `loading \|\| resolvingNames` |
| [`showResults`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L59) | `searching \|\| searched \|\| names.isNotEmpty()` |
| [`footer`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L60) | `DirectoryFooter.Retry` when `failed`, else `ShowMore` when a cursor exists, else null |
| [`searching`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L56) | `loading` |
| [`showResults`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L57) | `loading \|\| searched` |
| [`footer`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L58) | `DirectoryFooter.Retry` when `failed`, else `LoadMore` when a cursor exists, else null |
| Method | Description |
|--------|-------------|
| [`reset()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L62-L73) | Clears all results and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L76-L80) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay |
| [`search(text, name)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L82-L99) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`coroutineScope { async { resolveNames(name, gen) } }`, with the generation captured before the child coroutine starts) |
| [`searchDirectory(text)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L101-L115) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page |
| [`loadMore()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L117-L120) | "Show more": the next page, appended |
| [`retry()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L123-L129) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L131-L148) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshLocalChatIds()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L150-L165) | The `cleanup` of a connection started from a result row, run when its flow ends: looks the local chat up again, store-only, for every row that has none (a name row by its target, a directory row by its link), unless the generation changed. A chat opened from a result is created during that flow, so its row is then hidden like any shown chat, or drawn as a chat row when the list doesn't show it. Name rows are copied with `withLocalChatId`, as `NameSearchResult` keeps its constructor private |
| [`reset()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L60-L70) | Clears all results, `moreCount` and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L73-L76) | The spinner's `onCancel`: bumps `generation` and clears `loading`; results already shown stay |
| [`searchDirectory(text)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L78-L93) | Both triggers: ignored for empty text or while `loading` for the same text; for the same text it clears the results, `moreCount`, cursor and flags, for new text it resets; then the first page |
| [`loadMore()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L95-L98) | The load row: the next page, appended |
| [`retry()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L101-L107) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L109-L135) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshStates()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L138-L148) | The `cleanup` of a connection started from a result row, run when its flow ends: plans every result that is not `YourChat` again, store-only, unless the generation changed. A chat opened from a result is created during that flow, so its result then becomes `YourChat`: hidden like any shown chat, or drawn as its chat row when the list doesn't show it |
**Spinner.** The private setters of `loading` and `resolvingNames` call [`updateSpinner()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L216-L229) on every write, which starts the "Searching directory…" progress with owner `ConnectProgressOwner.DirectorySearch` when `searching` becomes true and stops it when it becomes false (`spinnerShown` keeps the last state, so repeated writes do nothing). The chat list shows it in the toolbar ([`ChatListToolbar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L668-L674)) once it has run for a second, not in the search field. The spinner is shared with connections: [`ConnectProgressOwner`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L50) records whose it is, [`startConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L60-L69) does not let a search take it from a running connection, and [`stopConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L72-L78) ignores a stop from another owner. Tapping a result calls [`planAndConnect`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L24-L55), whose `cancelConnectProgress()` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L81-L88)) cancels the search through its `onCancel` before the connection takes the spinner. Clearing the search text does the same through the typing path.
**Spinner.** The private setter of `loading` calls [`updateSpinner()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L182-L195) on every write, which starts the "Searching directory…" progress with owner `ConnectProgressOwner.DirectorySearch` when `searching` becomes true and stops it when it becomes false (`spinnerShown` keeps the last state, so repeated writes do nothing). The chat list shows it in the toolbar ([`ChatListToolbar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L610-L616)) once it has run for a second, not in the search field. The spinner is shared with connections: [`ConnectProgressOwner`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L50) records whose it is, [`startConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L60-L69) does not let a search take it from a running connection, and [`stopConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L72-L78) ignores a stop from another owner. Tapping a result calls [`planAndConnect`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L28-L60), whose `cancelConnectProgress()` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L81-L88)) cancels the search through its `onCancel` before the connection takes the spinner. Clearing the search text does the same through the typing path.
### Directory requests
[`request(append)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L190-L214) reads `chatModel.remoteHostId()` and calls [`apiSearchDirectory`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1677-L1684), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with a [10-second timeout](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L71-L75) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L79-L88) accepts only a `searchResults` response and decodes its entries one at a time inside `runCatching`, so an entry the app cannot decode is dropped and the rest of the page is kept.
[`request(append)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L155-L180) reads `chatModel.remoteHostId()` and calls [`apiSearchDirectory`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1677-L1684), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L14) with a [10-second timeout](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L17) and the request built by [`directorySearchRequest`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L70-L74) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L78-L89) accepts only a `searchResults` response and decodes its entries one at a time inside `runCatching`, so an entry the app cannot decode is dropped and the rest of the page is kept.
[`DirectorySearchEntry`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L33-L58) keeps the wire field `groupLink` private; its [`link`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L47-L57) is a `@Transient` property whose initializer, run by the generated decoder, takes `groupLink.connShortLink ?: groupLink.connFullLink` and throws `SerializationException` unless [`simplexLinkType`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L60-L63) classifies it as `SimplexLinkType.group` or `SimplexLinkType.channel`. Being `@Transient`, `link` cannot be set by a `link` key in the JSON. `simplexLinkType` returns the link type only when [`strConnectTarget`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt#L834) finds exactly one SimpleX link whose text is the whole string. `link` is also the entry's `id`. The directory sends only the short link when an entry has one.
The directory's response (`DRSearchResults`, apps/simplex-directory-service/src/Directory/Rpc.hs) is `{"type": "searchResults", "entries": […], "searchCursor": …, "moreCount": N, "pageSize": N}`. `searchCursor` is absent when there are no more results. `moreCount` is the number of matches after the cursor, 0 without one. `pageSize` is the directory's page size, which the app does not read: a page is cut to fit the service envelope, so it often carries fewer entries than that. The app keeps `moreCount` only when it is a non-string JSON primitive that reads as a non-negative `Int` ([L85](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L85), `intOrNull`, which rejects `5.0` where iOS accepts it); anything else becomes null without failing the response. It is only shown as text in the load row. An older directory sends no count, and the row then reads "Load more".
For each new entry of a page (not already shown, not repeated in the page), the request looks up the local chat its link refers to with `apiConnectPlan(rh, entry.link, resolveMode = PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)` and [`planLocalChatId`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L90-L110), building a [`DirectoryResult`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L176-L181). The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. The page is published, appended or replacing, only if the generation did not change.
[GAP] `moreCount` counts the matching listings, including ones without a public link that the directory never sends, so the load row can promise more results than it brings. See [GAP-11](../../product/gaps.md#gap-11-online-search-the-count-of-results-left-can-include-listings-the-directory-never-sends).
### Name resolution
[`DirectorySearchEntry`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L31-L56) keeps the wire field `groupLink` private; its [`link`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L45-L55) is a `@Transient` property whose initializer, run by the generated decoder, takes `groupLink.connShortLink ?: groupLink.connFullLink` and throws `SerializationException` unless [`simplexLinkType`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L58-L61) classifies it as `SimplexLinkType.group` or `SimplexLinkType.channel`. Being `@Transient`, `link` cannot be set by a `link` key in the JSON. `simplexLinkType` returns the link type only when [`strConnectTarget`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt#L834) finds exactly one SimpleX link whose text is the whole string. `link` is also the entry's `id`. The directory sends only the short link when an entry has one.
[`resolveNames(name, gen)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L172-L188) resolves `[name]` for a candidate starting with `@` or `#`, else `["@$name", "#$name"]` (the candidate comes from [`nameSearchCandidate`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1320-L1331), as on the typing path). Each target is planned with `apiConnectPlan(rh, target, resolveMode = PRMUnknown, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult(target, result)`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L112-L174) (a private constructor behind a companion `operator fun invoke` that returns null for no row) maps the plan with one exhaustive `when`: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName?.shortStr`, `localChatId` comes from `planLocalChatId`, `iconName` is an `ImageResource`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; the false `inProgress` also keeps `sendCmdWithRetry` from showing its network retry alert.
For each new entry of a page (not already shown, not repeated in the page), the request plans its link with `apiConnectPlan(rh, entry.link, resolveMode = PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)` ([`resultState`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L150-L153)) and maps the plan with [`directoryResultState`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L120-L129) to a [`DirectoryResultState`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L113-L117), building a [`DirectoryResult`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L131-L136)`(entry, state)`. The `when` branches in the same order as iOS:
| State | Plan | Drawn as |
|-------|------|----------|
| `YourChat(chatId)` | [`planLocalChatId`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L91-L111) names a chat (a known or prepared group, your own group, or a pending join with its chat) | the chat's own row |
| `Joining` | `GroupLinkPlan.ConnectingConfirmReconnect`, or `ConnectingProhibit` with no `groupInfo_`: a pending join without a chat | a result with "Already joining" |
| `Open` | anything else: `GroupLinkPlan.Ok`, or no plan (`NotResolvedLocally` for a link the store doesn't know, which `apiConnectPlan` returns as null without an alert, or a logged failure) | a result with Open |
The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. A short link reaches a connecting plan only after a network fetch, and the directory sends the short link whenever an entry has one, so `Joining` is reachable only for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change.
### De-duplication
[`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L131-L148) returns an [`OnlineSearchRows`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L183-L190) at composition time, so pages added by "Show more" are filtered too:
[`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L109-L135) returns an [`OnlineSearchRows`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L138-L145)`(yourChats, results, pageInChats, footer)` at composition time, so pages added by "Load more" are handled too:
- a name result is hidden when its `localChatId` is among the shown local chats;
- a directory result is hidden when its `localChatId` is among the shown local chats or any name result's `localChatId`, or when its `entry.link` equals any name result's `resolvedLink` (all name results, shown or not);
- `pageInChats` is true when the last page's results (`lastPageIds`) are not empty and each one's `localChatId` is among the shown local chats or the name results' chat ids. A result hidden only because a name result resolved to its link is not a chat the user has, so it does not count, and the part keeps its usual look;
- `yourChats`: the chat ids of `YourChat` results whose chat is loaded (`chatModel.getChat`) and not among the shown local chats, in directory order, each id once (two entries with different links can name one group);
- `results`: the other results, in directory order; a `YourChat` result whose chat is not loaded is copied as `Open`, and Open's online plan then finds the known chat;
- `pageInChats` is true when the last page's results (`lastPageIds`) are not empty and each one is `YourChat` with its chat among the shown local chats. A result the filter didn't show joins `yourChats`, which is a visible change, so it does not count;
- `footer` passes through.
Only store-derived chat ids and the link the core resolved can hide a row, never a field the directory or a name's owner wrote. A mismatch in link encoding can only produce a duplicate row.
So nothing is shown twice: a result that is a chat the list shows is hidden, a chat the list doesn't show (the local filter doesn't match it) is drawn once as its own row, and a link repeated across pages was already dropped by the request. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row.
A directory result that is a chat the user has but that the screen doesn't list (the local filter doesn't match it) stays in `rows.directory` and is drawn as a chat row (see Rendering). When its chat id is not among the loaded chats (`chatModel.getChat` returns null), the Directory row is drawn as before, and tapping it goes through `planAndConnect`, which opens the known chat.
[GAP] A regular group the user joined through a different link, through the directory's full link, or by invitation, is not found by the store lookup and still appears as a Directory row with "Join group"; tapping it goes through `planAndConnect`. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-through-another-link-are-not-recognised).
[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row. For a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `Ok`, so Open prepares and opens a second copy of the group without asking. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins this. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-without-a-prepared-group-still-appear-as-results-with-open).
### Rendering
| Where | Rows | Empty state |
|-------|------|-------------|
| [`ChatList`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1103-L1276) | `rows(shownChatIds)` with the ids of the shown chats ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1146)); after the local chats and before `ChatListFeatureCards`, [`nameRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L517-L532) then [`directoryRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L534-L562) | `NoChatsView` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1254-L1259)); the `you_have_no_chats` overlay of `ChatListWithLoadingScreen` (no chats at all) is hidden while `showResults` |
| Onboarding branch of [`ChatListWithLoadingScreen`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L459-L506) | A `Column` with `ChatListSearchBarWithTags` first (top bar) or last (one-hand UI), and between them a `Box(Modifier.weight(1f))` holding, while `showResults`, a `LazyColumnWithScrollBarNoAppBar` of the same parts with `shownChatIds = emptySet()` (reversed in one-hand mode, `imePadding()` in top-bar mode), otherwise `ConnectOnboardingView()` on Android | A centred "No chats found" item when not searching and `rows.isEmpty` |
| [`ChatList`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1039-L1210) | `rows(shownChatIds)` with the ids of the shown chats ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1081)); after the local chats and before `ChatListFeatureCards`, [`directoryRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L510-L526) | `NoChatsView` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1188-L1193)); the `you_have_no_chats` overlay of `ChatListWithLoadingScreen` (no chats at all) is hidden while `showResults` |
| Onboarding branch of [`ChatListWithLoadingScreen`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L459-L499) | A `Column` with `ChatListSearchBarWithTags` first (top bar) or last (one-hand UI), and between them a `Box(Modifier.weight(1f))` holding, while `showResults`, a `LazyColumnWithScrollBarNoAppBar` of the same rows with `shownChatIds = emptySet()` (reversed in one-hand mode, `imePadding()` in top-bar mode), otherwise `ConnectOnboardingView()` on Android | A centred "No chats found" item when not searching and `rows.isEmpty` |
The onboarding `Column` takes the toolbar and navigation-bar paddings and consumes the navigation-bar insets, so the results list's `imePadding()` does not count them twice. On desktop the onboarding pages are drawn in the centre pane, so the chat list pane shows only the search bar and the results. When a joined result adds a chat and the onboarding state ends, `ChatList` enters composition and its `LaunchedEffect(activeFilter.value)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1273-L1275)) clears the search text, which resets the model; the iOS app keeps the search there.
The onboarding `Column` takes the toolbar and navigation-bar paddings and consumes the navigation-bar insets, so the results list's `imePadding()` does not count them twice. On desktop the onboarding pages are drawn in the centre pane, so the chat list pane shows only the search bar and the results. When an opened result adds a chat and the onboarding state ends, `ChatList` enters composition and its `LaunchedEffect(activeFilter.value)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1207-L1209)) clears the search text, which resets the model; the iOS app keeps the search there.
Each part renders only when it has rows: the Names part when `rows.names` is not empty, the Directory part when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set. `nameRows` and `directoryRows` are `LazyListScope` builders; each part has a header `item` ([`SearchPartHeader`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L575-L583): `body2`, secondary colour, `semantics { heading() }`) rather than a `stickyHeader`, and its rows are `items` keyed by `id`. [`searchPart`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L564-L573) emits the header before the part's rows with the top bar and after them in the one-hand UI, so it is drawn above them in both layouts, the one-hand list being reversed. Its header defaults to `SearchPartHeader`; the New chat sheet passes its own ([Navigation, New Chat Online Search](navigation.md#10-new-chat-online-search)). A lazy list keeps its first visible item in place by key, so in the reversed onboarding list, whose first item is then a keyed row, names arriving after the directory page can end up out of view: that list scrolls to its first item when name results arrive in one-hand mode ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L474-L480)). The `ChatList` branch starts with a keyless spacer, which stays the anchor.
`directoryRows` is a `LazyListScope` builder that emits one list without headers, in this order: your chats (`rows.yourChats`, `items` keyed `remoteHostId to id`), the results (`items` keyed by `id`, the link), the note, then the footer item. So the load or retry row is the last row of the results: at the bottom with the top bar, and at the top in the one-hand UI, the list being reversed, which puts it farthest from the field.
| Row | View | Tap |
|-----|------|-----|
| Name | [`NameSearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L297-L325): avatar, profile name in bold, the SimpleX name in parentheses (secondary, one `AnnotatedString`, one line), description (2 lines) | `planAndConnect(rhId, result.target, close = null, cleanup = { withApi { refreshLocalChatIds() } })` |
| Directory entry | [`DirectorySearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L250-L295): avatar, `#` + name, the directory's SimpleX name claim as plain text (truncates first), description (2 lines), member or subscriber count (`body2`, secondary colour), and the verb "Join channel" / "Join group" (one line, never wrapped) | `planAndConnect(rhId, result.entry.link, close = null, cleanup = { withApi { refreshLocalChatIds() } })` |
| Known chat | When `result.localChatId` names a loaded chat (`chatModel.getChat`), in place of the directory entry: the chat list's own row, [`ChatListNavLinkView(chat, remember { mutableStateOf(false) })`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L540) | as a local chat: opens it |
| Note | [`DirectoryPageInChatsRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L327-L336), an `item` after the directory rows when `rows.pageInChats` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L552-L554)): "All results on this page are already in your chats", secondary colour, padded as "Show more" | none |
| Retry | [`DirectoryRetryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L355-L367): "Search failed, tap to retry" | `withApi { retry() }` |
| Show more | [`DirectoryShowMoreRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L338-L353): "Show more", disabled (secondary colour) while `loading` | `withApi { loadMore() }` |
| Your chat | Each loaded chat of `rows.yourChats`, drawn with the chat list's own row, `ChatListNavLinkView(chat, remember { mutableStateOf(false) })` | as a local chat: opens it |
| Result | [`DirectoryResultNavLink`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L321-L342)`(result, imageSize = 72.dp * fontSizeSqrtMultiplier, close = null, cleanup = { withApi { refreshStates() } })`: `ChatListNavLinkLayout` with [`DirectorySearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L217-L267) as its preview, so the row gets the chat rows' padding and divider and the chat rows' avatar size. The row: avatar, `#` + name, the directory's SimpleX name claim as plain text (truncates first), description (2 lines), then the caption (`body2`, secondary colour): the member or subscriber count, or "Already joining" for `Joining`; trailing "Open" (`open_verb`, primary colour, one line, never wrapped), except for `Joining` | `hideKeyboard(view)`, then [`openDirectoryResult`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L345-L348) (see Opening a result) |
| Note | [`DirectoryPageInChatsRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L271-L278), an `item` after the results when `rows.pageInChats`: "All results on this page are already in your chats", secondary colour, padded as the load row | none |
| Load more | [`DirectoryLoadMoreRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L282-L300): `ic_search` (MR has no plus-magnifier) and "Load more · %1$d left" (`directory_search_load_more_n_left`) when `moreCount` is above 0, else "Load more"; not clickable and in the secondary colour while `loading` | `withApi { loadMore() }` |
| Retry | [`DirectoryRetryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L304-L316): `ic_refresh` and "Search failed, tap to retry" | `withApi { retry() }` |
A name or directory row tap first hides the keyboard (`hideKeyboard(view)`), then runs `planAndConnect` with `withBGApi`. Text from the directory and from name owners is rendered with `Text(String)` (never parsed as markdown) with line limits.
A result's menu is `ChatListNavLinkLayout`'s dropdown, opened by a long press, or by a right click on desktop, with one item, `ItemAction(open_verb, ic_chat_bubble)`, which does what a tap does. It has no lifted preview, unlike the iOS context menu, so `DirectorySearchRow` has no `showsOpen` parameter. Text from the directory is rendered with `Text(String)` (never parsed as markdown) with line limits.
[GAP] Resolving a SimpleX name with `PRMUnknown` also loads the resolved address's link data from the server that hosts it; the alert names only the directory and the name-resolving server. See [GAP-11](../../product/gaps.md#gap-11-online-search-first-search-alert-does-not-mention-the-server-hosting-a-resolved-address).
### Opening a result
[`openDirectoryResult(result, close, cleanup)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L345-L348) runs `withBGApi { planAndConnect(rhId, result.entry.link, close, cleanup, prepareGroup = …) }` with `PrepareGroupMode.Open`, or `Confirm` (the default) for `Joining`. `planAndConnect` makes the online plan with the "Loading profile…" progress. [`PrepareGroupMode`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L24-L26) is passed on to `planAndConnectTask` and read only in the `GroupLinkPlan.Ok` branch with link data ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L258-L281)): `Confirm` shows the "Open channel?" / "Open group?" alert ([`showPrepareGroupAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L768)), as for a pasted link, and `Open` calls [`prepareAndOpenGroup`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L808-L835) at once, the function the alert's confirm also calls: `apiPrepareGroup`, the channel's relay hostnames, `addChat`, `openChat_`, then `cleanup`. So Open goes straight to the prepared chat with its Join button. Every other plan shows what a pasted link shows: the "Open group" alert for a known group, the reconnect and connecting alerts for a pending join, and the no-relays and update-required alerts. A `Joining` result keeps the default mode, so whatever its online plan turns out to be, it shows what a pasted link would.
The chat banner of a prepared channel (`nextConnectPrepared && useRelays`) shows its subscriber count, `subscriberCountStr(groupSummary.publicMemberCount)` from the link data, after the full name ([ChatView.kt](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L2355-L2364)), in `body2`, the secondary colour, centred.
+8 -10
View File
@@ -396,23 +396,21 @@ The stage value is stored in `appPrefs.onboardingStage` and persisted across app
## 10. New Chat Online Search
**Location:** [`NewChatSheetLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L110-L548)
**Location:** [`NewChatSheetLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L110-L509)
`NewChatSheetLayout` offers the chat list's online search (see [Chat List, Online Search](chat-list.md#11-online-search)) with its own [`OnlineSearchModel`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L141) (`remember`), reset from a `KeyChangeEffect` on `searchText.value.text` and another on `chatModel.currentUser.value?.userId` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L182-L187)). A profile switch also closes all modals on Kotlin, so the sheet is disposed anyway. The sheet's `DisposableEffect` `onDispose` ([`NewChatSheet`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L42-L48)) calls `cancelConnectProgress()`, which cancels a search still in flight. The model is also dropped while a modal opened from the sheet covers it (`ModalManager` composes only the top modal) and on an Android rotation.
| Element | Where | Behavior |
|---------|-------|----------|
| Search key | [`ContactsSearchBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L568-L670) with `onSearch` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L576)), passed to `SearchTextField` | the keyboard's Search action (Android) or Enter (desktop) runs [`runOnlineSearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L250-L255): `search(text, name = connectNameCandidate.value)` |
| "Connect to …" row | the sticky header, below the field, in both `OneHandLazyColumn` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L400-L404)) and `NonOneHandLazyColumn` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L498-L502)) | shown for a name candidate until name results arrive (`candidate != null && onlineSearch.names.isEmpty()`) |
| "Search in Directory" row | after the "Connect to …" row, behind a `Divider` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L405-L408), [source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L503-L506)) | shown for non-empty, non-link text; runs [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L244-L248): `searchDirectory(text)` |
| Names and Directory sections | after `itemsIndexed(filteredContactChats)` in both columns, [`onlineSearchSections(rows)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L196-L240) | each part starts with a `SectionDividerSpaced` item, then the chat list's `searchPart` places its [`OnlineSearchHeader`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L189-L192) item (the `SectionView` title, not upper-cased), styled as "Your contacts"; rows are `items` keyed by `id`; the Directory part renders when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set, and ends with the note item ([`DirectoryPageInChatsRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L327-L336), when `rows.pageInChats`) and then the retry or "Show more" row. A directory result whose `localChatId` names a loaded chat (`chatModel.getChat`) is drawn as `ContactListNavLinkView(chat, remember { mutableStateOf(false) }, showDeletedChatIcon = true)`, whose [`ChatInfo.Group` branch](../../common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt#L138-L156) opens the group and closes the sheet (`openChat` then `ModalManager.start.closeModals()`; `ContactPreviewView` draws the avatar, the name as a contact request's, favourite star and incognito mask; no menu items); other rows tap `hideKeyboard(view)` then `withBGApi { planAndConnect(rhId, …, close = close, cleanup = null) }`, which closes the sheet once connected |
| Search key | [`ContactsSearchBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L529-L631) with `onSearch` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L537)), passed to `SearchTextField` | the keyboard's Search action (Android) or Enter (desktop) runs [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L212-L216): `searchDirectory(text)`, the directory only |
| "Connect to …" row | the sticky header, below the field, in both `OneHandLazyColumn` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L361-L365)) and `NonOneHandLazyColumn` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L459-L463)) | shown whenever there is a name candidate, results or not |
| "Search in Directory" row | after the "Connect to …" row, behind a `Divider` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L366-L369), [source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L464-L467)) | shown for non-empty, non-link text while no results show or load (`!onlineSearch.showResults`); runs `runDirectorySearch()` |
| Results card | after `itemsIndexed(filteredContactChats)` in both columns, [`onlineSearchSection(rows)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L190-L208) | When `!rows.isEmpty`: a `SectionDividerSpaced` item, then, without a header, your chats (`rows.yourChats`, the loaded ones, keyed `remoteHostId to id`), the results (keyed by `id`), the note item ([`DirectoryPageInChatsRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L271-L278), when `rows.pageInChats`) and then the retry or load row. Your chats are drawn as `ContactListNavLinkView(chat, remember { mutableStateOf(false) }, showDeletedChatIcon = true)`, whose [`ChatInfo.Group` branch](../../common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt#L138-L156) opens the group and closes the sheet (`openChat` then `ModalManager.start.closeModals()`; `ContactPreviewView` draws the avatar, the name as a contact request's, favourite star and incognito mask; no menu items). Results are `DirectoryResultNavLink(result, imageSize = 42.dp, previewModifier = Modifier.padding(horizontal = DEFAULT_PADDING_HALF), close = close, cleanup = null)`, the contact rows' avatar size and padding, with Open or "Already joining" and the same long-press / right-click menu as in the chat list; a tap or the menu's Open runs `openDirectoryResult`, which closes the sheet once the chat opens |
Both triggers do nothing for empty text or a pasted link ([`searchTrimmed()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L242), `searchShowingSimplexLink`) and go through `confirmDirectorySearch`, launching the model with `withApi`. `onlineSearchSections`, `OnlineSearchHeader`, `searchTrimmed`, `runDirectorySearch` and `runOnlineSearch` are local functions of `NewChatSheetLayout`. The one-hand column is reversed, so there `searchPart` puts each header after its rows and it is drawn above them, as in the chat list. A reversed list also reverses the nodes inside an item, so the divider is an item of its own: drawn below its part's rows there, it separates the parts, where in the header's item it would fall between the header and its rows. The contacts header shows only while the search text is empty, so it is never next to online results.
Both triggers do nothing for empty text or a pasted link ([`searchTrimmed()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L210), `searchShowingSimplexLink`) and go through `confirmDirectorySearch`, which asks "Enable Directory search?" while the switch is off, launching the model with `withApi`. `onlineSearchSection`, `searchTrimmed` and `runDirectorySearch` are local functions of `NewChatSheetLayout`. `onlineSearchSection` emits its items in the order above, so the load row is the row farthest from the field in both layouts: last in the top-bar column, and drawn at the top of the reversed one-hand column, whose field is at the bottom (iOS's New chat is not flipped, so there it is always the last row). The divider is an item of its own, as a reversed list also reverses the nodes inside an item. The contacts header shows only while the search text is empty, so it is never next to online results.
**De-duplication against the contacts list.** `filteredContactChats` (the contacts the list shows, computed in the layout's scope) gives the shown ids: `rows = onlineSearch.rows(shownChatIds = filteredContactChats.map { it.id }.toSet())` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L180)), so no extra helper is needed.
**Empty state.** `showsEmptyState = !onlineSearch.searching && rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L181)); [`NoFilteredContactsItem`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L337-L351) shows "No filtered contacts" only when it holds. Kotlin has no "No contacts" state.
**Empty state.** `showsEmptyState = !onlineSearch.searching && rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L181)); [`NoFilteredContactsItem`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L298-L312) shows "No filtered contacts" only when it holds. Kotlin has no "No contacts" state.
**Archived contacts.** [`DeletedContactsView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L763-L877) passes no `onSearch` to `ContactsSearchBar`: its field keeps the Search IME label (every `SearchTextField` uses `ImeAction.Search`), the key does nothing, and it shows no online rows; its empty state is unchanged.
[GAP] New chat does not resolve a typed name locally while typing (the `ContactsSearchBar` collector resolves a name only when its "Connect to …" row is tapped), so a known contact whose name does not contain the typed text is not in "Your contacts" and appears as a Names row; tapping it opens the existing chat. See [GAP-12](../../product/gaps.md#gap-12-online-search-new-chat-does-not-narrow-contacts-by-name-while-typing).
**Archived contacts.** [`DeletedContactsView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L724-L838) passes no `onSearch` to `ContactsSearchBar`: its field keeps the Search IME label (every `SearchTextField` uses `ImeAction.Search`), the key does nothing, and it shows no online rows; its empty state is unchanged.
+9 -9
View File
@@ -41,7 +41,7 @@
| PC29 | User Address |
| PC30 | Member Support Chat |
| PC31 | Channels (Relays) |
| PC32 | Online Search (Directory and Names) |
| PC32 | Online Search (SimpleX Directory) |
---
@@ -57,7 +57,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle |
| `model/ChatModel.kt` | PC1 through PC32 | High | Central state object — every feature reads or writes here |
| `model/SimpleXAPI.kt` | PC1 through PC32 | High | FFI bridge to Haskell core — all commands and responses |
| `model/DirectorySearch.kt` | PC32 | High | Directory response parsing and link-type validation of untrusted entries; online result types |
| `model/DirectorySearch.kt` | PC32 | High | Directory response parsing (entries, lenient `moreCount`) and link-type validation of untrusted entries; result states and rows |
| `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers |
| `platform/Core.kt` | PC1 through PC32 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic |
| `platform/AppCommon.kt` | PC1 through PC32 | Medium | Shared app initialization logic |
@@ -93,9 +93,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| Source File | Product Concepts Affected | Risk Level | Notes |
|-------------|--------------------------|------------|-------|
| `views/chatlist/ChatListView.kt` | PC1, PC28, PC32 | High | Main screen — chat list rendering and search; online search triggers, result parts and the onboarding search |
| `views/chatlist/ChatListView.kt` | PC1, PC28, PC32 | High | Main screen — chat list rendering and search; online search triggers, result rows and the onboarding search |
| `views/chatlist/GetStakeBanner.kt` | PC1 | Low | Crowdfunding banner and shared banner card chrome |
| `views/chatlist/DirectorySearchView.kt` | PC1, PC32 | Medium | Online search model (generation, spinner, de-duplication), result rows, consent alert |
| `views/chatlist/DirectorySearchView.kt` | PC1, PC32 | Medium | Online search model (generation, spinner, result states, de-duplication), result rows, load row, result menu, Open, consent alert |
| `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat |
| `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering |
| `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI |
@@ -111,7 +111,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| Source File | Product Concepts Affected | Risk Level | Notes |
|-------------|--------------------------|------------|-------|
| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features |
| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC32 | High | Core conversation UI — most messaging features; the prepared channel's banner count that Open leads to |
| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages |
| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle |
| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion |
@@ -194,8 +194,8 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| Source File | Product Concepts Affected | Risk Level | Notes |
|-------------|--------------------------|------------|-------|
| `views/newchat/NewChatView.kt` | PC12, PC29 | High | New connection creation — onramp for all contacts |
| `views/newchat/NewChatSheet.kt` | PC12, PC32 | Medium | Bottom sheet with connection options, contacts list and online search sections; search bar shared with archived contacts |
| `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution |
| `views/newchat/NewChatSheet.kt` | PC12, PC32 | Medium | Bottom sheet with connection options, contacts list and online search results card; search bar shared with archived contacts |
| `views/newchat/ConnectPlan.kt` | PC12, PC15, PC32 | Medium | Link parsing and connection plan resolution; `planAndConnect`'s `prepareGroup` mode, which the online search's Open uses |
| `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow |
| `views/newchat/AddChannelView.kt` | PC31 | Medium | Public channel creation, channel link card, `RelayStatusIndicator` |
| `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details |
@@ -211,7 +211,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
|-------------|--------------------------|------------|-------|
| `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub |
| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization |
| `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings |
| `views/usersettings/PrivacySettings.kt` | PC20, PC22, PC32 | Medium | Privacy and lock settings; the "Directory search" switch |
| `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing |
| `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management |
| `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access |
@@ -286,7 +286,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker |
| `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop |
| `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers |
| `views/helpers/SearchTextField.kt` | PC1, PC32 | Low | Search bar composable; optional Search key handler (`onSearch`: Android IME action, desktop Enter) |
| `views/helpers/SearchTextField.kt` | PC1, PC32 | Low | Search bar composable; optional Search key handler (`onSearch`: Android IME action, desktop Enter); no autocorrect or capitalization |
| `views/helpers/CustomTimePicker.kt` | PC8 | Low | Time picker for timed messages |
| `views/helpers/DragAndDrop.kt` | PC10 | Low | Drag-and-drop file handling |
| `views/helpers/ProcessedErrors.kt` | — | Low | Error aggregation |
+2 -5
View File
@@ -369,6 +369,7 @@ val appPrefs: AppPreferences by lazy { AppPreferences() }
| `privacyProtectScreen` | `Boolean` | `true` | FLAG_SECURE on Android |
| `privacyAcceptImages` | `Boolean` | `true` | Auto-accept images |
| `privacyLinkPreviews` | `Boolean` | `true` | Generate link previews |
| [`privacyDirectorySearch`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L128) (`"PrivacyDirectorySearch"`) | `Boolean` | `false` | "Directory search" in Privacy settings; while it is off, every online search asks "Enable Directory search?", and Enable turns it on (see [Chat List, Online Search](client/chat-list.md#11-online-search)) |
| `privacySanitizeLinks` | `Boolean` | `false` | Remove tracking params from links |
| `simplexLinkMode` | `SimplexLinkMode` | `DESCRIPTION` | DESCRIPTION / FULL / BROWSER |
| `privacyShowChatPreviews` | `Boolean` | `true` | Show chat previews in list |
@@ -453,11 +454,7 @@ val appPrefs: AppPreferences by lazy { AppPreferences() }
#### Hints
Preferences listed in [`hintPreferences`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L279-L297) are restored to their defaults by "Reset all hints" (`resetHintPreferences()` in `views/usersettings/SettingsView.kt`).
| Key | Type | Default | Purpose |
|---|---|---|---|
| [`directorySearchAlertShown`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L196) (`"DirectorySearchAlertShown"`) | `Boolean` | `false` | The user chose "Search and don't show again" in the online search alert; in `hintPreferences` (see [Chat List, Online Search](client/chat-list.md#11-online-search)) |
Preferences listed in [`hintPreferences`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L282-L299) are restored to their defaults by "Reset all hints" (`resetHintPreferences()` in `views/usersettings/SettingsView.kt`). The online search's consent is not among them: `privacyDirectorySearch` (Privacy, above) is a setting, so "Reset all hints" leaves it as it is.
#### Remote Access (lines 258-263)