mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 00:39:00 +00:00
ui: crowdfunding banner on onboarding and in chat list (#7552)
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -157,6 +157,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 = ""
|
||||
@@ -174,6 +175,8 @@ struct ChatListView: View {
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = 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
|
||||
|
||||
// Spec: spec/client/chat-list.md#body
|
||||
@@ -211,6 +214,9 @@ struct ChatListView: View {
|
||||
NewChatSheet()
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
}
|
||||
.appSheet(isPresented: $showGetStakeSheet) {
|
||||
GetStakeView(fromSettings: false, showFirstImage: true)
|
||||
}
|
||||
.onChange(of: activeUserPickerSheet) {
|
||||
if $0 != nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
@@ -382,14 +388,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 {
|
||||
@@ -418,6 +436,18 @@ struct ChatListView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
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
|
||||
ChatListNavLink(chat: chat, parentSheet: $sheet)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -795,7 +795,7 @@ fileprivate struct InvestInSimpleXChat: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: $showGetStakeSheet) {
|
||||
GetStakeView(fromSettings: false)
|
||||
GetStakeView(fromSettings: false, showFirstImage: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +835,7 @@ struct GetStakeView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
var fromSettings: Bool
|
||||
var showFirstImage: Bool
|
||||
|
||||
var body: some View {
|
||||
ZoomablePageView {
|
||||
@@ -844,7 +845,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))
|
||||
|
||||
@@ -56,6 +56,8 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
|
||||
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"
|
||||
@@ -117,6 +119,8 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_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,
|
||||
@@ -148,6 +152,8 @@ let hintDefaults = [
|
||||
DEFAULT_LA_NOTICE_SHOWN,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_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,
|
||||
@@ -384,7 +390,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") }
|
||||
|
||||
@@ -164,6 +164,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 */; };
|
||||
@@ -543,6 +544,7 @@
|
||||
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
|
||||
647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = "<group>"; };
|
||||
647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = "<group>"; };
|
||||
647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = "<group>"; };
|
||||
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
|
||||
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
|
||||
@@ -1027,6 +1029,7 @@
|
||||
5CB9250B27A942F300ACCCDD /* ChatList */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
647C9E283061362A0032110E /* GetStakeBanner.swift */,
|
||||
5C2E260A27A30CFA00F70299 /* ChatListView.swift */,
|
||||
5C5346A727B59A6A004DF848 /* ChatHelp.swift */,
|
||||
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */,
|
||||
@@ -1683,6 +1686,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 */,
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -240,6 +240,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file)
|
||||
| common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md |
|
||||
| common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md |
|
||||
| common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| common/.../common/views/chatlist/GetStakeBanner.kt | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
|
||||
@@ -397,9 +397,11 @@ fun CenterPartOfScreen() {
|
||||
}
|
||||
when (currentChatId.value) {
|
||||
null -> {
|
||||
if (shouldShowOnboarding()) {
|
||||
if (rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
|
||||
ModalManager.center.showInView()
|
||||
} else if (shouldShowOnboarding()) {
|
||||
ConnectOnboardingView()
|
||||
} else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) {
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -408,8 +410,6 @@ fun CenterPartOfScreen() {
|
||||
) {
|
||||
Text(stringResource(if (chatModel.desktopNoUserNoRemote) MR.strings.no_connected_mobile else MR.strings.no_selected_chat))
|
||||
}
|
||||
} else {
|
||||
ModalManager.center.showInView()
|
||||
}
|
||||
}
|
||||
else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {}
|
||||
|
||||
+6
@@ -192,6 +192,8 @@ class AppPreferences {
|
||||
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
|
||||
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
|
||||
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_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)
|
||||
@@ -273,6 +275,8 @@ class AppPreferences {
|
||||
hintPref(laNoticeShown, false),
|
||||
hintPref(oneHandUICardShown, false),
|
||||
hintPref(addressCreationCardShown, false),
|
||||
hintPref(getStakeBannerTapped, false),
|
||||
hintPref(getStakeBannerDismissed, false),
|
||||
hintPref(liveMessageAlertShown, false),
|
||||
hintPref(signMessageAlertShown, false),
|
||||
hintPref(showHiddenProfilesNotice, true),
|
||||
@@ -464,6 +468,8 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
|
||||
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_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"
|
||||
|
||||
+15
@@ -912,6 +912,10 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
|
||||
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.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) {
|
||||
@@ -1000,6 +1004,17 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
||||
ToggleChatListCard()
|
||||
}
|
||||
}
|
||||
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 {
|
||||
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
|
||||
|
||||
+127
@@ -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)
|
||||
)
|
||||
}
|
||||
+27
-2
@@ -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
|
||||
@@ -407,6 +411,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(
|
||||
@@ -414,12 +439,12 @@ fun ConnectOnboardingView() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) {
|
||||
pager()
|
||||
content()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
pager()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1040,7 +1040,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),
|
||||
@@ -1122,7 +1122,7 @@ private val getStakeSlides: List<CrowdfundingSlide> = listOf(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) {
|
||||
fun GetStakeView(showFirstImage: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
@@ -1152,7 +1152,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(
|
||||
@@ -1165,7 +1165,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
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -119,7 +119,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) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2745,6 +2745,8 @@
|
||||
<string name="v7_0_invest_descr" translatable="false">Crowdfunding on Wefunder.</string>
|
||||
<string name="v7_0_crowdfunding" translatable="false">Crowdfunding on Wefunder</string>
|
||||
<string name="v7_0_invest_learn_more" translatable="false">Learn more on Wefunder</string>
|
||||
<string name="invest_banner_title" translatable="false">Get a stake in SimpleX Chat!</string>
|
||||
<string name="invest_banner_subtitle" translatable="false">Invest on Wefunder from $100</string>
|
||||
<string name="v7_0_simplex_names">SimpleX public names (BETA)</string>
|
||||
<string name="v7_0_simplex_names_descr">Public names for your channel or business.</string>
|
||||
<string name="v7_0_channels">Better channels 📢</string>
|
||||
|
||||
@@ -19,7 +19,7 @@ This document provides a structured mapping between product-level concepts, thei
|
||||
|
||||
| # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) |
|
||||
|---|---------|-------------|-----------|----------------------|----------------------|
|
||||
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) |
|
||||
| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) |
|
||||
| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` |
|
||||
| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) |
|
||||
| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) |
|
||||
|
||||
@@ -115,6 +115,7 @@ Each chat type provides specific dropdown menu items:
|
||||
| One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch |
|
||||
| Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` |
|
||||
| FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` |
|
||||
| Crowdfunding banner (`GetStakeBanner`) | `crowdfundingAvailable()` and not dismissed | Gradient card above the chats, and below the onboarding cards when there are no conversations; opens `GetStakeView`. The dismiss X appears once the banner has been tapped and there is at least one chat; dismissing hides it until hints are reset |
|
||||
|
||||
### Empty States
|
||||
|
||||
@@ -134,3 +135,4 @@ Each chat type provides specific dropdown menu items:
|
||||
| `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` |
|
||||
| `UserPicker.kt` | `views/chatlist/UserPicker.kt` |
|
||||
| `TagListView.kt` | `views/chatlist/TagListView.kt` |
|
||||
| `GetStakeBanner.kt` | `views/chatlist/GetStakeBanner.kt` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatLis
|
||||
7. [Tag System](#7-tag-system)
|
||||
8. [UserPicker](#8-userpicker)
|
||||
9. [Source Files](#9-source-files)
|
||||
10. [Crowdfunding Banner](#10-crowdfunding-banner)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection.
|
||||
The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 179 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +54,7 @@ ChatListView
|
||||
|
||||
## 2. ChatListView Composable
|
||||
|
||||
**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127)
|
||||
**Location:** [`ChatListView.kt#L179`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L179)
|
||||
|
||||
```kotlin
|
||||
fun ChatListView(
|
||||
@@ -66,8 +67,8 @@ fun ChatListView(
|
||||
|
||||
### Initialization
|
||||
|
||||
- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay.
|
||||
- On desktop, closing a chat resets audio/video players (line ~138).
|
||||
- Shows "What's New" modal on first launch after update (line ~185), with a 1-second delay.
|
||||
- On desktop, closing a chat resets audio/video players (line ~193).
|
||||
|
||||
### Layout Modes
|
||||
|
||||
@@ -88,8 +89,8 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout:
|
||||
|
||||
### Android-specific
|
||||
|
||||
- `SetNotificationsModeAdditions`: Notification permission setup (line ~184).
|
||||
- `UserPicker`: Overlay side panel for user switching (line ~192).
|
||||
- `SetNotificationsModeAdditions`: Notification permission setup (line ~243).
|
||||
- `UserPicker`: Overlay side panel for user switching (line ~247).
|
||||
|
||||
---
|
||||
|
||||
@@ -113,7 +114,7 @@ The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout:
|
||||
|
||||
### Active Filter Types
|
||||
|
||||
Defined as sealed class `ActiveFilter` (line ~51):
|
||||
Defined as sealed class `ActiveFilter` (line ~59):
|
||||
|
||||
```kotlin
|
||||
sealed class ActiveFilter {
|
||||
@@ -136,7 +137,7 @@ sealed class ActiveFilter {
|
||||
|
||||
### Search Filtering
|
||||
|
||||
The `filteredChats` function (line ~1188) applies filters in this order:
|
||||
The `filteredChats` function (line ~1474) applies filters in this order:
|
||||
|
||||
1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat.
|
||||
2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`.
|
||||
@@ -147,7 +148,7 @@ The `filteredChats` function (line ~1188) applies filters in this order:
|
||||
|
||||
### Search Bar
|
||||
|
||||
`ChatListSearchBar` (line ~611) provides:
|
||||
`ChatListSearchBar` (line ~765) provides:
|
||||
- Text input with search icon.
|
||||
- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search.
|
||||
- Unread filter toggle button (right side, when search is empty).
|
||||
@@ -158,7 +159,7 @@ The `filteredChats` function (line ~1188) applies filters in this order:
|
||||
|
||||
## 5. Chat Preview
|
||||
|
||||
**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40)
|
||||
**Location:** [`ChatPreviewView.kt#L41`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L41)
|
||||
|
||||
```kotlin
|
||||
fun ChatPreviewView(
|
||||
@@ -224,7 +225,7 @@ On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) re
|
||||
|
||||
### TagsView
|
||||
|
||||
**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929)
|
||||
**Location:** [`ChatListView.kt#L1214`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L1214)
|
||||
|
||||
Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable).
|
||||
|
||||
@@ -244,7 +245,7 @@ Layout logic:
|
||||
|
||||
### TagListView
|
||||
|
||||
**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48)
|
||||
**Location:** [`TagListView.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L47)
|
||||
|
||||
Full-screen tag management view opened from the "+" button or long-press menu.
|
||||
|
||||
@@ -312,3 +313,42 @@ Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow`
|
||||
| `ShareListView.kt` | Share target list (forwarding flow) |
|
||||
| `TagListView.kt` | Tag management and assignment view |
|
||||
| `UserPicker.kt` | User switching side panel |
|
||||
| `GetStakeBanner.kt` | Crowdfunding banner and the shared banner card chrome |
|
||||
|
||||
---
|
||||
|
||||
<a id="GetStakeBanner"></a>
|
||||
|
||||
## 10. Crowdfunding Banner
|
||||
|
||||
**Location:** [`GetStakeBanner.kt#L31`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt#L31)
|
||||
|
||||
```kotlin
|
||||
fun GetStakeBanner(showDismiss: Boolean, onTap: () -> Unit, onDismiss: () -> Unit)
|
||||
```
|
||||
|
||||
Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfundingAvailable()` — always outside Play Store builds, and in Play builds only while the store country is the US or not yet known — and only while `getStakeBannerDismissed` is false.
|
||||
|
||||
### Placement
|
||||
|
||||
| Where | Condition | Layout |
|
||||
|-------|-----------|--------|
|
||||
| Chat list | in `ChatList`'s `LazyColumn`, after `ToggleChatListCard` and before the chats | `Box(Modifier.zIndex(1f).padding(16.dp))` |
|
||||
| Onboarding | inside `ConnectOnboardingView` (`views/newchat/OnboardingCards.kt`), below the pager, so it shares the pages' width limit on desktop and their dimming while a start modal is open; opens the page in `ModalManager.center` on desktop, `ModalManager.start` on Android | `padding(start/end = DEFAULT_PADDING, bottom = 8.dp)`, in a `Column` where the pager takes `weight(1f)` |
|
||||
|
||||
`crowdfundingAvailable()` launches an effect to load the store country, so both call sites read it in the composable body rather than inside the `LazyColumn` builder.
|
||||
|
||||
### Dismissal
|
||||
|
||||
| Preference | Set by | Effect |
|
||||
|------------|--------|--------|
|
||||
| `getStakeBannerTapped` | `openGetStake()` | the dismiss X appears from then on, while there are chats |
|
||||
| `getStakeBannerDismissed` | the dismiss X | hides the banner in both placements |
|
||||
|
||||
Both are in `AppPreferences.hintPreferences`, so "Reset all hints" restores the banner. The X is never offered below the onboarding cards, so the banner cannot be dismissed before the user has a chat.
|
||||
|
||||
Tapping the card runs `openGetStake()`, which opens `GetStakeView(showFirstImage = true)` as a card modal — `showFirstImage` decides whether the page repeats the first slide's image, which only What's New shows above its own link.
|
||||
|
||||
### Shared card chrome
|
||||
|
||||
`Modifier.bannerCard(onTap)` (size state, gradient brush, `heightIn`, `clip`, `background`, `clickable`) and `BannerDismissButton` are declared separately from `GetStakeBanner` so other banners can adopt the same chrome; the paddings stay with the caller. The gradient reuses `gradientPoints`, `lightStops` and `darkStops` from `views/newchat/OnboardingCards.kt`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user