diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md index 5a8356f656..e6372a9e33 100644 --- a/apps/ios/CODE.md +++ b/apps/ios/CODE.md @@ -166,6 +166,7 @@ After completing all changes (code + documentation), you MUST run an adversarial | Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | | Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | | Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/ChatList/GetStakeBanner.swift | spec/client/chat-list.md | product/views/chat-list.md | | Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | | Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index ab3ffdd191..3797ec0f63 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -158,6 +158,7 @@ struct ChatListView: View { @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? @State private var showNewChatSheet = false + @State private var showGetStakeSheet = false @State private var searchMode = false @FocusState private var searchFocussed @State private var searchText = "" @@ -176,6 +177,8 @@ struct ChatListView: View { @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_TAPPED) private var getStakeBannerTapped = false + @AppStorage(DEFAULT_GET_STAKE_BANNER_DISMISSED) private var getStakeBannerDismissed = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @State private var showBadgesSheet = false @@ -220,6 +223,9 @@ struct ChatListView: View { .modifier(ThemedBackground()) } } + .appSheet(isPresented: $showGetStakeSheet) { + GetStakeView(fromSettings: false, showFirstImage: true) + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -425,14 +431,26 @@ struct ChatListView: View { @ViewBuilder private var chatList: some View { if shouldShowOnboarding { - ConnectOnboardingView() - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .modifier(ThemedBackground()) + VStack(spacing: 0) { + ConnectOnboardingView() + if isInUS && !getStakeBannerDismissed { + GetStakeBanner(showDismiss: false, onTap: openGetStake, onDismiss: {}) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .modifier(ThemedBackground()) } else { chatListContent } } + private func openGetStake() { + getStakeBannerTapped = true + showGetStakeSheet = true + } + private var chatListContent: some View { let cs = filteredChats() return ZStack { @@ -484,6 +502,17 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) .zIndex(1) + } else if isInUS && !getStakeBannerDismissed { + GetStakeBanner( + showDismiss: getStakeBannerTapped && !chatModel.chats.isEmpty, + onTap: openGetStake, + onDismiss: { withAnimation { getStakeBannerDismissed = true } } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift new file mode 100644 index 0000000000..741cb7e273 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -0,0 +1,106 @@ +// +// GetStakeBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 21.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// Spec: spec/client/chat-list.md#GetStakeBanner +struct GetStakeBanner: View { + @EnvironmentObject var theme: AppTheme + var showDismiss: Bool + let onTap: () -> Void + let onDismiss: () -> Void + + var body: some View { + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Get a stake in SimpleX Chat!") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Invest on Wefunder from $100") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + // keeps the text clear of the dismiss X: its 4 leading + 12 wide + 16 trailing + Spacer(minLength: 32) + } + .modifier(BannerCard()) + } + .buttonStyle(.plain) + + if showDismiss { + BannerDismissButton(onDismiss: onDismiss) + } + } + } +} + +struct BannerCard: ViewModifier { + @Environment(\.colorScheme) var colorScheme: ColorScheme + + // grows with Dynamic Type but never shrinks below the default, so small-font users see the same card + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + + func body(content: Content) -> some View { + content + // the leading padding matches OneHandUICard's segment icon, so the text aligns with it in the list + .padding(.leading, 16) + .padding(.trailing, 8) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + 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 BannerDismissButton: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + let onDismiss: () -> Void + + var body: some View { + 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) + } +} + +#Preview { + GetStakeBanner(showDismiss: true, onTap: {}, onDismiss: {}) + .padding() +} diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 2fd21e39dd..6cafb78043 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -845,7 +845,7 @@ fileprivate struct InvestInSimpleXChat: View { } .frame(maxWidth: .infinity, alignment: .leading) .sheet(isPresented: $showGetStakeSheet) { - GetStakeView(fromSettings: false) + GetStakeView(fromSettings: false, showFirstImage: false) } } } @@ -885,6 +885,7 @@ struct GetStakeView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel var fromSettings: Bool + var showFirstImage: Bool var body: some View { ZoomablePageView { @@ -894,7 +895,7 @@ struct GetStakeView: View { .bold() .fixedSize(horizontal: false, vertical: true) .if(!fromSettings) { $0.padding(.top) } - if fromSettings { + if showFirstImage { slideImage(getStakeSlides[0]) } (Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor)) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 767acade71..efc16a6309 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,8 @@ let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" +let DEFAULT_GET_STAKE_BANNER_TAPPED = "getStakeBannerTapped" +let DEFAULT_GET_STAKE_BANNER_DISMISSED = "getStakeBannerDismissed" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -119,6 +121,8 @@ let appDefaults: [String: Any] = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_SUPPORTER_BANNER_SHOWN: false, + DEFAULT_GET_STAKE_BANNER_TAPPED: false, + DEFAULT_GET_STAKE_BANNER_DISMISSED: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -151,6 +155,8 @@ let hintDefaults = [ DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, DEFAULT_SUPPORTER_BANNER_SHOWN, + DEFAULT_GET_STAKE_BANNER_TAPPED, + DEFAULT_GET_STAKE_BANNER_DISMISSED, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -403,7 +409,7 @@ struct SettingsView: View { if isInUS { Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) { NavigationLink { - GetStakeView(fromSettings: true) + GetStakeView(fromSettings: true, showFirstImage: true) .navigationBarTitle("", displayMode: .inline) } label: { settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index dfab20ec8a..e0a634a713 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -165,6 +165,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; @@ -556,6 +557,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; + 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; @@ -1052,6 +1054,7 @@ 5CB9250B27A942F300ACCCDD /* ChatList */ = { isa = PBXGroup; children = ( + 647C9E283061362A0032110E /* GetStakeBanner.swift */, 5C2E260A27A30CFA00F70299 /* ChatListView.swift */, 5C5346A727B59A6A004DF848 /* ChatHelp.swift */, 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */, @@ -1736,6 +1739,7 @@ 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */, + 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */, 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 6d63ee2faf..89884eab43 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei | # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | |---|---------|-------------|-----------|---------------------|----------------------| -| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift`, `Shared/Views/ChatList/GetStakeBanner.swift` | `Controller.hs` (`APIGetChats`) | | 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | | 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | | 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 04d19bef9e..dee9e1d427 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -93,6 +93,7 @@ When a relay address link (`/r` path) is opened via URL deep link, `ContentView. - **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode - **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address +- **Crowdfunding banner** (`GetStakeBanner`): Shown to users in the US only, above the chats and below the onboarding pages. Tapping it opens `GetStakeView` (invest on Wefunder). The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset ### Pull-to-Refresh @@ -103,7 +104,7 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve | State | Behavior | |---|---| | Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | -| No chats | `ChatHelp` view displayed with onboarding guidance | +| No conversations yet | `ConnectOnboardingView` pages replace the list, with the crowdfunding banner below them where it applies | | Connection in progress | `ConnectProgressManager` overlay with connecting text | | Search with no results | Empty list with no special empty-state view | @@ -127,4 +128,5 @@ Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect serve - `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering - `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering - `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/GetStakeBanner.swift` -- Wefunder crowdfunding banner and shared banner card chrome - `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index d35de1f80a..900e0d4341 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -20,6 +20,7 @@ 7. [Swipe Actions](#7-swipe-actions) 8. [UserPicker](#8-userpicker) 9. [Floating Action Button](#9-floating-action-button) +10. [Crowdfunding Banner](#10-crowdfunding-banner) --- @@ -53,7 +54,7 @@ ChatListView --- -## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L154) {#2-chatlistview} **File**: `Shared/Views/ChatList/ChatListView.swift` @@ -62,7 +63,7 @@ The root list view. Key responsibilities: ### Data Source - Reads `ChatModel.shared.chats` (all conversations) - Applies active filter from `ChatTagsModel.shared.activeFilter` -- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) - Sorts by last activity (most recent first), with pinned chats at top ### Layout @@ -79,11 +80,11 @@ The root list view. Key responsibilities: | Function | Line | Description | |----------|------|-------------| -| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | -| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | -| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | -| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | -| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L183) | 183 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) | 555 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) | 598 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L529) | 529 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L549) | 549 | Stops any playing voice message | --- @@ -171,7 +172,7 @@ Horizontal scrolling tab bar below the navigation bar. Tabs: | Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | | User tags | `.userTag(ChatTag)` | User-defined custom tags | -Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L1134) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L928) struct. ### ChatTagsModel State @@ -194,9 +195,9 @@ class ChatTagsModel: ObservableObject { | Type | File | Line | Description | |------|------|------|-------------| -| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | -| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | -| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 36 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L53) | ChatListView.swift | 53 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L1113) | ChatListView.swift | 1113 | Applies a filter and persists selection | ### Tag Management Commands - `apiCreateChatTag(tag: ChatTagData)` -- create tag @@ -211,7 +212,7 @@ class ChatTagsModel: ObservableObject { Search is available via pull-down gesture or search button in the navigation bar. -**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L662) ### Filtering Logic - Filters `ChatModel.chats` by matching search text against: @@ -219,7 +220,7 @@ Search is available via pull-down gesture or search button in the navigation bar - `chatInfo.localAlias` (local alias) - `chatInfo.fullName` (full name) - For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter -- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L555) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L598) ### Search Results - Matching chats are displayed in the same list format @@ -279,11 +280,43 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f --- +## 10. [`GetStakeBanner`](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) {#10-crowdfunding-banner} + +**File**: `Shared/Views/ChatList/GetStakeBanner.swift` + +Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS`](../../Shared/Views/Onboarding/WhatsNewView.swift#L45) — the same condition that gates the Wefunder row in settings — and only while `DEFAULT_GET_STAKE_BANNER_DISMISSED` is false. + +### Placement + +| Where | Condition | Layout | +|-------|-----------|--------| +| 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)` | + +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 + +| Default | Set by | Effect | +|---------|--------|--------| +| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L408) | the dismiss X appears from then on, while there are chats | +| `DEFAULT_GET_STAKE_BANNER_DISMISSED` | the dismiss X | hides the banner in both placements | + +Both are in `hintDefaults`, so "Reset all hints" in the developer settings restores the banner. The X is never offered in the onboarding branch, so the banner cannot be dismissed before the user has a chat. + +Tapping the card opens [`GetStakeView`](../../Shared/Views/Onboarding/WhatsNewView.swift#L834) as an `appSheet`. + +### Shared card chrome + +`BannerCard` (a `ViewModifier`: paddings, minimum height scaled by Dynamic Type, gradient background, rounded corners) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome. The gradient reuses `OnboardingCardView.gradientPoints`, `lightStops` and `darkStops`. + +--- + ## Source Files | File | Path | Key struct | Line | |------|------|------------|------| -| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [154](../../Shared/Views/ChatList/ChatListView.swift#L154) | | Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | | Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | | Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | @@ -294,3 +327,4 @@ The FAB (floating action button) in the bottom-right corner opens the new chat f | Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | | Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | | One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | +| Crowdfunding banner | [`GetStakeBanner.swift`](../../Shared/Views/ChatList/GetStakeBanner.swift) | `GetStakeBanner` | [13](../../Shared/Views/ChatList/GetStakeBanner.swift#L13) | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index 74acec789e..35528f30d6 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -52,6 +52,7 @@ | Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | | Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | | Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/ChatList/GetStakeBanner.swift | PC1 | Low | Crowdfunding banner and shared banner card chrome | | Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | | Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | | Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 67fa676414..30462d18e8 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -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 | diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index cb91c386ce..338e602a45 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -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) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 966a931208..92f4c6d5c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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" 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 a013cc97a2..3d0e7c7d2e 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 @@ -950,6 +950,10 @@ private fun BoxScope.ChatList(searchText: MutableState, 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, 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt new file mode 100644 index 0000000000..9d6833854e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -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) + ) +} 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 bd33bde96a..a1cc8676de 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 @@ -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() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ca9ee078d6..0fa53ea6de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -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 = 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 ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index cbd2be9ee3..9e1a6eb4ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -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) } } ) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f305ffc13f..feed7b3c8e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2678,6 +2678,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder + Get a stake in SimpleX Chat! + Invest on Wefunder from $100 SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index 5d707cf832..d29ac5c2e5 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -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`) | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md index daa7907c5d..a09994d1e3 100644 --- a/apps/multiplatform/product/views/chat-list.md +++ b/apps/multiplatform/product/views/chat-list.md @@ -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` | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index c5d9a3b4f7..d6a7ec20af 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -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 | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index b0f3750659..a5eeb268fa 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -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 | + +--- + + + +## 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`. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 3a96638310..ec302e30b5 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -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 |