diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt index e7bd02eff8..df4be0b312 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt @@ -45,6 +45,7 @@ data class DirectorySearchEntry( val id: String get() = link // untrusted input: only group and channel links are kept, as the core fetches an invitation link even in a store-only plan + // @Transient: the decoder always runs this check, and a "link" key in the JSON cannot replace it @Transient val link: String = run { val link = groupLink.connShortLink ?: groupLink.connFullLink diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt index f1c0ab37a8..fbedbc0ccd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt @@ -28,6 +28,7 @@ import kotlinx.serialization.json.JsonObject enum class DirectoryFooter { Retry, ShowMore } // Online results are not chats and are never saved: they live as long as the search text does. +// main thread only, as iOS's @MainActor: callers launch with withApi, and the generation checks rely on it class OnlineSearchModel { var names by mutableStateOf>(emptyList()) private set diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index e7c09217eb..c296fa3ceb 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -451,7 +451,7 @@ sealed class ArchiveError { | Function | Description | Line | |----------|-------------|------| -| `apiSearchDirectory(rh, text, cursor)` | Sends `CC.APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with [`DIRECTORY_SEARCH_TIMEOUT_SEC`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L70-L74); returns [`parseDirectorySearchResponse`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L78-L87)`(responseData)`, or null on any error (logged with `responseType` and `details`). Blocks for up to the timeout; uses plain `sendCmd`, so a timeout gives a retry row rather than the alert `sendCmdWithRetry` would show | [L1645](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652) | +| `apiSearchDirectory(rh, text, cursor)` | Sends `CC.APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with [`DIRECTORY_SEARCH_TIMEOUT_SEC`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L71-L75); returns [`parseDirectorySearchResponse`](../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L79-L88)`(responseData)`, or null on any error (logged with `responseType` and `details`). Blocks for up to the timeout; uses plain `sendCmd`, so a timeout gives a retry row rather than the alert `sendCmdWithRetry` would show | [L1645](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652) | | `apiConnectPlan(rh, connLink, resolveMode, linkOwnerSig, inProgress, onFailure)` | Plans a connection; returns `ConnectionPlanResult?`. `NotResolvedLocally` (a `PRMNever` lookup that matched nothing) returns null silently; any other failure is handled by `onFailure` | [L1654](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1654-L1668) | | `ConnectPlanFailure` | `Alert` (default, every existing caller): `apiConnectResponseAlert`, only while `inProgress.value` is true, as before; `Log`: a debug log only, used by the online search so name resolution and per-entry lookups never raise alerts | [L7522](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7522) | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index c91f8519b2..d9979721c0 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -378,11 +378,11 @@ The search text can be sent online on request: to SimpleX Directory, to find pub | Trigger | Handler | Runs | |---------|---------|------| | Search key: the keyboard's Search action on Android, Enter (key up) on desktop, through [`SearchTextField`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt#L33-L43)`(onSearch)` | [`runOnlineSearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L995-L1000) | `search(text, name = connectNameCandidate.value)`: the directory's first page, and name resolution when the text is a name candidate | -| "Search in Directory" row ([`SearchInDirectoryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L202-L217)) | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L988-L993) | `searchDirectory(text)`: the directory only | +| "Search in Directory" row ([`SearchInDirectoryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L203-L218)) | [`runDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L988-L993) | `searchDirectory(text)`: the directory only | `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. -Both triggers do nothing for empty (trimmed) text or a pasted SimpleX link ([`searchTrimmed`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L985-L986), `searchShowingSimplexLink`), and both go through [`confirmDirectorySearch()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L327-L333), which runs the search at once when `appPrefs.directorySearchAlertShown` is set and otherwise shows the three-button alert ([`showDirectorySearchAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L335-L363), `showAlertDialogButtonsColumn` with `parseHtml = false` so the paragraph break shows): Search / Search and don't show again / Cancel. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The preference is in `hintPreferences`, so "Reset all hints" brings the alert back. Each trigger launches the model method with `withApi`. +Both triggers 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#L328-L334), which runs the search at once when `appPrefs.directorySearchAlertShown` is set and otherwise shows the three-button alert ([`showDirectorySearchAlert`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L336-L364), `showAlertDialogButtonsColumn` with `parseHtml = false` so the paragraph break shows): Search / Search and don't show again / Cancel. Its message names both recipients: the directory, and, for a SimpleX name, the server that resolves names. The preference is in `hintPreferences`, so "Reset all hints" brings the alert back. Each trigger launches the model method with `withApi`. 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#L1298-L1352) and [`ChatListSearchBarWithTags`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1219-L1244): @@ -400,7 +400,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#L31-L199) +**Location:** [`OnlineSearchModel`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L32-L200) 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. @@ -419,37 +419,37 @@ The public state has private setters; `searchedText`, `generation` and `spinnerS | Computed | Definition | |----------|------------| -| [`searching`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L56) | `loading \|\| resolvingNames` | -| [`showResults`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L57) | `searching \|\| searched \|\| names.isNotEmpty()` | -| [`footer`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L58) | `DirectoryFooter.Retry` when `failed`, else `ShowMore` when a cursor exists, else null | +| [`searching`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L57) | `loading \|\| resolvingNames` | +| [`showResults`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L58) | `searching \|\| searched \|\| names.isNotEmpty()` | +| [`footer`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L59) | `DirectoryFooter.Retry` when `failed`, else `ShowMore` 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 and flags, bumps `generation` (text change, profile switch) | -| [`cancelRequest()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L73-L77) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay | -| [`search(text, name)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L79-L96) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`coroutineScope { async { resolveNames(name, gen) } }`, with the generation captured before the child coroutine starts) | -| [`searchDirectory(text)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L98-L111) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page | -| [`loadMore()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L113-L116) | "Show more": the next page, appended | -| [`retry()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L119-L125) | 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#L127-L139) | The rows to render, given the ids of the local chats shown (see De-duplication) | +| [`reset()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L61-L71) | Clears all results and flags, bumps `generation` (text change, profile switch) | +| [`cancelRequest()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L74-L78) | The spinner's `onCancel`: bumps `generation` and clears `loading` and `resolvingNames`; results already shown stay | +| [`search(text, name)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L80-L97) | Search key: ignored for empty text or while `searching` for the same text; otherwise `reset()`, then the first directory page and, with a name, name resolution concurrently (`coroutineScope { async { resolveNames(name, gen) } }`, with the generation captured before the child coroutine starts) | +| [`searchDirectory(text)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L99-L112) | The row: ignored while `loading` for the same text; for the same text it clears only the directory state (name results stay), for new text it resets; then the first page | +| [`loadMore()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L114-L117) | "Show more": the next page, appended | +| [`retry()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L120-L126) | 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#L128-L140) | The rows to render, given the ids of the local chats shown (see De-duplication) | -**Spinner.** The private setters of `loading` and `resolvingNames` call [`updateSpinner()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L185-L198) 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#L639-L645)) once it has run for a second, not in the search field. The spinner is shared with connections: [`ConnectProgressOwner`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L50) records whose it is, [`startConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L60-L69) does not let a search take it from a running connection, and [`stopConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L72-L78) ignores a stop from another owner. Tapping a result calls [`planAndConnect`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L24-L55), whose `cancelConnectProgress()` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L81-L88)) cancels the search through its `onCancel` before the connection takes the spinner. Clearing the search text does the same through the typing path. +**Spinner.** The private setters of `loading` and `resolvingNames` call [`updateSpinner()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L186-L199) 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#L639-L645)) once it has run for a second, not in the search field. The spinner is shared with connections: [`ConnectProgressOwner`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L50) records whose it is, [`startConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L60-L69) does not let a search take it from a running connection, and [`stopConnectProgress`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L72-L78) ignores a stop from another owner. Tapping a result calls [`planAndConnect`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt#L24-L55), whose `cancelConnectProgress()` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L81-L88)) cancels the search through its `onCancel` before the connection takes the spinner. Clearing the search text does the same through the typing path. ### Directory requests -[`request(append)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L159-L183) reads `chatModel.remoteHostId()` and calls [`apiSearchDirectory`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with a [10-second timeout](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L70-L74) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L78-L87) 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#L160-L184) reads `chatModel.remoteHostId()` and calls [`apiSearchDirectory`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1645-L1652), which sends `APISendServiceRequest` to [`DIRECTORY_SERVICE_LINK`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L16) with a [10-second timeout](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L19) and the request built by [`directorySearchRequest`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L71-L75) (`searchText`, and `searchCursor` for later pages). [`parseDirectorySearchResponse`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L79-L88) accepts only a `searchResults` response and decodes its entries one at a time inside `runCatching`, so an entry the app cannot decode is dropped and the rest of the page is kept. -[`DirectorySearchEntry`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L33-L57) keeps the wire field `groupLink` private; its [`link`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L47-L56) 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#L59-L62) 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. +[`DirectorySearchEntry`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L33-L58) keeps the wire field `groupLink` private; its [`link`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L47-L57) is a `@Transient` property whose initializer, run by the generated decoder, takes `groupLink.connShortLink ?: groupLink.connFullLink` and throws `SerializationException` unless [`simplexLinkType`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L60-L63) classifies it as `SimplexLinkType.group` or `SimplexLinkType.channel`. Being `@Transient`, `link` cannot be set by a `link` key in the JSON. `simplexLinkType` returns the link type only when [`strConnectTarget`](../../common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt#L834) finds exactly one SimpleX link whose text is the whole string. `link` is also the entry's `id`. The directory sends only the short link when an entry has one. -For each new entry of a page (not already shown, not repeated in the page), the request looks up the local chat its link refers to with `apiConnectPlan(rh, entry.link, resolveMode = PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)` and [`planLocalChatId`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L89-L109), building a [`DirectoryResult`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L172-L177). The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. The page is published, appended or replacing, only if the generation did not change. +For each new entry of a page (not already shown, not repeated in the page), the request looks up the local chat its link refers to with `apiConnectPlan(rh, entry.link, resolveMode = PRMNever, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)` and [`planLocalChatId`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L90-L110), building a [`DirectoryResult`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L173-L178). The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. The page is published, appended or replacing, only if the generation did not change. ### Name resolution -[`resolveNames(name, gen)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L141-L157) resolves `[name]` for a candidate starting with `@` or `#`, else `["@$name", "#$name"]` (the candidate comes from [`nameSearchCandidate`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1261-L1272), as on the typing path). Each target is planned with `apiConnectPlan(rh, target, resolveMode = PRMUnknown, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult(target, result)`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L111-L170) (a private constructor behind a companion `operator fun invoke` that returns null for no row) maps the plan with one exhaustive `when`: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName?.shortStr`, `localChatId` comes from `planLocalChatId`, `iconName` is an `ImageResource`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; the false `inProgress` also keeps `sendCmdWithRetry` from showing its network retry alert. +[`resolveNames(name, gen)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L142-L158) resolves `[name]` for a candidate starting with `@` or `#`, else `["@$name", "#$name"]` (the candidate comes from [`nameSearchCandidate`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1261-L1272), as on the typing path). Each target is planned with `apiConnectPlan(rh, target, resolveMode = PRMUnknown, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log)`; it stops before a target once the generation changed, so a cancelled or replaced search sends no further names. [`NameSearchResult(target, result)`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L112-L171) (a private constructor behind a companion `operator fun invoke` that returns null for no row) maps the plan with one exhaustive `when`: a contact address or group link gives a row (profile from the link data or the known chat), the user's own contact address, an invitation link and an error give none. `simplexName` is the plan's `planSimplexName?.shortStr`, `localChatId` comes from `planLocalChatId`, `iconName` is an `ImageResource`, and for a group link `resolvedLink` is the link the core resolved (`connLink.connShortLink`). Failures are logged at debug level and never shown as alerts; the false `inProgress` also keeps `sendCmdWithRetry` from showing its network retry alert. ### De-duplication -[`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L127-L139) returns an [`OnlineSearchRows`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L179-L185) at composition time, so pages added by "Show more" are filtered too: +[`rows(shownChatIds)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L128-L140) returns an [`OnlineSearchRows`](../../common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt#L180-L186) at composition 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); @@ -472,10 +472,10 @@ Each part renders only when it has rows: the Names part when `rows.names` is not | Row | View | Tap | |-----|------|-----| -| Name | [`NameSearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L266-L294): avatar, profile name in bold, the SimpleX name in parentheses (secondary, one `AnnotatedString`, one line), description (2 lines) | `planAndConnect(rhId, result.target, close = null, cleanup = null)` | -| Directory entry | [`DirectorySearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L219-L264): avatar, `#` + name, the directory's SimpleX name claim as plain text (truncates first), description (2 lines), member or subscriber count, and the verb "Join channel" / "Join group" (one line, never wrapped) | `planAndConnect(rhId, result.entry.link, close = null, cleanup = null)` | -| Retry | [`DirectoryRetryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L313-L325): "Search failed, tap to retry" | `withApi { retry() }` | -| Show more | [`DirectoryShowMoreRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L296-L311): "Show more", disabled (secondary colour) while `loading` | `withApi { loadMore() }` | +| Name | [`NameSearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L267-L295): avatar, profile name in bold, the SimpleX name in parentheses (secondary, one `AnnotatedString`, one line), description (2 lines) | `planAndConnect(rhId, result.target, close = null, cleanup = null)` | +| Directory entry | [`DirectorySearchRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L220-L265): avatar, `#` + name, the directory's SimpleX name claim as plain text (truncates first), description (2 lines), member or subscriber count, and the verb "Join channel" / "Join group" (one line, never wrapped) | `planAndConnect(rhId, result.entry.link, close = null, cleanup = null)` | +| Retry | [`DirectoryRetryRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L314-L326): "Search failed, tap to retry" | `withApi { retry() }` | +| Show more | [`DirectoryShowMoreRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt#L297-L312): "Show more", disabled (secondary colour) while `loading` | `withApi { loadMore() }` | A name or directory row tap first hides the keyboard (`hideKeyboard(view)`), then runs `planAndConnect` with `withBGApi`. Text from the directory and from name owners is rendered with `Text(String)` (never parsed as markdown) with line limits.