diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 5ddad9b53f..16b75c0924 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1107,6 +1107,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat // val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } } val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value) val rows = onlineSearch.rows(shownChatIds = chats.map { it.id }.toSet()) + val showsCards = !onlineSearch.showResults val topPaddingToContent = topPaddingToContent(false) val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent LazyColumnWithScrollBar( @@ -1138,16 +1139,14 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ChatListSearchBarWithTags(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate, onlineSearch) } } - if (!oneHandUICardShown.value && !onlineSearch.showResults) { + if (showsCards && !oneHandUICardShown.value) { item { ToggleChatListCard() } } // one slot: a badge the user paid for ending outranks the pitch to get one val alert = BadgeModel.alert.value - if (onlineSearch.showResults) { - // banners give way to online results - } else if (supportEnded() && alert != null) { + if (showsCards && supportEnded() && alert != null) { item { SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired } Box(Modifier.zIndex(1f).padding(16.dp)) { @@ -1159,7 +1158,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) } } - } else if (badgeIssueFailed()) { + } else if (showsCards && badgeIssueFailed()) { item { SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeIssueFailed } Box(Modifier.zIndex(1f).padding(16.dp)) { @@ -1172,7 +1171,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) } } - } else if (chatModel.bannerSlotFree(ChatListBanner.BadgePitch) && !supporterBannerShown.value && noShownBadge() && chatModel.chats.value.size > 3) { + } else if (showsCards && chatModel.bannerSlotFree(ChatListBanner.BadgePitch) && !supporterBannerShown.value && noShownBadge() && chatModel.chats.value.size > 3) { item { SideEffect { chatModel.chatListBanner = ChatListBanner.BadgePitch } Box(Modifier.zIndex(1f).padding(16.dp)) { @@ -1186,7 +1185,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) } } - } else if (chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfunding && !getStakeBannerDismissed.value) { + } else if (showsCards && chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfunding && !getStakeBannerDismissed.value) { item { SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake } Box(Modifier.zIndex(1f).padding(16.dp)) { @@ -1206,7 +1205,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } nameRows(rows.names, oneHandUI.value) directoryRows(rows, onlineSearch, oneHandUI.value) - if (!addressCreationCardShown.value && !onlineSearch.showResults) { + if (showsCards && !addressCreationCardShown.value) { item { ChatListFeatureCards() } diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index ae08dc207a..e3676477a1 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -227,7 +227,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re ### TagsView -**Location:** [`ChatListView.kt#L1452`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1452) +**Location:** [`ChatListView.kt#L1451`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1451) Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). @@ -340,7 +340,7 @@ Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfun | Chat list | in `ChatList`'s `LazyColumn`, after `ToggleChatListCard` and before the chats | `Box(Modifier.zIndex(1f).padding(16.dp))` | | Onboarding | inside `ConnectOnboardingView` (`views/newchat/OnboardingCards.kt`), below the pager, so it shares the pages' width limit on desktop and their dimming while a start modal is open; opens the page in `ModalManager.center` on desktop, `ModalManager.start` on Android | `padding(start/end = DEFAULT_PADDING, bottom = 8.dp)`, in a `Column` where the pager takes `weight(1f)` | -The list has a single banner slot, 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's `item` records itself in `ChatModel.chatListBanner` (`BadgeExpired`, `BadgeIssueFailed`, `BadgePitch`, `GetStake`) in a `SideEffect`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(banner)` — 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()` (`views/newchat/OnboardingCards.kt`), false until `BadgeModel.isCurrent` for the current user, so it cannot take the slot from a supporter whose badge loads a moment later. `ConnectOnboardingView` applies the same `bannerSlotFree` check and records `GetStake`. While online search results are shown (`onlineSearch.showResults`, after "Search in Directory" or the Search key) the slot stays empty, and `ToggleChatListCard` and `ChatListFeatureCards` are left out too; typing alone, which only filters the chats, keeps them, and they return when the search is cleared or cancelled before showing anything. +The list has a single banner slot, 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's `item` records itself in `ChatModel.chatListBanner` (`BadgeExpired`, `BadgeIssueFailed`, `BadgePitch`, `GetStake`) in a `SideEffect`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(banner)` — 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()` (`views/newchat/OnboardingCards.kt`), false until `BadgeModel.isCurrent` for the current user, so it cannot take the slot from a supporter whose badge loads a moment later. `ConnectOnboardingView` applies the same `bannerSlotFree` check and records `GetStake`. While online search results are shown (`showsCards` is `!onlineSearch.showResults`, false after "Search in Directory" or the Search key) the slot stays empty, and `ToggleChatListCard` and `ChatListFeatureCards` are left out too; typing alone, which only filters the chats, keeps them, and they return when the search is cleared or cancelled before showing anything. `crowdfundingAvailable()` launches an effect to load the store country, so both call sites read it in the composable body rather than inside the `LazyColumn` builder. @@ -384,7 +384,7 @@ The search text can be sent online on request: to SimpleX Directory, to find pub 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#L1006-L1007), `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#L1321-L1375) and [`ChatListSearchBarWithTags`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1242-L1267): +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#L1320-L1374) and [`ChatListSearchBarWithTags`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1241-L1266): | Search text | Android, one-hand UI (slot above the field) | Android, top bar (slot below the field) | Desktop (either mode) | |-------------|-----------------------------------|------------------------------|------| @@ -445,7 +445,7 @@ For each new entry of a page (not already shown, not repeated in the page), the ### Name resolution -[`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#L1284-L1295), 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. +[`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#L1283-L1294), 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 @@ -463,10 +463,10 @@ Only store-derived chat ids and the link the core resolved can hide a row, never | Where | Rows | Empty state | |-------|------|-------------| -| [`ChatList`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1066-L1240) | `rows(shownChatIds)` with the ids of the shown chats ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1109)); after the local chats and before `ChatListFeatureCards`, [`nameRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L517-L532) then [`directoryRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L534-L554) | `NoChatsView` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1218-L1223)); the `you_have_no_chats` overlay of `ChatListWithLoadingScreen` (no chats at all) is hidden while `showResults` | +| [`ChatList`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1066-L1239) | `rows(shownChatIds)` with the ids of the shown chats ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1109)); after the local chats and before `ChatListFeatureCards`, [`nameRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L517-L532) then [`directoryRows`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L534-L554) | `NoChatsView` overlay only when no chat is shown, the model is not `searching` and `rows.isEmpty` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1217-L1222)); the `you_have_no_chats` overlay of `ChatListWithLoadingScreen` (no chats at all) is hidden while `showResults` | | Onboarding branch of [`ChatListWithLoadingScreen`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L459-L506) | A `Column` with `ChatListSearchBarWithTags` first (top bar) or last (one-hand UI), and between them a `Box(Modifier.weight(1f))` holding, while `showResults`, a `LazyColumnWithScrollBarNoAppBar` of the same parts with `shownChatIds = emptySet()` (reversed in one-hand mode, `imePadding()` in top-bar mode), otherwise `ConnectOnboardingView()` on Android | A centred "No chats found" item when not searching and `rows.isEmpty` | -The onboarding `Column` takes the toolbar and navigation-bar paddings and consumes the navigation-bar insets, so the results list's `imePadding()` does not count them twice. On desktop the onboarding pages are drawn in the centre pane, so the chat list pane shows only the search bar and the results. When a joined result adds a chat and the onboarding state ends, `ChatList` enters composition and its `LaunchedEffect(activeFilter.value)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1237-L1239)) clears the search text, which resets the model; the iOS app keeps the search there. +The onboarding `Column` takes the toolbar and navigation-bar paddings and consumes the navigation-bar insets, so the results list's `imePadding()` does not count them twice. On desktop the onboarding pages are drawn in the centre pane, so the chat list pane shows only the search bar and the results. When a joined result adds a chat and the onboarding state ends, `ChatList` enters composition and its `LaunchedEffect(activeFilter.value)` ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1236-L1238)) clears the search text, which resets the model; the iOS app keeps the search there. 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. `nameRows` and `directoryRows` are `LazyListScope` builders; each part has a header `item` ([`SearchPartHeader`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L567-L575): `body2`, secondary colour, `semantics { heading() }`) rather than a `stickyHeader`, and its rows are `items` keyed by `id`. [`searchPart`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L556-L565) emits the header before the part's rows with the top bar and after them in the one-hand UI, so it is drawn above them in both layouts, the one-hand list being reversed. Its header defaults to `SearchPartHeader`; the New chat sheet passes its own ([Navigation, New Chat Online Search](navigation.md#10-new-chat-online-search)). A lazy list keeps its first visible item in place by key, so in the reversed onboarding list, whose first item is then a keyed row, names arriving after the directory page can end up out of view: that list scrolls to its first item when name results arrive in one-hand mode ([source](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L474-L480)). The `ChatList` branch starts with a keyless spacer, which stays the anchor.