From a3ac420e9136ccff9b4b55fefa69ff9225a34cde Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:51:14 +0000 Subject: [PATCH] ui: one banner at a time in chat list (#7554) --- apps/ios/Shared/Model/ChatModel.swift | 14 ++++ .../Views/Badges/SupportSimpleXBanner.swift | 44 ++---------- .../Shared/Views/ChatList/ChatListView.swift | 16 +++-- apps/ios/spec/client/chat-list.md | 2 + apps/ios/spec/state.md | 1 + .../chat/simplex/common/model/ChatModel.kt | 8 +++ .../views/badges/SupportSimpleXBanner.kt | 69 ++----------------- .../common/views/chatlist/ChatListView.kt | 7 +- .../common/views/newchat/OnboardingCards.kt | 9 ++- apps/multiplatform/spec/client/chat-list.md | 2 + apps/multiplatform/spec/state.md | 1 + 11 files changed, 61 insertions(+), 112 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1bf711ca5f..ef1f01c84d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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 = [] + // 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 diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift index c2f6136d31..1ff47c9ed9 100644 --- a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -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 { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 3797ec0f63..ad2c411172 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index 900e0d4341..ece1b4d045 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -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 diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index db16aa2936..88f798666c 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -163,6 +163,7 @@ ChatTagsModel (singleton -- filter state) |----------|------|-------------|------| | `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | | `filesToDelete` | `Set` | 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 5bbb41e4dd..929f19436d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 94203e2169..36737c0c1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -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) - ) -} 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 3d0e7c7d2e..36b636653c 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 @@ -1046,6 +1046,7 @@ private fun BoxScope.ChatList(searchText: MutableState, 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, 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, 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(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index a1cc8676de..d9df033018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -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): 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, diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index a5eeb268fa..47cb39e344 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -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 diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 229c30d18e..5003bb60e5 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -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` | 138 | Whether a deep link connection is in progress | | [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState` | 141 | Whether new chat bottom sheet is visible | | [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState` | 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` | 147 | Notification content preview level | | [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | | [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list |