Merge branch 'stable'

This commit is contained in:
spaced4ndy
2026-09-21 17:31:29 +04:00
24 changed files with 450 additions and 48 deletions
+1
View File
@@ -240,6 +240,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file)
| common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md |
| common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md |
| common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md |
| common/.../common/views/chatlist/GetStakeBanner.kt | spec/client/chat-list.md | product/views/chat-list.md |
| common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md |
| common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md |
| common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md |
@@ -397,9 +397,11 @@ fun CenterPartOfScreen() {
}
when (currentChatId.value) {
null -> {
if (shouldShowOnboarding()) {
if (rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
ModalManager.center.showInView()
} else if (shouldShowOnboarding()) {
ConnectOnboardingView()
} else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
} else {
Box(
Modifier
.fillMaxSize()
@@ -408,8 +410,6 @@ fun CenterPartOfScreen() {
) {
Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
}
} else {
ModalManager.center.showInView()
}
}
else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
@@ -193,6 +193,8 @@ class AppPreferences {
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false)
val getStakeBannerTapped = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_TAPPED, false)
val getStakeBannerDismissed = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_DISMISSED, false)
val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true)
val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true)
val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null)
@@ -275,6 +277,8 @@ class AppPreferences {
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(supporterBannerShown, false),
hintPref(getStakeBannerTapped, false),
hintPref(getStakeBannerDismissed, false),
hintPref(liveMessageAlertShown, false),
hintPref(signMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
@@ -467,6 +471,8 @@ class AppPreferences {
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown"
private const val SHARED_PREFS_GET_STAKE_BANNER_TAPPED = "GetStakeBannerTapped"
private const val SHARED_PREFS_GET_STAKE_BANNER_DISMISSED = "GetStakeBannerDismissed"
private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert"
private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert"
private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase"
@@ -950,6 +950,10 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
val supporterBannerShown = remember { appPrefs.supporterBannerShown.state }
val getStakeBannerTapped = remember { appPrefs.getStakeBannerTapped.state }
val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state }
// read here rather than in the LazyColumn: it launches an effect, so it needs a composable scope
val crowdfunding = crowdfundingAvailable()
val activeFilter = remember { chatModel.activeChatTagFilter }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
@@ -1060,6 +1064,16 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
)
}
}
} else if (crowdfunding && !getStakeBannerDismissed.value) {
item {
Box(Modifier.zIndex(1f).padding(16.dp)) {
GetStakeBanner(
showDismiss = getStakeBannerTapped.value && chatModel.chats.value.isNotEmpty(),
onTap = { openGetStake(ModalManager.start) },
onDismiss = { appPrefs.getStakeBannerDismissed.set(true) }
)
}
}
}
itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat ->
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
@@ -0,0 +1,127 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.common.views.helpers.ModalManager
import chat.simplex.common.views.helpers.fontSizeMultiplier
import chat.simplex.common.views.newchat.darkStops
import chat.simplex.common.views.newchat.gradientPoints
import chat.simplex.common.views.newchat.lightStops
import chat.simplex.common.views.onboarding.GetStakeView
import chat.simplex.res.MR
// Spec: spec/client/chat-list.md#GetStakeBanner
@Composable
fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit) {
Box(Modifier.fillMaxWidth()) {
Row(
Modifier
.bannerCard(onTap)
// the end padding is the 8dp trailing inset plus the X's 36dp hit region
.padding(start = 16.dp, end = 44.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
stringResource(MR.strings.invest_banner_title),
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colors.primary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
stringResource(MR.strings.invest_banner_subtitle),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
if (showDismiss) {
BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss)
}
}
}
fun openGetStake(modalManager: ModalManager) {
appPrefs.getStakeBannerTapped.set(true)
modalManager.showModalCloseable(cardScreen = true) { close ->
GetStakeView(showFirstImage = true, inCenterOfWindow = modalManager === ModalManager.center, close = close)
}
}
@Composable
fun Modifier.bannerCard(onTap: () -> Unit): Modifier {
// grows linearly with system font but never shrinks below the default, and the card grows further
// when 2-line text wraps at very large fonts
val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp)
val isDark = isInDarkTheme()
var cardSize by remember { mutableStateOf(IntSize.Zero) }
val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) }
return this
.fillMaxWidth()
.heightIn(min = cardHeight)
.clip(RoundedCornerShape(16.dp))
.background(brush)
.clickable(onClick = onTap)
.onSizeChanged { cardSize = it }
}
// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area.
@Composable
fun BannerDismissButton(modifier: Modifier, onDismiss: () -> Unit) {
Icon(
painterResource(MR.images.ic_close),
contentDescription = stringResource(MR.strings.icon_descr_close_button),
tint = if (isInDarkTheme()) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
modifier = modifier
.padding(end = 4.dp, top = 4.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss)
.padding(8.dp)
.size(16.dp)
)
}
// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than
// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region.
private fun gradientBrush(isDark: Boolean, size: IntSize): Brush {
val stops = if (isDark) darkStops else lightStops
if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops)
val w = size.width.toFloat()
val h = size.height.toFloat()
val startScale = if (isDark) 3.0f else 2.5f
val endScale = if (isDark) 2.1f else 1.7f
val gp = gradientPoints(h / w, 1.0f)
val sx = 0.5f + (gp.startX - 0.5f) * startScale
val sy = 0.5f + (gp.startY - 0.5f) * startScale
val ex = 0.5f + (gp.endX - 0.5f) * endScale
val ey = 0.5f + (gp.endY - 0.5f) * endScale
return Brush.linearGradient(
colorStops = stops,
start = Offset(sx * w, sy * h),
end = Offset(ex * w, ey * h)
)
}
@@ -23,6 +23,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.compose.painterResource
@@ -32,7 +33,10 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.GetStakeBanner
import chat.simplex.common.views.chatlist.openGetStake
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.crowdfundingAvailable
import chat.simplex.common.views.usersettings.UserAddressView
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@@ -415,6 +419,27 @@ fun ConnectOnboardingView() {
}
}
val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state }
val showGetStakeBanner = crowdfundingAvailable() && !getStakeBannerDismissed.value
// on desktop the pages span the window, but the banner keeps the width it has in the chat list
val bannerMaxWidth = if (appPlatform.isDesktop) DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier else Dp.Unspecified
val content = @Composable {
Column(Modifier.fillMaxSize()) {
Box(Modifier.weight(1f).fillMaxWidth()) {
pager()
}
if (showGetStakeBanner) {
Box(Modifier.align(Alignment.CenterHorizontally).widthIn(max = bannerMaxWidth).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 8.dp)) {
GetStakeBanner(
showDismiss = false,
onTap = cardClickOverride ?: { openGetStake(if (appPlatform.isDesktop) ModalManager.center else ModalManager.start) },
onDismiss = {}
)
}
}
}
}
if (appPlatform.isDesktop) {
val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier
Box(
@@ -422,12 +447,12 @@ fun ConnectOnboardingView() {
contentAlignment = Alignment.Center
) {
Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) {
pager()
content()
}
}
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
pager()
content()
}
}
}
@@ -1088,7 +1088,7 @@ fun isInUs(): Boolean =
@Composable
private fun InvestInSimpleXChatView(modalManager: ModalManager) {
if (!crowdfundingAvailable()) return
val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } }
val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } }
Column(modifier = Modifier.padding(bottom = 12.dp)) {
Text(
generalGetString(MR.strings.v7_0_invest),
@@ -1170,7 +1170,7 @@ private val getStakeSlides: List<CrowdfundingSlide> = listOf(
)
@Composable
fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) {
fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) {
val uriHandler = LocalUriHandler.current
val stopped = chatModel.chatRunning.value == false
@@ -1200,7 +1200,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close
val title = "Get a stake in\nSimpleX Chat"
AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false)
// What's new already shows the image of the first slide, above the link that opens this page
if (fromSettings) {
if (showFirstImage) {
slideImage(getStakeSlides[0])
}
Text(
@@ -1213,7 +1213,7 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close
}
}
},
Modifier.padding(top = if (fromSettings) 8.dp else 0.dp),
Modifier.padding(top = if (showFirstImage) 8.dp else 0.dp),
lineHeight = 24.sp
)
@@ -132,7 +132,7 @@ fun SettingsLayout(
SettingsActionItem(
painterResource(MR.images.ic_redeem),
stringResource(MR.strings.v7_0_crowdfunding),
{ ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } }
{ ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(showFirstImage = true, close = close) } }
)
}
}
@@ -2678,6 +2678,8 @@
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
<string name="v7_0_channels">Better channels 📢</string>
+1 -1
View File
@@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei
| # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) |
|---|---------|-------------|-----------|----------------------|----------------------|
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) |
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) |
| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` |
| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) |
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) |
@@ -115,6 +115,7 @@ Each chat type provides specific dropdown menu items:
| One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch |
| Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` |
| FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` |
| Crowdfunding banner (`GetStakeBanner`) | `crowdfundingAvailable()` and not dismissed | Gradient card above the chats, and below the onboarding cards when there are no conversations; opens `GetStakeView`. The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset |
### Empty States
@@ -134,3 +135,4 @@ Each chat type provides specific dropdown menu items:
| `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` |
| `UserPicker.kt` | `views/chatlist/UserPicker.kt` |
| `TagListView.kt` | `views/chatlist/TagListView.kt` |
| `GetStakeBanner.kt` | `views/chatlist/GetStakeBanner.kt` |
+2 -2
View File
@@ -124,9 +124,9 @@ Common Module (commonMain)
| Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 |
| Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 |
| JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 |
| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 |
| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L525) | `object ChatController` | 525 |
| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 |
| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 |
| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 |
| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 |
| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 |
| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 |
+52 -12
View File
@@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatLis
7. [Tag System](#7-tag-system)
8. [UserPicker](#8-userpicker)
9. [Source Files](#9-source-files)
10. [Crowdfunding Banner](#10-crowdfunding-banner)
---
## Executive Summary
The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection.
The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 179 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection.
---
@@ -53,7 +54,7 @@ ChatListView
## 2. ChatListView Composable
**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127)
**Location:** [`ChatListView.kt#L179`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L179)
```kotlin
fun ChatListView(
@@ -66,8 +67,8 @@ fun ChatListView(
### Initialization
- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay.
- On desktop, closing a chat resets audio/video players (line ~138).
- Shows "What's New" modal on first launch after update (line ~185), with a 1-second delay.
- On desktop, closing a chat resets audio/video players (line ~193).
### Layout Modes
@@ -88,8 +89,8 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout:
### Android-specific
- `SetNotificationsModeAdditions`: Notification permission setup (line ~184).
- `UserPicker`: Overlay side panel for user switching (line ~192).
- `SetNotificationsModeAdditions`: Notification permission setup (line ~243).
- `UserPicker`: Overlay side panel for user switching (line ~247).
---
@@ -113,7 +114,7 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout:
### Active Filter Types
Defined as sealed class `ActiveFilter` (line ~51):
Defined as sealed class `ActiveFilter` (line ~59):
```kotlin
sealed class ActiveFilter {
@@ -136,7 +137,7 @@ sealed class ActiveFilter {
### Search Filtering
The `filteredChats` function (line ~1188) applies filters in this order:
The `filteredChats` function (line ~1474) applies filters in this order:
1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat.
2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`.
@@ -147,7 +148,7 @@ The `filteredChats` function (line ~1188) applies filters in this order:
### Search Bar
`ChatListSearchBar` (line ~611) provides:
`ChatListSearchBar` (line ~765) provides:
- Text input with search icon.
- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search.
- Unread filter toggle button (right side, when search is empty).
@@ -158,7 +159,7 @@ The `filteredChats` function (line ~1188) applies filters in this order:
## 5. Chat Preview
**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40)
**Location:** [`ChatPreviewView.kt#L41`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L41)
```kotlin
fun ChatPreviewView(
@@ -224,7 +225,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re
### TagsView
**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929)
**Location:** [`ChatListView.kt#L1214`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1214)
Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable).
@@ -244,7 +245,7 @@ Layout logic:
### TagListView
**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48)
**Location:** [`TagListView.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L47)
Full-screen tag management view opened from the "+" button or long-press menu.
@@ -312,3 +313,42 @@ Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow`
| `ShareListView.kt` | Share target list (forwarding flow) |
| `TagListView.kt` | Tag management and assignment view |
| `UserPicker.kt` | User switching side panel |
| `GetStakeBanner.kt` | Crowdfunding banner and the shared banner card chrome |
---
<a id="GetStakeBanner"></a>
## 10. Crowdfunding Banner
**Location:** [`GetStakeBanner.kt#L31`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt#L31)
```kotlin
fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit)
```
Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfundingAvailable()` — always outside Play Store builds, and in Play builds only while the store country is the US or not yet known — and only while `getStakeBannerDismissed` is false.
### Placement
| Where | Condition | Layout |
|-------|-----------|--------|
| 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)` |
`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.
### Dismissal
| Preference | Set by | Effect |
|------------|--------|--------|
| `getStakeBannerTapped` | `openGetStake()` | the dismiss X appears from then on, while there are chats |
| `getStakeBannerDismissed` | the dismiss X | hides the banner in both placements |
Both are in `AppPreferences.hintPreferences`, so "Reset all hints" restores the banner. The X is never offered below the onboarding cards, so the banner cannot be dismissed before the user has a chat.
Tapping the card runs `openGetStake()`, which opens `GetStakeView(showFirstImage = true)` as a card modal — `showFirstImage` decides whether the page repeats the first slide's image, which only What's New shows above its own link.
### Shared card chrome
`Modifier.bannerCard(onTap)` (size state, gradient brush, `heightIn`, `clip`, `background`, `clickable`) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome; the paddings stay with the caller. The gradient reuses `gradientPoints`, `lightStops` and `darkStops` from `views/newchat/OnboardingCards.kt`.
+1
View File
@@ -92,6 +92,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/`
| Source File | Product Concepts Affected | Risk Level | Notes |
|-------------|--------------------------|------------|-------|
| `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search |
| `views/chatlist/GetStakeBanner.kt` | PC1 | Low | Crowdfunding banner and shared banner card chrome |
| `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat |
| `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering |
| `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI |