ios: hide a search result once the chat opened from it is listed

A result's local chat was looked up only when the search ran, so a group or channel opened from its row stayed listed twice, as the chat and as the row, until the next search (a test run joined cinews_digest from the Directory part and saw both). The rows without a chat look it up again when a connection started from one ends.
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-17 04:15:47 +00:00
1 parent 7165bb7d02
commit 1c24109730
4 files changed
+45 -16

No files matched your search

+2 -2
View File
@@ -129,7 +129,7 @@ struct NameSearchResult: Identifiable {
let descr: String?
let image: String?
let iconName: String
let localChatId: ChatId?
var localChatId: ChatId?
// the link the core resolved the name to, never a profile field the name's owner wrote
let resolvedLink: String?
@@ -172,7 +172,7 @@ struct NameSearchResult: Identifiable {
struct DirectoryResult: Identifiable {
let entry: DirectorySearchEntry
let localChatId: ChatId?
var localChatId: ChatId?
var id: String { entry.id }
}
@@ -489,7 +489,7 @@ struct ChatListView: View {
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: nil)
planAndConnect(result.target, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
}
}
@@ -505,7 +505,7 @@ struct ChatListView: View {
.listRowBackground(Color.clear)
.onTapGesture {
searchFocussed = false
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: nil)
planAndConnect(result.entry.link, theme: theme, dismiss: false, cleanup: { Task { await onlineSearch.refreshLocalChatIds() } })
}
}
switch rows.footer {
@@ -114,6 +114,35 @@ class OnlineSearchModel: ObservableObject {
)
}
// a chat opened from a result exists only once its connection flow ends, so rows without a chat look again then
func refreshLocalChatIds() async {
let gen = generation
var nameChatIds: [String: ChatId] = [:]
for result in names where result.localChatId == nil {
nameChatIds[result.id] = await localChatId(result.target)
}
var directoryChatIds: [String: ChatId] = [:]
for result in directory where result.localChatId == nil {
directoryChatIds[result.id] = await localChatId(result.entry.link)
}
guard gen == generation else { return }
names = names.map { result in
var r = result
r.localChatId = r.localChatId ?? nameChatIds[r.id]
return r
}
directory = directory.map { result in
var r = result
r.localChatId = r.localChatId ?? directoryChatIds[r.id]
return r
}
}
private func localChatId(_ link: String) async -> ChatId? {
let plan = await apiConnectPlan(connLink: link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
return plan.flatMap { planLocalChatId($0.connectionPlan) }
}
private func resolveNames(_ name: String, generation gen: Int) async {
let targets = name.hasPrefix("@") || name.hasPrefix("#") ? [name] : ["@" + name, "#" + name]
var results: [NameSearchResult] = []
@@ -140,8 +169,7 @@ class OnlineSearchModel: ObservableObject {
var seen = Set(directory.map { $0.id })
for entry in r.entries {
guard seen.insert(entry.id).inserted else { continue }
let plan = await apiConnectPlan(connLink: entry.link, resolveMode: .never, inProgress: BoxedValue(false), onFailure: .log)
page.append(DirectoryResult(entry: entry, localChatId: plan.flatMap { planLocalChatId($0.connectionPlan) }))
page.append(DirectoryResult(entry: entry, localChatId: await localChatId(entry.link)))
}
}
guard gen == generation else { return }
+11 -10
View File
@@ -331,9 +331,9 @@ The search text can be sent online on request: to SimpleX Directory, to find pub
| 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#L174)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1051) | `searchDirectory(text)`: the directory only |
| "Search in Directory" row ([`SearchInDirectoryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L202)) | [`runDirectorySearch()`](../../Shared/Views/ChatList/ChatListView.swift#L1051) | `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#L305), 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#L313)): 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#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.
The rows above or below the search field follow the toolbar mode:
@@ -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-L172)
### [`OnlineSearchModel`](../../Shared/Views/ChatList/DirectorySearchView.swift#L18-L200)
`@MainActor` `ObservableObject`, owned by `ChatListView` as `@StateObject onlineSearch` and passed to `ChatListSearchBar`.
@@ -375,12 +375,13 @@ The rows above or below the search field follow the toolbar mode:
| [`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 |
**Spinner.** `loading` and `resolvingNames` have a `didSet` that calls [`updateSpinner()`](../../Shared/Views/ChatList/DirectorySearchView.swift#L158), 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#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.
### 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#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#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.
[`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.
@@ -388,7 +389,7 @@ 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#L117) 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#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.
### De-duplication
@@ -413,10 +414,10 @@ Each part renders only when it has rows: the Names part when `rows.names` is not
| Row | View | Tap |
|-----|------|-----|
| Name | [`NameSearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L249): avatar, profile name in bold, the SimpleX name in parentheses (secondary), description (2 lines) | `planAndConnect(result.target, dismiss: false)` |
| Directory entry | [`DirectorySearchRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L196): 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)` |
| Retry | [`DirectoryRetryRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L291) via [`directoryRetryRow()`](../../Shared/Views/ChatList/ChatListView.swift#L695): "Search failed, tap to retry" | `retry()` |
| Show more | [`DirectoryShowMoreRow`](../../Shared/Views/ChatList/DirectorySearchView.swift#L276) 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#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()` |
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.