ui: one banner at a time in chat list (#7554)

This commit is contained in:
spaced4ndy
2026-09-21 16:51:14 +00:00
committed by GitHub
parent 9a583d394f
commit a3ac420e91
11 changed files with 61 additions and 112 deletions
+14
View File
@@ -383,6 +383,12 @@ class BadgeModel: ObservableObject {
}
}
enum ChatListBanner {
case badgeExpired
case badgePitch
case getStake
}
// Spec: spec/state.md#ChatModel
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@@ -463,6 +469,14 @@ final class ChatModel: ObservableObject {
var filesToDelete: Set<URL> = []
// the banner kind the chat list showed this app session: it keeps the slot until restart, so dismissing it never puts
// another in its place; only the badge alert shows regardless. Set while rendering, so not published.
var chatListBanner: ChatListBanner?
func bannerSlotFree(for banner: ChatListBanner) -> Bool {
chatListBanner == nil || chatListBanner == banner
}
static let shared = ChatModel()
let im = ItemsModel.shared
@@ -17,14 +17,10 @@ struct SupportSimpleXBanner: View {
let onTap: () -> Void
let onDismiss: () -> Void
private let cardCornerRadius: CGFloat = 16
// grows with Dynamic Type but never shrinks below the default so small-font users see the same
// banner as today; hero stays fixed so its above-card overhang shrinks at very large fonts
// the card's own height, for centring the fallback hero; hero stays fixed so its above-card
// overhang shrinks at very large fonts
@ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72
private var cardHeight: CGFloat { max(72, scaledCardHeight) }
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
private let cardLeadingPadding: CGFloat = 16
private let cardTrailingPadding: CGFloat = 8
private let heroWidth: CGFloat = 110
// shorter than the natural drawn height so .clipped() slices the phone body at card bottom
private let heroVisibleHeight: CGFloat = 108
@@ -50,12 +46,7 @@ struct SupportSimpleXBanner: View {
}
Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap)
}
.padding(.leading, cardLeadingPadding)
.padding(.trailing, cardTrailingPadding)
.padding(.vertical, 12)
.frame(minHeight: cardHeight)
.background(gradientBackground())
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
.modifier(BannerCard())
}
.buttonStyle(.plain)
.overlay(alignment: .bottomTrailing) {
@@ -64,15 +55,7 @@ struct SupportSimpleXBanner: View {
.allowsHitTesting(false)
}
Image(systemName: "multiply")
.foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary)
.frame(width: 12, height: 12)
.padding(.top, 12)
.padding(.bottom, 4)
.padding(.trailing, 16)
.padding(.leading, 4)
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
BannerDismissButton(onDismiss: onDismiss)
}
}
@@ -94,25 +77,6 @@ struct SupportSimpleXBanner: View {
.padding(.trailing, 12)
#endif
}
private func gradientBackground() -> some View {
// Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is
// above, so the card's middle lands at the bright/mid-transition stop instead of the dark
// navy region. Keeps the small warm accent at top-right.
GeometryReader { geo in
let aspect = max(geo.size.height, 1) / max(geo.size.width, 1)
let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0
let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1
let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0)
let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale)
let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale)
return LinearGradient(
stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
startPoint: start,
endPoint: end
)
}
}
}
struct SupportSimpleXBanner_Previews: PreviewProvider {
@@ -388,8 +388,10 @@ struct ChatListView: View {
badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId
}
private var hasShownBadge: Bool {
badgeModel.badgeState?.shown == true && badgeModel.userId == chatModel.currentUser?.userId
// false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge
// arriving a moment later would hide it, leaving the slot empty for the session
private var noShownBadge: Bool {
badgeModel.badgeState?.shown != true && badgeModel.userId == chatModel.currentUser?.userId
}
private func showSupportEndedDismissAlert() {
@@ -433,10 +435,11 @@ struct ChatListView: View {
if shouldShowOnboarding {
VStack(spacing: 0) {
ConnectOnboardingView()
if isInUS && !getStakeBannerDismissed {
if chatModel.bannerSlotFree(for: .getStake) && isInUS && !getStakeBannerDismissed {
GetStakeBanner(showDismiss: false, onTap: openGetStake, onDismiss: {})
.padding(.horizontal, 20)
.padding(.bottom, 8)
.onAppear { chatModel.chatListBanner = .getStake }
}
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
@@ -492,7 +495,8 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
} else if !supporterBannerShown && !hasShownBadge && chatModel.chats.count > 3 {
.onAppear { chatModel.chatListBanner = .badgeExpired }
} else if chatModel.bannerSlotFree(for: .badgePitch) && !supporterBannerShown && noShownBadge && chatModel.chats.count > 3 {
SupportSimpleXBanner(
onTap: { showBadgesSheet = true },
onDismiss: showSupportSimpleXDismissAlert
@@ -502,7 +506,8 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
} else if isInUS && !getStakeBannerDismissed {
.onAppear { chatModel.chatListBanner = .badgePitch }
} else if chatModel.bannerSlotFree(for: .getStake) && isInUS && !getStakeBannerDismissed {
GetStakeBanner(
showDismiss: getStakeBannerTapped && !chatModel.chats.isEmpty,
onTap: openGetStake,
@@ -513,6 +518,7 @@ struct ChatListView: View {
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.zIndex(1)
.onAppear { chatModel.chatListBanner = .getStake }
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
+2
View File
@@ -293,6 +293,8 @@ Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS`
| Chat list | rendered whenever [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L413) is, in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` |
| Onboarding | below [`ConnectOnboardingView`](../../Shared/Views/NewChat/OnboardingCards.swift#L135) when `shouldShowOnboarding` | `.padding(.horizontal, 20)` (the onboarding cards' margin), `.padding(.bottom, 8)` |
The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the support-ended alert (`supportEnded`), the pitch, then the Wefunder banner. Each banner records itself in `ChatModel.chatListBanner` (`.badgeExpired`, `.badgePitch`, `.getStake`) in its `onAppear`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(for:)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alert has no such check: it takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge`, false until `BadgeModel` holds the current user's state, so it cannot take the slot from a supporter whose badge loads a moment later. The onboarding placement applies the same `bannerSlotFree` check and records `.getStake`.
In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied to the enclosing `VStack` rather than to each child, so the banner stays below the pages in both toolbar modes.
### Dismissal
+1
View File
@@ -163,6 +163,7 @@ ChatTagsModel (singleton -- filter state)
|----------|------|-------------|------|
| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) |
| `filesToDelete` | `Set<URL>` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) |
| `chatListBanner` | `ChatListBanner?` | The banner kind the chat list showed this app session; `bannerSlotFree(for:)` tells whether a kind may take the slot (see [chat-list.md](client/chat-list.md)) | [L474](../Shared/Model/ChatModel.swift#L474) |
| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) |
### Key Methods
@@ -130,6 +130,8 @@ object BadgeModel {
this.rhId.value == rhId && this.userId.value == userId
}
enum class ChatListBanner { BadgeExpired, BadgePitch, GetStake }
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@@ -199,6 +201,12 @@ object ChatModel {
// Needed to apply black color to left/right cutout area on Android
val fullscreenGalleryVisible = mutableStateOf(false)
// the banner kind the chat list showed this app session: it keeps the slot until restart, so dismissing it never puts
// another in its place; only the badge alert shows regardless. Set while rendering, so not a state.
var chatListBanner: ChatListBanner? = null
fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
@@ -2,32 +2,23 @@ package chat.simplex.common.views.badges
import androidx.compose.foundation.*
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.ContentScale
import androidx.compose.ui.layout.Layout
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.Dp
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.BuildConfigCommon
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.BannerDismissButton
import chat.simplex.common.views.chatlist.bannerCard
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.darkStops
import chat.simplex.common.views.newchat.gradientPoints
import chat.simplex.common.views.newchat.lightStops
import chat.simplex.res.MR
@Composable
@@ -37,13 +28,9 @@ fun SupportSimpleXBanner(
onTap: () -> Unit,
onDismiss: () -> Unit
) {
val cardCornerRadius = 16.dp
// grows linearly with system font but never shrinks below the default so small-font users see the
// same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text
// wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts.
// the card's own height, for centring the fallback hero; hero stays fixed so its above-card
// overhang shrinks at very large fonts
val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp)
// matches OneHandUICard's segment icon leading so the text aligns with it in the list
val cardLeadingPadding = 16.dp
val cardTrailingPadding = 8.dp
val heroWidth = 110.dp
// shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom
@@ -52,24 +39,15 @@ fun SupportSimpleXBanner(
val heroTrailingPadding = 28.dp
val textToHeroGap = 6.dp
val isDark = isInDarkTheme()
var cardSize by remember { mutableStateOf(IntSize.Zero) }
val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) }
// Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero
// overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts).
Layout(content = {
Box(Modifier.fillMaxWidth()) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = cardHeight)
.clip(RoundedCornerShape(cardCornerRadius))
.background(brush)
.clickable(onClick = onTap)
.onSizeChanged { cardSize = it }
.bannerCard(onTap)
.padding(
start = cardLeadingPadding,
start = 16.dp,
end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap,
top = 12.dp,
bottom = 12.dp
@@ -95,19 +73,7 @@ fun SupportSimpleXBanner(
}
}
// Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area.
Icon(
painterResource(MR.images.ic_close),
contentDescription = stringResource(MR.strings.icon_descr_close_button),
tint = if (isDark) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 4.dp, top = 4.dp)
.clip(CircleShape)
.clickable(onClick = onDismiss)
.padding(8.dp)
.size(16.dp)
)
BannerDismissButton(Modifier.align(Alignment.TopEnd), onDismiss)
}
HeroThumbnail(
@@ -149,24 +115,3 @@ private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: 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)
)
}
@@ -1046,6 +1046,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
val alert = BadgeModel.alert.value
if (supportEnded() && alert != null) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired }
Box(Modifier.zIndex(1f).padding(16.dp)) {
SupportSimpleXBanner(
title = stringResource(MR.strings.badges_support_ended),
@@ -1055,8 +1056,9 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
)
}
}
} else if (!supporterBannerShown.value && !hasShownBadge() && chatModel.chats.value.size > 3) {
} else if (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)) {
SupportSimpleXBanner(
onTap = { ModalManager.start.showCustomModal { close -> BadgesView(close) } },
@@ -1064,8 +1066,9 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
)
}
}
} else if (crowdfunding && !getStakeBannerDismissed.value) {
} else if (chatModel.bannerSlotFree(ChatListBanner.GetStake) && crowdfunding && !getStakeBannerDismissed.value) {
item {
SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake }
Box(Modifier.zIndex(1f).padding(16.dp)) {
GetStakeBanner(
showDismiss = getStakeBannerTapped.value && chatModel.chats.value.isNotEmpty(),
@@ -58,8 +58,10 @@ fun shouldShowOnboarding(): Boolean {
fun supportEnded(): Boolean =
BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
fun hasShownBadge(): Boolean =
BadgeModel.badgeState.value?.shown == true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
// false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge
// arriving a moment later would hide it, leaving the slot empty for the session
fun noShownBadge(): Boolean =
BadgeModel.badgeState.value?.shown != true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)
fun hasConversations(chats: List<Chat>): Boolean =
chats.any { chat ->
@@ -420,7 +422,7 @@ fun ConnectOnboardingView() {
}
val getStakeBannerDismissed = remember { appPrefs.getStakeBannerDismissed.state }
val showGetStakeBanner = crowdfundingAvailable() && !getStakeBannerDismissed.value
val showGetStakeBanner = chatModel.bannerSlotFree(ChatListBanner.GetStake) && 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 {
@@ -429,6 +431,7 @@ fun ConnectOnboardingView() {
pager()
}
if (showGetStakeBanner) {
SideEffect { chatModel.chatListBanner = ChatListBanner.GetStake }
Box(Modifier.align(Alignment.CenterHorizontally).widthIn(max = bannerMaxWidth).padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 8.dp)) {
GetStakeBanner(
showDismiss = false,
@@ -336,6 +336,8 @@ 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 pitch, then the Wefunder banner. Each banner's `item` records itself in `ChatModel.chatListBanner` (`BadgeExpired`, `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 alert has no such check: it 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`.
`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
+1
View File
@@ -152,6 +152,7 @@ Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex
| [`appOpenUrlConnecting`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L138) | `MutableState<Boolean>` | 138 | Whether a deep link connection is in progress |
| [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState<Boolean>` | 141 | Whether new chat bottom sheet is visible |
| [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState<Boolean>` | 144 | Fullscreen gallery mode |
| [`chatListBanner`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L206) | `ChatListBanner?` (plain var) | 206 | The banner kind the chat list showed this app session; `bannerSlotFree(banner)` tells whether a kind may take the slot (see [chat-list.md](client/chat-list.md)) |
| [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState<NotificationPreviewMode>` | 147 | Notification content preview level |
| [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState<Boolean>` | 156 | Whether to show authentication screen |
| [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState<Boolean>` | 158 | Whether to show chat preview text in list |