ios: show directory results you already have as chats, and say when a page adds none

A directory result whose link resolves to a chat the screen doesn't list is drawn with that screen's chat row, which opens it, instead of "Join group" / "Join channel": the chat list's row there, a contact-style row in New chat (ContactListNavLink gets a group case). When every result of the last page is a chat already shown, the Directory part says "All results on this page are already in your chats" above "Show more", instead of showing the header and "Show more" alone.

Run: 008-dirsearch-known-chats, slice 2
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-19 09:08:39 +00:00
1 parent 0939975778
commit 9313a490bc
11 files changed
+138 -73

No files matched your search

+2 -1
View File
@@ -180,7 +180,8 @@ struct DirectoryResult: Identifiable {
struct OnlineSearchRows {
var names: [NameSearchResult]
var directory: [DirectoryResult]
var pageInChats: Bool
var footer: DirectoryFooter?
var isEmpty: Bool { names.isEmpty && directory.isEmpty && footer == nil }
var isEmpty: Bool { names.isEmpty && directory.isEmpty && !pageInChats && footer == nil }
}
@@ -497,17 +497,22 @@ struct ChatListView: View {
}
@ViewBuilder private func directoryRows(_ rows: OnlineSearchRows) -> some View {
if !rows.directory.isEmpty || rows.footer != nil {
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
searchPart("Directory") {
ForEach(rows.directory) { result in
DirectorySearchRow(entry: result.entry)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
chatListNavLink(chat)
} else {
DirectorySearchRow(entry: result.entry)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
}
}
if rows.pageInChats { directoryPageInChatsRow() }
switch rows.footer {
case .retry: directoryRetryRow()
case .showMore: directoryShowMoreRow()
@@ -686,6 +691,12 @@ 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 directoryShowMoreRow() -> some View {
DirectoryShowMoreRow(onlineSearch: onlineSearch)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
@@ -25,6 +25,7 @@ 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,6 +40,7 @@ class OnlineSearchModel: ObservableObject {
names = []
directory = []
cursor = nil
lastPageIds = []
loading = false
resolvingNames = false
failed = false
@@ -76,6 +78,7 @@ class OnlineSearchModel: ObservableObject {
if text == searchedText {
directory = []
cursor = nil
lastPageIds = []
failed = false
searched = false
} else {
@@ -105,11 +108,16 @@ class OnlineSearchModel: ObservableObject {
func isAmong(_ ids: Set<ChatId>, _ id: ChatId?) -> Bool {
id.map { ids.contains($0) } ?? false
}
let lastPage = directory.filter { lastPageIds.contains($0.id) }
return OnlineSearchRows(
names: names.filter { !isAmong(shownChatIds, $0.localChatId) },
directory: directory.filter {
!isAmong(shownChatIds, $0.localChatId) && !isAmong(nameChatIds, $0.localChatId) && !nameLinks.contains($0.entry.link)
},
// a result hidden only because a name resolved to its link is on screen, but not one of your chats
pageInChats: !lastPage.isEmpty && lastPage.allSatisfy {
isAmong(shownChatIds, $0.localChatId) || isAmong(nameChatIds, $0.localChatId)
},
footer: footer
)
}
@@ -181,6 +189,7 @@ class OnlineSearchModel: ObservableObject {
}
cursor = r.cursor
directory = append ? directory + page : page
lastPageIds = Set(page.map { $0.id })
}
private func updateSpinner() {
@@ -301,6 +310,15 @@ struct NameSearchRow: 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 DirectoryShowMoreRow: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var onlineSearch: OnlineSearchModel
@@ -34,6 +34,8 @@ struct ContactListNavLink: View {
}
case let .contactRequest(contactRequest):
contactRequestNavLink(contactRequest)
case let .group(groupInfo, _):
groupNavLink(groupInfo)
default:
EmptyView()
}
@@ -165,16 +167,34 @@ struct ContactListNavLink: View {
}
}
func contactPreview(_ contact: Contact, titleColor: Color) -> some View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
func groupNavLink(_ groupInfo: GroupInfo) -> some View {
Button {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
} label: {
chatPreview(
ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: 30),
NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground), chat.chatInfo.nameBadge)
.lineLimit(1)
)
}
}
previewTitle(contact, titleColor: titleColor)
func contactPreview(_ contact: Contact, titleColor: Color) -> some View {
chatPreview(ProfileImage(imageStr: contact.image, size: 30), previewTitle(contact, titleColor: titleColor))
}
private func chatPreview(_ image: some View, _ title: some View) -> some View {
HStack{
image
title
Spacer()
HStack {
if showDeletedChatIcon && contact.chatDeleted {
if showDeletedChatIcon && chat.chatInfo.chatDeleted {
Image(systemName: "archivebox")
.resizable()
.scaledToFit()
@@ -187,7 +207,7 @@ struct ContactListNavLink: View {
.frame(width: 18, height: 18)
.foregroundColor(.secondary.opacity(0.65))
}
if contact.contactConnIncognito {
if chat.chatInfo.incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
@@ -206,15 +206,20 @@ struct NewChatSheet: View {
onlineSearchHeader("Names")
}
}
if !rows.directory.isEmpty || rows.footer != nil {
if !rows.directory.isEmpty || rows.pageInChats || rows.footer != nil {
Section {
ForEach(rows.directory) { result in
DirectorySearchRow(entry: result.entry)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: true, cleanup: nil)
}
if let chat = result.localChatId.flatMap({ chatModel.getChat($0) }) {
ContactListNavLink(chat: chat, showDeletedChatIcon: true)
} else {
DirectorySearchRow(entry: result.entry)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: true, cleanup: nil)
}
}
}
if rows.pageInChats { DirectoryPageInChatsRow() }
switch rows.footer {
case .retry: DirectoryRetryRow(onlineSearch: onlineSearch)
case .showMore: DirectoryShowMoreRow(onlineSearch: onlineSearch)
+3 -1
View File
@@ -66,7 +66,9 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages
### GAP: Groups joined through another link still appear as directory results
**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
A directory entry is matched to a local chat by a store-only lookup of its link. A regular group's entry carries the directory's own group link (short link only), so a group the user joined through a different link, through the directory's full link, or by invitation is not found and is shown again in the Directory part. Tapping it goes through `planAndConnect`, which handles the known group.
A directory entry is matched to a local chat by a store-only lookup of its link. An entry the app finds this way opens as a chat: the screen's chat row is drawn in place of the entry. A regular group's entry carries the directory's own group link (short link only), so a group the user joined through a different link, through the directory's full link, or by invitation is not found and still shows as a directory entry with "Join group". Tapping it goes through `planAndConnect`, which handles the known group. Accepted.
Related: the note "All results on this page are already in your chats" is not shown when a page's only results are hidden because a name search resolved to the same link (that row is on screen, but not one of the user's chats); the Directory part then shows only "Show more" when there are more pages, and is not shown otherwise.
**REC:** Have the directory send a stable group identifier the core can match locally, or let the core match a directory short link against groups joined through the corresponding full link.
+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 name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. Limit: a regular group joined through another link (or the directory's full link) is not found by the lookup and still appears as a directory result.
**Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.rows(shownChatIds:)`); `DirectorySearch.swift` (`planLocalChatId`, `NameSearchResult.resolvedLink`).
**Rule:** A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. A directory result whose link refers to a chat the screen doesn't list MUST be drawn as that screen's chat row, which opens the chat, never as "Join …". When every result of the last page loaded is hidden as a chat the user has, the Directory part MUST say so ("All results on this page are already in your chats"); a result hidden only because a name was resolved through its link does not count. Limit: a regular group joined through another link, the directory's full link or by invitation is not found by the lookup and still appears as a directory result with "Join group".
**Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.rows(shownChatIds:)`, `pageInChats`); `DirectorySearch.swift` (`planLocalChatId`, `NameSearchResult.resolvedLink`); `ChatListView.swift` (`directoryRows`, `chatListNavLink`); `NewChatMenuButton.swift` (`onlineSearchSections`); `ContactListNavLink.swift` (`groupNavLink`).
**Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search)
### RULE-28: Consent before the first online search
+4 -2
View File
@@ -46,9 +46,11 @@ Searching beyond the user's own chats happens only when the user asks, and the r
| "Connect to …" row | Shown for a SimpleX name, as before; hidden once the online search found anything for that name |
| First-search alert | Before the first online search: "Search in Directory?" — the text goes to SimpleX Directory, and a SimpleX name also to the user's server that resolves names; no contact is created and the profile is not sent. Cancel / Search / Search and don't show again ("Reset all hints" brings it back) |
| Names part | Below the local chats, headed "Names": avatar, profile name, the SimpleX name in parentheses, description. Tapping connects (or opens the chat if it is already known) |
| Directory part | Below the Names part, headed "Directory": avatar, `#` name, the directory's SimpleX name, description, member or subscriber count, and "Join channel" / "Join group". Tapping connects; "Show more" loads the next page; a failed request shows "Search failed, tap to retry" |
| Directory part | Below the Names part, headed "Directory": avatar, `#` name, the directory's SimpleX name, description, member or subscriber count, and "Join channel" / "Join group". Tapping connects; "Show more" loads the next page; a failed request shows "Search failed, tap to retry". An entry for a group or channel the user already has, that the list above doesn't show (its name doesn't match the typed text), is shown as the regular chat row and opens the chat, never "Join …". When every result of the last page loaded is a chat already in the list or found by name, the part shows "All results on this page are already in your chats" (secondary colour), followed by "Show more" when there are more pages; nothing loads more pages automatically |
A result is not shown twice: a name or directory entry for a chat already shown in the list is hidden, and a directory entry is hidden when the name search found the same channel (matched by its link). A group the user joined through a link other than the directory's still appears in the Directory part. Directory entries that are not group or channel links are ignored. Looking up a name never shows an error; a name that is not found simply gives no row, and "Connect to …" stays.
A result is not shown twice: a name or directory entry for a chat already shown in the list is hidden, and a directory entry is hidden when the name search found the same channel (matched by its link). A group the user joined through a link other than the directory's, or by invitation, is not recognised: it still appears in the Directory part with "Join group". Directory entries that are not group or channel links are ignored. Looking up a name never shows an error; a name that is not found simply gives no row, and "Connect to …" stays.
With the one-hand UI the whole list is mirrored: going up from the search field at the bottom come the local chats, then Names, then Directory, each part's header above its rows. This order is intended, so the user's own chats stay nearest the field.
With no conversations yet (onboarding), the online results replace the onboarding pages while a search runs or has results. With conversations, while an online search runs or after it has run, even one that found nothing, the list hides the banner (Wefunder or badges), the one-hand UI card and the "New 1-time link" / "Paste link / Scan" cards; filtering the chats by typing keeps them, and they come back when the search text is cleared (× or Cancel).
+1 -1
View File
@@ -78,7 +78,7 @@ 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; hidden once the online search found anything for that name |
| "Search in Directory" row | Below the field for non-empty text that is not a SimpleX link; searches SimpleX Directory |
| Keyboard Search key | Searches the directory and, for a SimpleX name, resolves the name online |
| Names and Directory sections | After "Your Contacts", with headers in the same style. Tapping a result connects and closes the sheet, as other New chat actions do. A result for a contact already listed in "Your Contacts" is not repeated |
| Names and Directory sections | After "Your Contacts", with headers in the same style. Tapping a result connects and closes the sheet, as other New chat actions do. A result for a contact already listed in "Your Contacts" is not repeated. A directory entry for a group or channel the user already has is shown as a contact-style chat row (avatar, name, favourite star, incognito mask) that opens the chat and closes the sheet, never "Join …". When every result of the last page loaded is a chat the user has, the Directory section shows "All results on this page are already in your chats", followed by "Show more" when there are more pages |
| Contacts empty state | "No filtered contacts" / "No contacts" are not shown while the online search runs or shows results |
The first online search asks for consent with the same alert as on the chat list. Results disappear when the text changes or the user switches profile. Archived contacts keep their own search field without the Search key or online results.
+46 -40
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#L750)
- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L761)
- 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`
- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView`, drawn with its list modifiers by [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L676), which also draws known chats among the Directory results (see [Online Search](#11-online-search))
- 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#L188) | 188 | Main view body |
| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L750) | 750 | Applies active filter and search to chat list |
| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L793) | 793 | Normalizes search text for comparison |
| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L724) | 724 | Renders unread count circle badge |
| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L744) | 744 | Stops any playing voice message |
| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L761) | 761 | Applies active filter and search to chat list |
| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L804) | 804 | Normalizes search text for comparison |
| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L735) | 735 | Renders unread count circle badge |
| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L755) | 755 | Stops any playing voice message |
---
@@ -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#L1367) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1161) struct.
Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1378) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L1172) 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#L1346) | ChatListView.swift | 1346 | Applies a filter and persists selection |
| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1357) | ChatListView.swift | 1346 | 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#L857)
**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L868)
### 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#L750) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L793)
- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L761) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L804)
### 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#L546) while no online results are shown ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L549)), 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#L551) while no online results are shown ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L554)), in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` |
| 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#L541) | the dismiss X appears from then on, while there are chats |
| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L546) | the dismiss X appears from then on, while there are chats |
| `DEFAULT_GET_STAKE_BANNER_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,20 +324,20 @@ Tapping the card opens [`GetStakeView`](../../Shared/Views/Onboarding/WhatsNewVi
**Files**: `Shared/Views/ChatList/DirectorySearchView.swift` (model and rows), `Shared/Model/DirectorySearch.swift` (response and result types), `Shared/Views/ChatList/ChatListView.swift` (triggers and rendering)
The search text can be sent online on request: to SimpleX Directory, to find public groups and channels, and, when the text is a SimpleX name, to the user's name-resolving server. Results are shown below the local chats in two parts, Names then Directory. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L857) calls `reset()` on every change of `searchText` ([L946](../../Shared/Views/ChatList/ChatListView.swift#L946)) and of `currentUser?.userId` ([L949](../../Shared/Views/ChatList/ChatListView.swift#L949)). 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, and, when the text is a SimpleX name, to the user's name-resolving server. Results are shown below the local chats in two parts, Names then Directory. They are never saved and live only as long as the search text and the active profile: [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L868) calls `reset()` on every change of `searchText` ([L957](../../Shared/Views/ChatList/ChatListView.swift#L957)) and of `currentUser?.userId` ([L960](../../Shared/Views/ChatList/ChatListView.swift#L960)). The New chat sheet uses the same model and rows (see [Navigation, New Chat Online Search](navigation.md#12-new-chat-online-search)).
### Triggers
| Trigger | Handler | Runs |
|---------|---------|------|
| Keyboard Search key (`.submitLabel(.search)`) | [`runOnlineSearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1057) | `search(text, name: connectNameCandidate)`: the directory's first page, and name resolution when the text is a name candidate |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L202)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1051) | `searchDirectory(text)`: the directory only |
| Keyboard Search key (`.submitLabel(.search)`) | [`runOnlineSearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1068) | `search(text, name: connectNameCandidate)`: the directory's first page, and name resolution when the text is a name candidate |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L211)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1062) | `searchDirectory(text)`: the directory only |
Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link, and both go through [`confirmDirectorySearch()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L333), which runs the search at once when `DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN` is set and otherwise shows the three-action alert ([`showDirectorySearchAlert`](../../Shared/Views/ChatList/DirectorySearchView.swift#L341)): Cancel / Search / Search and don't show again. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The key is in `hintDefaults`, so "Reset all hints" brings the alert back.
Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link, and both go through [`confirmDirectorySearch()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L351), which runs the search at once when `DEFAULT_DIRECTORY_SEARCH_ALERT_SHOWN` is set and otherwise shows the three-action alert ([`showDirectorySearchAlert`](../../Shared/Views/ChatList/DirectorySearchView.swift#L359)): Cancel / Search / Search and don't show again. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The key is in `hintDefaults`, so "Reset all hints" brings the alert back.
The rows above or below the search field follow the toolbar mode:
| Search text | One-hand UI (slot above the field, [L879](../../Shared/Views/ChatList/ChatListView.swift#L879)) | Top bar (below the field, [L930](../../Shared/Views/ChatList/ChatListView.swift#L930)) |
| Search text | One-hand UI (slot above the field, [L890](../../Shared/Views/ChatList/ChatListView.swift#L890)) | Top bar (below the field, [L941](../../Shared/Views/ChatList/ChatListView.swift#L941)) |
|-------------|-----------------------------------|------------------------------|
| Empty | Tags | Tags above; nothing below |
| Plain text | "Search in Directory" | Tags above; "Search in Directory" below |
@@ -345,7 +345,7 @@ The rows above or below the search field follow the toolbar mode:
| Name candidate with name results | "Search in Directory" | Tags above; "Search in Directory" below |
| Pasted SimpleX link | Tags | Tags above; nothing below |
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L200)
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L209)
`@MainActor` `ObservableObject`, owned by `ChatListView` as `@StateObject onlineSearch` and passed to `ChatListSearchBar`.
@@ -358,30 +358,31 @@ The rows above or below the search field follow the toolbar mode:
| `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 \|\| resolvingNames` |
| [`showResults`](../../Shared/Views/ChatList/DirectorySearchView.swift#L34) | `searching \|\| searched \|\| !names.isEmpty` |
| [`footer`](../../Shared/Views/ChatList/DirectorySearchView.swift#L35) | `DirectoryFooter.retry` when `failed`, else `.showMore` when a cursor exists, else nil |
| [`searching`](../../Shared/Views/ChatList/DirectorySearchView.swift#L34) | `loading \|\| resolvingNames` |
| [`showResults`](../../Shared/Views/ChatList/DirectorySearchView.swift#L35) | `searching \|\| searched \|\| !names.isEmpty` |
| [`footer`](../../Shared/Views/ChatList/DirectorySearchView.swift#L36) | `DirectoryFooter.retry` when `failed`, else `.showMore` when a cursor exists, else nil |
| Method | Description |
|--------|-------------|
| [`reset()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L37) | Clears all results and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L50) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay |
| [`search(_:name:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L56) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`async let`, with the generation captured before the child task starts) |
| [`searchDirectory(_:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L73) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page |
| [`loadMore()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L88) | "Show more": the next page, appended |
| [`retry()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L94) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L102) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshLocalChatIds()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L118) | The `cleanup` of a connection started from a result row, run when its flow ends: looks the local chat up again, store-only, for every row that has none (a name row by its target, a directory row by its link), unless the generation changed. A chat opened from a result is created during that flow, so its row is then hidden like any shown chat |
| [`reset()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L38) | Clears all results and flags, bumps `generation` (text change, profile switch) |
| [`cancelRequest()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L52) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay |
| [`search(_:name:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L58) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`async let`, with the generation captured before the child task starts) |
| [`searchDirectory(_:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L75) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page |
| [`loadMore()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L91) | "Show more": the next page, appended |
| [`retry()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L97) | The retry row: resumes the page when a cursor exists, else repeats the first page; no reset |
| [`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L105) | The rows to render, given the ids of the local chats shown (see De-duplication) |
| [`refreshLocalChatIds()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L126) | The `cleanup` of a connection started from a result row, run when its flow ends: looks the local chat up again, store-only, for every row that has none (a name row by its target, a directory row by its link), unless the generation changed. A chat opened from a result is created during that flow, so its row is then hidden like any shown chat, or drawn as a chat row when the list doesn't show it |
**Spinner.** `loading` and `resolvingNames` have a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L186), 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` and `resolvingNames` have a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L195), which starts the "Searching directory…" progress with owner `.directorySearch` when `searching` becomes true and stops it when it becomes false (`spinnerShown` keeps the last state, so repeated writes do nothing). The search field shows it as a spinner once it has run for a second. The spinner is shared with connections: [`ConnectProgressOwner`](../../Shared/Model/ChatModel.swift#L303) records whose it is, [`startConnectProgress`](../../Shared/Model/ChatModel.swift#L316) does not let a search take it from a running connection, and [`stopConnectProgress`](../../Shared/Model/ChatModel.swift#L327) ignores a stop from another owner. Tapping a result calls `planAndConnect`, whose `cancelConnectProgress()` ([L336](../../Shared/Model/ChatModel.swift#L336)) cancels the search through its `onCancel` before the connection takes the spinner.
### Directory requests
[`request(append:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L162) calls [`apiSearchDirectory`](../../Shared/Model/SimpleXAPI.swift#L1044), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../Shared/Model/DirectorySearch.swift#L14) with a [10-second timeout](../../Shared/Model/DirectorySearch.swift#L17) and the request built by [`directorySearchRequestJSON`](../../Shared/Model/DirectorySearch.swift#L84) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../Shared/Model/DirectorySearch.swift#L92) accepts only a `searchResults` response and decodes its entries one at a time, so an entry the app cannot decode is dropped and the rest of the page is kept.
[`request(append:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L170) calls [`apiSearchDirectory`](../../Shared/Model/SimpleXAPI.swift#L1044), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../Shared/Model/DirectorySearch.swift#L14) with a [10-second timeout](../../Shared/Model/DirectorySearch.swift#L17) and the request built by [`directorySearchRequestJSON`](../../Shared/Model/DirectorySearch.swift#L84) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../Shared/Model/DirectorySearch.swift#L92) accepts only a `searchResults` response and decodes its entries one at a time, so an entry the app cannot decode is dropped and the rest of the page is kept.
[`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.
@@ -389,35 +390,40 @@ For each new entry of a page (not already shown, not repeated in the page), the
### Name resolution
[`resolveNames(_:generation:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L146) resolves `[name]` for a candidate starting with `@` or `#`, else `["@" + name, "#" + name]` (the candidate comes from `nameSearchCandidate`, as on the typing path). Each target is planned with `apiConnectPlan(connLink: target, resolveMode: .unknown, onFailure: .log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult.init?(target:result:)`](../../Shared/Model/DirectorySearch.swift#L138) maps the plan with one exhaustive switch: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName.shortStr`, `localChatId` comes from `planLocalChatId`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; `inProgress: BoxedValue(false)` also suppresses the network retry alert.
[`resolveNames(_:generation:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L154) resolves `[name]` for a candidate starting with `@` or `#`, else `["@" + name, "#" + name]` (the candidate comes from `nameSearchCandidate`, as on the typing path). Each target is planned with `apiConnectPlan(connLink: target, resolveMode: .unknown, onFailure: .log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult.init?(target:result:)`](../../Shared/Model/DirectorySearch.swift#L138) maps the plan with one exhaustive switch: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName.shortStr`, `localChatId` comes from `planLocalChatId`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; `inProgress: BoxedValue(false)` also suppresses the network retry alert.
### De-duplication
[`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L102) returns an [`OnlineSearchRows`](../../Shared/Model/DirectorySearch.swift#L180) at render time, so pages added by "Show more" are filtered too:
[`rows(shownChatIds:)`](../../Shared/Views/ChatList/DirectorySearchView.swift#L105) returns an [`OnlineSearchRows`](../../Shared/Model/DirectorySearch.swift#L180) at render time, so pages added by "Show more" are filtered too:
- a name result is hidden when its `localChatId` is among the shown local chats;
- a directory result is hidden when its `localChatId` is among the shown local chats or any name result's `localChatId`, or when its `entry.link` equals any name result's `resolvedLink` (all name results, shown or not);
- `pageInChats` is true when the last page's results (`lastPageIds`) are not empty and each one's `localChatId` is among the shown local chats or the name results' chat ids. A result hidden only because a name result resolved to its link is not a chat the user has, so it does not count, and the part keeps its usual look;
- `footer` passes through.
Only store-derived chat ids and the link the core resolved can hide a row, never a field the directory or a name's owner wrote. A mismatch in link encoding can only produce a duplicate row.
[GAP] A regular group the user joined through a different link, or through the directory's full link, is not found by the store lookup and still appears as a Directory row; tapping it goes through `planAndConnect`.
A directory result that is a chat the user has but that the screen doesn't list (the local filter doesn't match it) stays in `rows.directory` and is drawn as a chat row (see Rendering). When its chat id is not among the loaded chats (`getChat` returns nil), the Directory row is drawn as before, and tapping it goes through `planAndConnect`, which opens the known chat.
[GAP] A regular group the user joined through a different link, through the directory's full link, or by invitation, is not found by the store lookup and still appears as a Directory row with "Join group"; tapping it goes through `planAndConnect`.
### Rendering
| Where | Rows | Empty state |
|-------|------|-------------|
| [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L546) | `rows(shownChatIds:)` with the ids of the shown chats ([L548](../../Shared/Views/ChatList/ChatListView.swift#L548)); after the local chats, [`nameRows`](../../Shared/Views/ChatList/ChatListView.swift#L483) then [`directoryRows`](../../Shared/Views/ChatList/ChatListView.swift#L499). While `showResults` ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L549) 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` ([L681](../../Shared/Views/ChatList/ChatListView.swift#L681)) |
| [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L551) | `rows(shownChatIds:)` with the ids of the shown chats ([L553](../../Shared/Views/ChatList/ChatListView.swift#L553)); after the local chats, [`nameRows`](../../Shared/Views/ChatList/ChatListView.swift#L483) then [`directoryRows`](../../Shared/Views/ChatList/ChatListView.swift#L499). While `showResults` ([`showsCards`](../../Shared/Views/ChatList/ChatListView.swift#L554) false), the list leaves out `OneHandUICard`, the banner slot and `ConnectBannerCard`; they return when the search text is cleared, which resets the model | `noChatsView()` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([L668](../../Shared/Views/ChatList/ChatListView.swift#L668)) |
| Onboarding branch of [`chatList`](../../Shared/Views/ChatList/ChatListView.swift#L434) | While `showResults`, a list of the same parts with `shownChatIds: []` ([L449](../../Shared/Views/ChatList/ChatListView.swift#L449)) in place of the onboarding pages | A centred "No chats found" row when not searching and `rows.isEmpty` |
Each part renders only when it has rows: the Names part when `rows.names` is not empty, the Directory part when `rows.directory` is not empty or `rows.footer` is set. Each has a header row ([`searchPartHeader`](../../Shared/Views/ChatList/ChatListView.swift#L531): callout font, secondary colour, `.isHeader` trait, no separator) rather than a sticky `Section` header, since the one-hand list is flipped. [`searchPart`](../../Shared/Views/ChatList/ChatListView.swift#L521) places it before the part's rows with the top bar and after them with the one-hand UI, so it is drawn above them in both layouts, the one-hand list being flipped. Every row carries the per-row flip and a clear background.
Each part renders only when it has rows: the Names part when `rows.names` is not empty, the Directory part when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set. Each has a header row ([`searchPartHeader`](../../Shared/Views/ChatList/ChatListView.swift#L536): callout font, secondary colour, `.isHeader` trait, no separator) rather than a sticky `Section` header, since the one-hand list is flipped. [`searchPart`](../../Shared/Views/ChatList/ChatListView.swift#L526) places it before the part's rows with the top bar and after them with the one-hand UI, so it is drawn above them in both layouts, the one-hand list being flipped. Every row carries the per-row flip and a clear background.
| Row | View | Tap |
|-----|------|-----|
| Name | [`NameSearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L277): avatar, profile name in bold, the SimpleX name in parentheses (secondary), description (2 lines) | `planAndConnect(result.target, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Directory entry | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L224): avatar, `#` + name, the directory's SimpleX name claim as plain text, description (2 lines), member or subscriber count, and the verb "Join channel" / "Join group" (one line, fixed size) | `planAndConnect(result.entry.link, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Retry | [`DirectoryRetryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L319) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L695): "Search failed, tap to retry" | `retry()` |
| Show more | [`DirectoryShowMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L304) via [`directoryShowMoreRow()`](../../Shared/Views/ChatList/ChatListView.swift#L689): "Show more", disabled and in the secondary colour while `loading` | `loadMore()` |
| Name | [`NameSearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L286): avatar, profile name in bold, the SimpleX name in parentheses (secondary), description (2 lines) | `planAndConnect(result.target, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Directory entry | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L233): avatar, `#` + name, the directory's SimpleX name claim as plain text, description (2 lines), member or subscriber count, and the verb "Join channel" / "Join group" (one line, fixed size) | `planAndConnect(result.entry.link, dismiss: false)`, `cleanup`: `refreshLocalChatIds()` |
| Known chat | When `result.localChatId` names a loaded chat (`chatModel.getChat`), in place of the directory entry: the chat list's own row, [`chatListNavLink(_:)`](../../Shared/Views/ChatList/ChatListView.swift#L676) (`ChatListNavLink` with the local chats' modifiers) | as a local chat: opens it |
| Note | [`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L313) via [`directoryPageInChatsRow()`](../../Shared/Views/ChatList/ChatListView.swift#L694), after the directory rows when `rows.pageInChats`: "All results on this page are already in your chats", secondary colour | none |
| Retry | [`DirectoryRetryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L337) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L706): "Search failed, tap to retry" | `retry()` |
| Show more | [`DirectoryShowMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L322) via [`directoryShowMoreRow()`](../../Shared/Views/ChatList/ChatListView.swift#L700): "Show more", disabled and in the secondary colour while `loading` | `loadMore()` |
A name or directory row tap first sets `searchFocussed = false`. Text from the directory and from name owners is rendered with `Text(String)` / `Text(verbatim:)` (never parsed as markdown) with line limits.
+6 -6
View File
@@ -365,18 +365,18 @@ For channels (`groupInfo.useRelays`): the title is "Open channel"; for groups, "
| Element | Where | Behavior |
|---------|-------|----------|
| Search key | [`ContactsListSearchBar`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L435) with `onSearch` ([L445](../../Shared/Views/NewChat/NewChatMenuButton.swift#L445)) | `.submitLabel(.search)` and `.onSubmit` run [`runOnlineSearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L245): `search(text, name: connectNameCandidate)` |
| Search key | [`ContactsListSearchBar`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L440) with `onSearch` ([L450](../../Shared/Views/NewChat/NewChatMenuButton.swift#L450)) | `.submitLabel(.search)` and `.onSubmit` run [`runOnlineSearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L250): `search(text, name: connectNameCandidate)` |
| "Connect to …" row | below the field ([L99](../../Shared/Views/NewChat/NewChatMenuButton.swift#L99)) | shown for a name candidate until name results arrive |
| "Search in Directory" row | below the field ([L109](../../Shared/Views/NewChat/NewChatMenuButton.swift#L109)) | shown for non-empty, non-link text; runs [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L239): `searchDirectory(text)` |
| Names and Directory sections | after `ContactsList`, [`onlineSearchSections(_:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L195) | `Section`s with headers styled as "Your Contacts" ([`onlineSearchHeader`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L229)); the Directory section ends with the retry or "Show more" row; rows tap `planAndConnect(…, dismiss: true)` |
| "Search in Directory" row | below the field ([L109](../../Shared/Views/NewChat/NewChatMenuButton.swift#L109)) | shown for non-empty, non-link text; runs [`runDirectorySearch()`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L244): `searchDirectory(text)` |
| Names and Directory sections | after `ContactsList`, [`onlineSearchSections(_:)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L195) | `Section`s with headers styled as "Your Contacts" ([`onlineSearchHeader`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L234)); the Directory section renders when `rows.directory` is not empty, `rows.pageInChats` or `rows.footer` is set, and ends with the note row ([`DirectoryPageInChatsRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L313), when `rows.pageInChats`) and then the retry or "Show more" row. A directory result whose `localChatId` names a loaded chat (`chatModel.getChat`) is drawn as [`ContactListNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L12)`(chat:showDeletedChatIcon: true)`, whose [`groupNavLink`](../../Shared/Views/Contacts/ContactListNavLink.swift#L170) opens the group and closes the sheet (`dismissAllSheets` then `loadOpenChat`; avatar, name, favourite star, incognito mask; no swipe actions); other rows tap `planAndConnect(…, dismiss: true)` |
Both triggers do nothing for empty text or a pasted link and go through `confirmDirectorySearch`. The list is `insetGrouped` and not flipped, so the sections use ordinary `Section` headers and default row backgrounds.
**De-duplication against the contacts list.** Before `ContactsList(…)` the list builder computes [`ContactsList.shownChats(…)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L392) with the same inputs `ContactsList` gets (`contactListChatPredicate`, the search state, `DEFAULT_SHOW_UNREAD_AND_FAVORITES`) and passes the ids of the shown contacts to `rows(shownChatIds:)` ([L173](../../Shared/Views/NewChat/NewChatMenuButton.swift#L173)). `shownChats` returns the contacts matching the predicate and the filtered, sorted list the section shows; `ContactsList.body` uses the same function.
**De-duplication against the contacts list.** Before `ContactsList(…)` the list builder computes [`ContactsList.shownChats(…)`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L397) with the same inputs `ContactsList` gets (`contactListChatPredicate`, the search state, `DEFAULT_SHOW_UNREAD_AND_FAVORITES`) and passes the ids of the shown contacts to `rows(shownChatIds:)` ([L173](../../Shared/Views/NewChat/NewChatMenuButton.swift#L173)). `shownChats` returns the contacts matching the predicate and the filtered, sorted list the section shows; `ContactsList.body` uses the same function.
**Empty state.** [`ContactsList`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L303) has `showsEmptyState` (default `true`, [L314](../../Shared/Views/NewChat/NewChatMenuButton.swift#L314)); 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#L308) has `showsEmptyState` (default `true`, [L319](../../Shared/Views/NewChat/NewChatMenuButton.swift#L319)); the sheet passes `!onlineSearch.searching && rows.isEmpty`, so "No filtered contacts" / "No contacts" are omitted while the online search runs or shows rows.
**Archived contacts.** [`DeletedChats`](../../Shared/Views/NewChat/NewChatMenuButton.swift#L563) 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#L568) passes neither `onSearch` nor `showsEmptyState`: its search field keeps the `.return` label with a no-op submit, and its empty states are unchanged.
[GAP] New chat does not resolve a typed name locally while typing, so a known contact whose name does not contain the typed text is not in "Your Contacts" and appears as a Names row; tapping it opens the existing chat.