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
@@ -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>