diff --git a/apps/ios/Shared/Model/DirectorySearch.swift b/apps/ios/Shared/Model/DirectorySearch.swift index a6df76ff97..e1a613324c 100644 --- a/apps/ios/Shared/Model/DirectorySearch.swift +++ b/apps/ios/Shared/Model/DirectorySearch.swift @@ -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 } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d4b36aa7d8..1d23cc3f3d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift index 3f7622d106..54700d2caf 100644 --- a/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift +++ b/apps/ios/Shared/Views/ChatList/DirectorySearchView.swift @@ -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 } diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index bc08b00404..7c19936a81 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -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.