ios, android, desktop: align online search with the designer's screens

Three differences from the designer's boards, on both platforms:
- a page whose results are all chats the list already shows adds no row and no
  note: "All results on this page are already in your chats" is removed, with the
  page tracking that computed it;
- New chat: while results show, the contacts that match lead the results card,
  with no "Your Contacts" header (iOS), and no divider between the contacts and
  the results (Kotlin);
- Kotlin's load row gets the magnifier with a plus, as iOS's plus.magnifyingglass.

Product rules, views and specs follow, with the spec line references moved.
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-21 15:05:34 +00:00
1 parent d91d7742a4
commit 7b18bf89cd
20 files changed
+132 -197

No files matched your search

+1 -2
View File
@@ -151,8 +151,7 @@ struct DirectoryResult: Identifiable {
struct OnlineSearchRows {
var yourChats: [ChatId]
var results: [DirectoryResult]
var pageInChats: Bool
var footer: DirectoryFooter?
var isEmpty: Bool { yourChats.isEmpty && results.isEmpty && !pageInChats && footer == nil }
var isEmpty: Bool { yourChats.isEmpty && results.isEmpty && footer == nil }
}
@@ -493,7 +493,6 @@ struct ChatListView: View {
.listRowBackground(Color.clear)
.onTapGesture { openResult(result) }
}
if rows.pageInChats { directoryPageInChatsRow() }
switch rows.footer {
case .retry: directoryRetryRow()
case .loadMore: directoryLoadMoreRow()
@@ -653,12 +652,6 @@ struct ChatListView: View {
}
}
@ViewBuilder private func directoryPageInChatsRow() -> some View {
DirectoryPageInChatsRow()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
}
@ViewBuilder private func directoryLoadMoreRow() -> some View {
DirectoryLoadMoreRow(onlineSearch: onlineSearch)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
@@ -24,7 +24,6 @@ class OnlineSearchModel: ObservableObject {
@Published private(set) var searched = false
private var cursor: JSONValue? = nil
private var lastPageIds: Set<String> = []
private var searchedText = ""
// bumped on every reset, so a reply that arrives too late cannot refill a list already cleared
private var generation = 0
@@ -39,7 +38,6 @@ class OnlineSearchModel: ObservableObject {
directory = []
moreCount = nil
cursor = nil
lastPageIds = []
loading = false
failed = false
searched = false
@@ -59,7 +57,6 @@ class OnlineSearchModel: ObservableObject {
directory = []
moreCount = nil
cursor = nil
lastPageIds = []
failed = false
searched = false
} else {
@@ -98,15 +95,7 @@ class OnlineSearchModel: ObservableObject {
results.append(result)
}
}
let lastPage = directory.filter { lastPageIds.contains($0.id) }
return OnlineSearchRows(
yourChats: yourChats,
results: results,
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy { result in
if case let .yourChat(chatId) = result.state { shownChatIds.contains(chatId) } else { false }
},
footer: footer
)
return OnlineSearchRows(yourChats: yourChats, results: results, footer: footer)
}
// a chat opened from a result exists only once its connection flow ends, so results look again then
@@ -153,7 +142,6 @@ class OnlineSearchModel: ObservableObject {
cursor = r.cursor
moreCount = r.moreCount
directory = append ? directory + page : page
lastPageIds = Set(page.map { $0.id })
}
private func updateSpinner() {
@@ -256,15 +244,6 @@ struct DirectorySearchRow: View {
}
}
struct DirectoryPageInChatsRow: View {
@EnvironmentObject var theme: AppTheme
var body: some View {
Text("All results on this page are already in your chats")
.foregroundColor(theme.colors.secondary)
}
}
struct DirectoryLoadMoreRow: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var onlineSearch: OnlineSearchModel
@@ -171,18 +171,21 @@ struct NewChatSheet: View {
showUnreadAndFavorites: showUnreadAndFavorites
).shown
let rows = onlineSearch.rows(shownChatIds: Set(shownContacts.map { $0.id }))
ContactsList(
chatPredicate: contactListChatPredicate,
searchMode: $searchMode,
searchText: $searchText,
header: "Your Contacts",
searchFocussed: $searchFocussed,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
showDeletedChatIcon: true,
showsEmptyState: !onlineSearch.searching && rows.isEmpty
)
onlineSearchSection(rows)
if onlineSearch.showResults && (!shownContacts.isEmpty || !rows.isEmpty) {
onlineSearchSection(rows, contacts: shownContacts)
} else {
ContactsList(
chatPredicate: contactListChatPredicate,
searchMode: $searchMode,
searchText: $searchText,
header: "Your Contacts",
searchFocussed: $searchFocussed,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
showDeletedChatIcon: true,
showsEmptyState: !onlineSearch.searching
)
}
}
.onChange(of: searchText) { _ in
onlineSearch.reset()
@@ -192,23 +195,24 @@ struct NewChatSheet: View {
}
}
@ViewBuilder private func onlineSearchSection(_ rows: OnlineSearchRows) -> some View {
if !rows.isEmpty {
Section {
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
}
ForEach(rows.results) { result in
DirectorySearchRow(entry: result.entry, imageSize: 30, state: result.state)
.modifier(DirectoryResultMenu(result: result, imageSize: 30, onOpen: { openResult(result) }))
.onTapGesture { openResult(result) }
}
if rows.pageInChats { DirectoryPageInChatsRow() }
switch rows.footer {
case .retry: DirectoryRetryRow(onlineSearch: onlineSearch)
case .loadMore: DirectoryLoadMoreRow(onlineSearch: onlineSearch)
case nil: EmptyView()
}
private func onlineSearchSection(_ rows: OnlineSearchRows, contacts: [Chat]) -> some View {
Section {
ForEach(contacts, id: \.viewId) { chat in
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
.disabled(chatModel.chatRunning != true)
}
ForEach(rows.yourChats.compactMap { chatModel.getChat($0) }, id: \.viewId) { chat in
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
}
ForEach(rows.results) { result in
DirectorySearchRow(entry: result.entry, imageSize: 30, state: result.state)
.modifier(DirectoryResultMenu(result: result, imageSize: 30, onOpen: { openResult(result) }))
.onTapGesture { openResult(result) }
}
switch rows.footer {
case .retry: DirectoryRetryRow(onlineSearch: onlineSearch)
case .loadMore: DirectoryLoadMoreRow(onlineSearch: onlineSearch)
case nil: EmptyView()
}
}
}
+2 -2
View File
@@ -167,8 +167,8 @@
**Spec:** [spec/api.md](../spec/api.md#8-directory-search-and-connection-plans)
### RULE-27: Nothing is shown twice
**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, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (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`).
**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 page adds no row and no note. Limits: the store-only lookup finds a group only when it was prepared from that short link, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (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:)`; `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
-1
View File
@@ -51,7 +51,6 @@ Searching beyond the user's own chats happens only when the user asks, and the r
| 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. If the link's signature was checked or its name also leads to another chat, the question shows first, as for a pasted link, with the signature result or the other chat's button. 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: 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 the listed short link (with a full link from an older app, 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.
+2 -2
View File
@@ -78,8 +78,8 @@ The search field at the top of the New chat sheet filters "Your Contacts" and of
| "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 |
| Results card | While results show, one card without a header takes the place of "Your Contacts": first the contacts that match, then your chats among the results that the contacts don't include, 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. 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; they show when a finished search found nothing and no contact matches |
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.
+41 -44
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#L723)
- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L716)
- 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#L638), which also draws your chats among the online search 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#L637), 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
@@ -82,10 +82,10 @@ The root list view. Key responsibilities:
| Function | Line | Description |
|----------|------|-------------|
| [`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 |
| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L716) | 723 | Applies active filter and search to chat list |
| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L759) | 766 | Normalizes search text for comparison |
| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L690) | 697 | Renders unread count circle badge |
| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L710) | 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#L1335) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1129) struct.
Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1328) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1122) 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#L1314) | ChatListView.swift | 1314 | Applies a filter and persists selection |
| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1307) | 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#L830)
**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L823)
### Filtering Logic
- Filters `ChatModel.chats` by matching search text against:
@@ -221,7 +221,7 @@ 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#L723) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L766)
- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L716) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L759)
### Search Results
- Matching chats are displayed in the same list format
@@ -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#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)` |
| Chat list | rendered with [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L513) while no online results are shown ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L516)), 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#L509) | the dismiss X appears from then on, while there are chats |
| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L508) | 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,27 +324,27 @@ 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. 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)).
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#L823) calls `reset()` on every change of `searchText` ([L921](../../Shared/Views/ChatList/ChatListView.swift#L914)) and of `currentUser?.userId` ([L924](../../Shared/Views/ChatList/ChatListView.swift#L917)). 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)`) | [`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)` |
| Keyboard Search key (`.submitLabel(.search)`) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1019) | `searchDirectory(text)` |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L163)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1019) | `searchDirectory(text)` |
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.
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#L321), 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#L329): "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. "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, [L852](../../Shared/Views/ChatList/ChatListView.swift#L852)) | Top bar (below the field, [L905](../../Shared/Views/ChatList/ChatListView.swift#L905)) |
| Search text | One-hand UI (slot above the field, [L852](../../Shared/Views/ChatList/ChatListView.swift#L845)) | Top bar (below the field, [L905](../../Shared/Views/ChatList/ChatListView.swift#L898)) |
|-------------|-----------------------------------|------------------------------|
| Empty | Tags | Tags above; nothing 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-L173)
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L161)
`@MainActor` `ObservableObject`, owned by `ChatListView` as `@StateObject onlineSearch` and passed to `ChatListSearchBar`.
@@ -356,30 +356,29 @@ The rows above or below the search field follow the toolbar mode. "Search in Dir
| `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 |
| `lastPageIds` (private) | The ids of the results the last successful request added (none when every entry of the page was already shown); cleared with the directory state, kept by a failed request |
| `generation` (private) | Bumped by `reset()` and `cancelRequest()`; a reply whose captured generation differs never writes model state |
| Computed | Definition |
|----------|------------|
| [`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 |
| [`searching`](../../Shared/Views/ChatList/DirectorySearchView.swift#L32) | `loading` |
| [`showResults`](../../Shared/Views/ChatList/DirectorySearchView.swift#L33) | `loading \|\| searched` |
| [`footer`](../../Shared/Views/ChatList/DirectorySearchView.swift#L34) | `DirectoryFooter.retry` when `failed`, else `.loadMore` when a cursor exists, else nil |
| Method | Description |
|--------|-------------|
| [`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 |
| [`reset()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L36) | Clears all results, `moreCount` and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L48) | The spinner's `onCancel`: bumps `generation` and clears `loading`; results already shown stay |
| [`searchDirectory(_:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L53) | 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#L69) | The load row: the next page, appended |
| [`retry()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L75) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L83) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshStates()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L102) | 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` 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.
**Spinner.** `loading` has a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L147), 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#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.
[`request(append:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L122) 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".
@@ -387,7 +386,7 @@ The directory's response (`DRSearchResults`, apps/simplex-directory-service/src/
[`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 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}`:
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#L117)) 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}`:
| State | Plan | Drawn as |
|-------|------|----------|
@@ -399,14 +398,13 @@ The link-type check makes this lookup store-only: the core plans contact-mode li
### De-duplication
[`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:
[`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L83) returns an [`OnlineSearchRows`](../../Shared/Model/DirectorySearch.swift#L151) `{yourChats, results, footer}` at render time, so pages added by "Load more" are handled too:
- `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.
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.
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. A page whose results are all chats the list shows adds no row and no note. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row.
[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), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row. For a group joined with a full link (an older app) 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 tests "short link without resolving: prepared and /c-joined groups known, invited member not found" and "short link without resolving: pending /c join is connecting" (tests/ChatTests/Groups.hs) pin this.
@@ -414,26 +412,25 @@ So nothing is shown twice: a result that is a chat the list shows is hidden, a c
| Where | Rows | Empty state |
|-------|------|-------------|
| [`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)) |
| [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L513) | `rows(shownChatIds:)` with the ids of the shown chats ([L516](../../Shared/Views/ChatList/ChatListView.swift#L515)); after the local chats, [`directoryRows`](../../Shared/Views/ChatList/ChatListView.swift#L483). While `showResults` ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L516) 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#L629)) |
| 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` |
`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.
`directoryRows` draws one list without headers, in this order: your chats (`rows.yourChats`), the results, 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 |
|-----|------|-----|
| 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()` |
| Your chat | Each loaded chat of `rows.yourChats`, drawn with the chat list's own row, [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L637) (`ChatListNavLink` with the local chats' modifiers) | as a local chat: opens it |
| Result | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L185) 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#L503): `openDirectoryResult(result, dismiss: false)`, `cleanup`: `refreshStates()` (see Opening a result) |
| Load more | [`DirectoryLoadMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L247) via [`directoryLoadMoreRow()`](../../Shared/Views/ChatList/ChatListView.swift#L655): `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#L273) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L661): `arrow.clockwise` and "Search failed, tap to retry" | `retry()` |
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.
On iOS 16 and later a long press on a result shows a context menu, [`DirectoryResultMenu`](../../Shared/Views/ChatList/DirectorySearchView.swift#L290), 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.
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`. `.open` applies only when the plan has no `ownerVerification` (verified or failed) and no other name (`connectOtherLink`); with either, the branch shows the alert as `.confirm` does, because only the alert shows the signature line and the other name's button. Open plans a link, not a name, without a signature, so its plan has neither, and 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.
[`openDirectoryResult(_:theme:dismiss:cleanup:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L316) 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`. `.open` applies only when the plan has no `ownerVerification` (verified or failed) and no other name (`connectOtherLink`); with either, the branch shows the alert as `.confirm` does, because only the alert shows the signature line and the other name's button. Open plans a link, not a name, without a signature, so its plan has neither, and 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.
+7 -7
View File
@@ -365,22 +365,22 @@ For channels (`groupInfo.useRelays`): the title is "Open channel"; for groups, "
**Source:** [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift)
[`NewChatSheet`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L36) offers the chat list's online search (see [Chat List, Online Search](chat-list.md#11-online-search)) with its own [`OnlineSearchModel`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L46), reset on every change of `searchText` ([L187](../../Shared/Views/NewChat/NewChatMenuButton.swift#L187)) and of `currentUser?.userId` ([L190](../../Shared/Views/NewChat/NewChatMenuButton.swift#L190)). The sheet's `onDisappear` calls `cancelConnectProgress()`, which cancels a search still in flight.
[`NewChatSheet`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L36) offers the chat list's online search (see [Chat List, Online Search](chat-list.md#11-online-search)) with its own [`OnlineSearchModel`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L46), reset on every change of `searchText` ([L187](../../Shared/Views/NewChat/NewChatMenuButton.swift#L190)) and of `currentUser?.userId` ([L190](../../Shared/Views/NewChat/NewChatMenuButton.swift#L193)). The sheet's `onDisappear` calls `cancelConnectProgress()`, which cancels a search still in flight.
| Element | Where | Behavior |
|---------|-------|----------|
| 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 |
| Search key | [`ContactsListSearchBar`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L418) with `onSearch` ([L424](../../Shared/Views/NewChat/NewChatMenuButton.swift#L428)) | `.submitLabel(.search)` and `.onSubmit` run [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L229): `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 |
| "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#L229) |
| Results card | in place of `ContactsList` while `showResults` and there is a shown contact or a row, [`onlineSearchSection(_:contacts:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L198) | One `Section` without a header: the shown contacts (`ContactListNavLink(chat:showDeletedChatIcon: true)`, disabled while the chat isn't running, as in `ContactsList`), your chats (`rows.yourChats`, the loaded ones), then the results, 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#L220): `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`, 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#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.
**De-duplication against the contacts list.** Before the contacts the list builder computes [`ContactsList.shownChats(…)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L375) 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, and the results card draws that list.
**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.
**Empty state.** [`ContactsList`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L286) has `showsEmptyState` (default `true`, [L293](../../Shared/Views/NewChat/NewChatMenuButton.swift#L297)); the sheet passes `!onlineSearch.searching`, so "No filtered contacts" / "No contacts" are omitted while the online search runs. `ContactsList` is drawn only while the results card isn't, so the empty state also shows when a finished search found nothing and no contact matches.
**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.
**Archived contacts.** [`DeletedChats`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L548) passes neither `onSearch` nor `showsEmptyState`: its search field keeps the `.return` label with a no-op submit, and its empty states are unchanged.
---
@@ -138,8 +138,7 @@ data class DirectoryResult(
data class OnlineSearchRows(
val yourChats: List<ChatId>,
val results: List<DirectoryResult>,
val pageInChats: Boolean,
val footer: DirectoryFooter?,
) {
val isEmpty: Boolean get() = yourChats.isEmpty() && results.isEmpty() && !pageInChats && footer == null
val isEmpty: Boolean get() = yourChats.isEmpty() && results.isEmpty() && footer == null
}
@@ -514,9 +514,6 @@ private fun LazyListScope.directoryRows(rows: OnlineSearchRows, onlineSearch: On
items(rows.results, key = { it.id }) { result ->
DirectoryResultNavLink(result, imageSize = 72.dp * fontSizeSqrtMultiplier, close = null, cleanup = { withApi { onlineSearch.refreshStates() } })
}
if (rows.pageInChats) {
item { DirectoryPageInChatsRow() }
}
when (rows.footer) {
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
DirectoryFooter.LoadMore -> item { DirectoryLoadMoreRow(onlineSearch) }
@@ -47,7 +47,6 @@ class OnlineSearchModel {
private set
private var cursor by mutableStateOf<JsonObject?>(null)
private var lastPageIds by mutableStateOf<Set<String>>(emptySet())
private var searchedText = ""
// bumped on every reset, so a reply that arrives too late cannot refill a list already cleared
private var generation = 0
@@ -62,7 +61,6 @@ class OnlineSearchModel {
directory = emptyList()
moreCount = null
cursor = null
lastPageIds = emptySet()
loading = false
failed = false
searched = false
@@ -82,7 +80,6 @@ class OnlineSearchModel {
directory = emptyList()
moreCount = null
cursor = null
lastPageIds = emptySet()
failed = false
searched = false
} else {
@@ -122,16 +119,7 @@ class OnlineSearchModel {
results.add(result)
}
}
val lastPage = directory.filter { lastPageIds.contains(it.id) }
return OnlineSearchRows(
yourChats = yourChats,
results = results,
pageInChats = lastPage.isNotEmpty() && lastPage.all {
val state = it.state
state is DirectoryResultState.YourChat && shownChatIds.contains(state.chatId)
},
footer = footer
)
return OnlineSearchRows(yourChats = yourChats, results = results, footer = footer)
}
// a chat opened from a result exists only once its connection flow ends, so results look again then
@@ -176,7 +164,6 @@ class OnlineSearchModel {
cursor = r.cursor
moreCount = r.moreCount
directory = if (append) directory + page else page
lastPageIds = page.map { it.id }.toSet()
}
private fun updateSpinner() {
@@ -267,17 +254,6 @@ fun DirectorySearchRow(entry: DirectorySearchEntry, imageSize: Dp, state: Direct
}
}
@Composable
fun DirectoryPageInChatsRow() {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)
) {
Text(stringResource(MR.strings.directory_search_page_in_chats), color = MaterialTheme.colors.secondary)
}
}
@Composable
fun DirectoryLoadMoreRow(onlineSearch: OnlineSearchModel) {
val moreCount = onlineSearch.moreCount
@@ -291,7 +267,7 @@ fun DirectoryLoadMoreRow(onlineSearch: OnlineSearchModel) {
.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = color)
Icon(painterResource(MR.images.ic_zoom_in), null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = color)
Text(
if (moreCount != null && moreCount > 0) String.format(generalGetString(MR.strings.directory_search_load_more_n_left), moreCount)
else stringResource(MR.strings.directory_search_load_more),
@@ -189,16 +189,12 @@ private fun ModalData.NewChatSheetLayout(
// emitted in this order, the load row is the row farthest from the field in both layouts
fun LazyListScope.onlineSearchSection(rows: OnlineSearchRows) {
if (!rows.isEmpty) {
item { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) }
items(rows.yourChats.mapNotNull { chatModel.getChat(it) }, key = { it.remoteHostId to it.id }) { chat ->
ContactListNavLinkView(chat, remember { mutableStateOf(false) }, showDeletedChatIcon = true)
}
items(rows.results, key = { it.id }) { result ->
DirectoryResultNavLink(result, imageSize = 42.dp, previewModifier = Modifier.padding(horizontal = DEFAULT_PADDING_HALF), close = close, cleanup = null)
}
if (rows.pageInChats) {
item { DirectoryPageInChatsRow() }
}
when (rows.footer) {
DirectoryFooter.Retry -> item { DirectoryRetryRow(onlineSearch) }
DirectoryFooter.LoadMore -> item { DirectoryLoadMoreRow(onlineSearch) }
@@ -523,7 +523,6 @@
<string name="directory_search_load_more">Load more</string>
<string name="directory_search_load_more_n_left">Load more · %1$d left</string>
<string name="directory_search_failed_retry">Search failed, tap to retry</string>
<string name="directory_search_page_in_chats">All results on this page are already in your chats</string>
<string name="searching_directory">Searching directory…</string>
<!-- ChatView.kt -->
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 96 960 960" width="22"><path d="M772.625 909.63 532.66 669.5Q503 696 463.041 710t-83.883 14q-106.761 0-180.709-73.977-73.949-73.977-73.949-178.5T198.204 293q73.704-74 179-74T556 293.007q73.5 74.006 73.5 178.722 0 42.271-14 82.021T574 628.5L816 868q8.5 8.186 8.5 20.093t-9.413 21.32Q806 918.5 793.278 918.5q-12.722 0-20.653-8.87ZM378 666.5q80.75 0 137.375-57.059t56.625-138q0-80.941-56.625-137.941t-137.625-57q-81.833 0-138.792 57.059Q182 390.618 182 471.559T239 609.5q57 57 139 57ZM349.5 443v-53.5q0-12 8.25-20t20.25-8q12 0 20.25 8t8.25 20V443H460q12 0 20 8.25t8 20.25q0 12-8 20.25T460 500h-53.5v53.5q0 12-8.25 20t-20.25 8q-12 0-20.25-8t-8.25-20V500H296q-12 0-20-8.25T268 471.5q0-12 8-20.25T296 443h53.5Z"/></svg>

After

Width:  |  Height:  |  Size: 777 B

+2 -2
View File
@@ -301,9 +301,9 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C
### RULE-22: Nothing Is Shown Twice
**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, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (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).
**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 page adds no row and no note. Limits: the store-only lookup finds a group only when it was prepared from that short link, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (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.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`.
**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`; `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()`, `OnlineSearchModel.request()`
@@ -69,7 +69,6 @@ Searching beyond the user's own chats happens only when the user asks, and the r
| 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. If the link's signature was checked or its name also leads to another chat, the question shows first, as for a pasted link, with the signature result or the other chat's button. 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: 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 the listed short link (with a full link from an older app, 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.
+1 -1
View File
@@ -92,7 +92,7 @@ The search field of the New chat sheet filters "Your contacts" and offers the sa
| "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 |
| Results card | One group of rows without a header, continuing the contacts that match with no divider between them. 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. 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 results card |
+28 -31
View File
@@ -374,20 +374,20 @@ 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. 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)).
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#L863-L966) 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#L907-L912)). 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)` | [`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)` |
| 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#L985-L989) | `searchDirectory(text)` |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L186-L201)) | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L985-L989) | `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. 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 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`.
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#L981-L982), `searchShowingSimplexLink`), and both go through [`confirmDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L326-L332), 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#L334-L356) (`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#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:
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#L1208-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) |
|-------------|-----------------------------------|------------------------------|------|
@@ -402,7 +402,7 @@ 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#L34-L196)
**Location:** [`OnlineSearchModel`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L34-L183)
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.
@@ -414,32 +414,31 @@ A plain class holding Compose state, owned by `ChatListView` as `remember { Onli
| `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 |
| `lastPageIds` (private) | The ids of the results the last successful request added (none when every entry of the page was already shown); cleared with the directory state, kept by a failed request. Snapshot state, because `rows` reads it |
| `generation` (private) | Bumped by `reset()` and `cancelRequest()`; a reply whose captured generation differs never writes model state |
The public state has private setters; `searchedText`, `generation` and `spinnerShown` are plain fields.
| Computed | Definition |
|----------|------------|
| [`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 |
| [`searching`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L55) | `loading` |
| [`showResults`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L56) | `loading \|\| searched` |
| [`footer`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L57) | `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#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 |
| [`reset()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L59-L68) | Clears all results, `moreCount` and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L71-L74) | 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#L76-L90) | 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#L92-L95) | The load row: the next page, appended |
| [`retry()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L98-L104) | 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#L106-L123) | 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#L126-L136) | 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 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.
**Spinner.** The private setter of `loading` calls [`updateSpinner()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L169-L182) 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#L607-L613)) 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#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.
[`request(append)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L143-L167) 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.
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".
@@ -447,7 +446,7 @@ The directory's response (`DRSearchResults`, apps/simplex-directory-service/src/
[`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.
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:
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#L138-L141)) 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 |
|-------|------|----------|
@@ -459,14 +458,13 @@ The link-type check makes this lookup store-only: the core plans contact-mode li
### De-duplication
[`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:
[`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L106-L123) returns an [`OnlineSearchRows`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L138-L144)`(yourChats, results, footer)` at composition time, so pages added by "Load more" are handled too:
- `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.
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.
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. A page whose results are all chats the list shows adds no row and no note. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row.
[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), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row. For a group joined with a full link (an older app) 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 tests "short link without resolving: prepared and /c-joined groups known, invited member not found" and "short link without resolving: pending /c join is connecting" (tests/ChatTests/Groups.hs) pin this. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-without-the-listed-short-link-still-appear-as-results-with-open).
@@ -474,25 +472,24 @@ So nothing is shown twice: a result that is a chat the list shows is hidden, a c
| Where | Rows | Empty state |
|-------|------|-------------|
| [`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` |
| [`ChatList`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1035-L1206) | `rows(shownChatIds)` with the ids of the shown chats ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1077)); after the local chats and before `ChatListFeatureCards`, [`directoryRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L510-L523) | `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#L1184-L1189)); 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 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.
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#L1203-L1205)) clears the search text, which resets the model; the iOS app keeps the search there.
`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.
`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), 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 |
|-----|------|-----|
| 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() }` |
| Result | [`DirectoryResultNavLink`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L297-L318)`(result, imageSize = 72.dp * fontSizeSqrtMultiplier, close = null, cleanup = { withApi { refreshStates() } })`: `ChatListNavLinkLayout` with [`DirectorySearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L204-L254) 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#L321-L324) (see Opening a result) |
| Load more | [`DirectoryLoadMoreRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L258-L276): `ic_zoom_in` (the magnifier with a plus, as iOS's `plus.magnifyingglass`) 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#L280-L292): `ic_refresh` and "Search failed, tap to retry" | `withApi { retry() }` |
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.
### 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-L282)): `Confirm` shows the "Open channel?" / "Open group?" alert ([`showPrepareGroupAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L769)), as for a pasted link, and `Open` calls [`prepareAndOpenGroup`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L809-L836) at once, the function the alert's confirm also calls: `apiPrepareGroup`, the channel's relay hostnames, `addChat`, `openChat_`, then `cleanup`. `Open` applies only when the plan has no `ownerVerification` (verified or failed) and no other name (`connectOtherLink`); with either, the branch shows the alert as `Confirm` does, because only the alert shows the signature line and the other name's button. Open plans a link, not a name, without a signature, so its plan has neither, and 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.
[`openDirectoryResult(result, close, cleanup)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L321-L324) 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-L282)): `Confirm` shows the "Open channel?" / "Open group?" alert ([`showPrepareGroupAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L769)), as for a pasted link, and `Open` calls [`prepareAndOpenGroup`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L809-L836) at once, the function the alert's confirm also calls: `apiPrepareGroup`, the channel's relay hostnames, `addChat`, `openChat_`, then `cleanup`. `Open` applies only when the plan has no `ownerVerification` (verified or failed) and no other name (`connectOtherLink`); with either, the branch shows the alert as `Confirm` does, because only the alert shows the signature line and the other name's button. Open plans a link, not a name, without a signature, so its plan has neither, and 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.
+10 -10
View File
@@ -110,11 +110,11 @@ When onboarding is complete:
### URL Deep Link
On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L882), which re-queues it while no profile is active.
On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L878), which re-queues it while no profile is active.
On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L55): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.<scheme>` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing.
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L904) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L911) dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen 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 screen composes), which core would refuse, the screen 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 back closes the screen without cancelling anything; if the redemption then succeeds with the screen 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()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string.
App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L900) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L907) dispatches on its path. A badge link (`simplexchat:/badge/code/<code>`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen 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 screen composes), which core would refuse, the screen 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 back closes the screen without cancelling anything; if the redemption then succeeds with the screen 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()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string.
### Overlay Layers (bottom of MainScreen)
@@ -396,21 +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-L509)
**Location:** [`NewChatSheetLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L110-L505)
`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#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 |
| Search key | [`ContactsSearchBar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L525-L627) with `onSearch` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L533)), 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#L208-L212): `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#L357-L361)) and `NonOneHandLazyColumn` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L455-L459)) | 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#L362-L365), [source](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L460-L463)) | 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-L204) | When `!rows.isEmpty`: without a header or a divider, so the results continue the contacts as one group (iOS draws the contacts in its results card), your chats (`rows.yourChats`, the loaded ones, keyed `remoteHostId to id`), the results (keyed by `id`) 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#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.
Both triggers do nothing for empty text or a pasted link ([`searchTrimmed()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L206), `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 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#L298-L312) 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#L294-L308) 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#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.
**Archived contacts.** [`DeletedContactsView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt#L720-L834) 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.