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 b05e0696e3..442a2990a7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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, 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) diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift new file mode 100644 index 0000000000..0ee07ea828 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -0,0 +1,111 @@ +// +// 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 + @Environment(\.colorScheme) var colorScheme: ColorScheme + 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("Invest in SimpleX Chat!") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Crowdfunding on Wefunder") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: 6) + Image(colorScheme == .light ? "decentralized" : "decentralized-light") + .resizable() + .scaledToFit() + .frame(width: 37, height: 37) + .padding(.trailing, showDismiss ? 32 : 10) + } + .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 ba87729012..92dbd06091 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -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,16 +835,17 @@ struct GetStakeView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel var fromSettings: Bool + var showFirstImage: Bool var body: some View { ZoomablePageView { VStack(alignment: .leading, spacing: 18) { - Text(verbatim: "Get a stake in\nSimpleX Chat") + Text(verbatim: "Invest in\nSimpleX Chat") .font(.largeTitle) .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 ae317cd864..09d6c98f76 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -13,6 +13,8 @@ import SimpleXChat let simplexTeamURL = URL(string: "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im")! +let simplexNewsURL = URL(string: "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im")! + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String @@ -56,6 +58,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 +121,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 +154,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 +392,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") } @@ -434,6 +442,15 @@ struct SettingsView: View { } Section(header: Text("Contact").foregroundColor(theme.colors.secondary)) { + settingsRow("antenna.radiowaves.left.and.right", color: theme.colors.secondary) { + Button("Follow SimpleX Network News") { + dismiss() + DispatchQueue.main.async { + ChatModel.shared.appOpenUrl = simplexNewsURL + } + } + } + .disabled(chatModel.chatRunning != true) settingsRow("number", color: theme.colors.secondary) { Button("Send questions and ideas") { dismiss() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 119e593080..66b884fc7e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; @@ -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 */, @@ -2081,7 +2085,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2106,7 +2110,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2131,7 +2135,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2156,7 +2160,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2173,11 +2177,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2193,11 +2197,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2218,7 +2222,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2233,7 +2237,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2255,7 +2259,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2270,7 +2274,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2292,7 +2296,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2318,7 +2322,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2343,7 +2347,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2370,7 +2374,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2397,7 +2401,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2412,7 +2416,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2431,7 +2435,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 351; + CURRENT_PROJECT_VERSION = 359; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2446,7 +2450,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0.2; + MARKETING_VERSION = 7.0.3; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; 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 26a36e75bb..d45675f78c 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/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 9e059afa14..64d7a03cf7 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -100,12 +100,18 @@ - - - - - - + + + + + + + + + + + + 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 f9438fca32..f9be4a8d0b 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 @@ -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" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 7e66c15937..da64862c03 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -622,7 +622,7 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) - if (result[i].text.isEmpty()) result.removeLast() + if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } return result.ifEmpty { null } } 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 68fa25d553..9eb1e3a0b4 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 @@ -742,7 +742,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } else { withBGApi { chatModel.appOpenUrlConnecting.value = true - planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false }) + planAndConnect(rhId, uri, close = { ModalManager.closeAllModalsEverywhere() }, cleanup = { chatModel.appOpenUrlConnecting.value = false }) } } } @@ -912,6 +912,10 @@ private fun BoxScope.ChatList(searchText: MutableState, 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, 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 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..d383366dd4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -0,0 +1,133 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.Image +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) + .padding(start = 16.dp, end = if (showDismiss) 40.dp else 18.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Column(Modifier.weight(1f), 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 + ) + } + Image( + painterResource(if (isInDarkTheme()) MR.images.decentralized_light else MR.images.decentralized), + contentDescription = null, + modifier = Modifier.size(37.dp) + ) + } + + 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 26007c74af..1d7effbe32 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 @@ -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() } } } 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 ea7d935efe..0c2637ade6 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 @@ -985,7 +985,7 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } -private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat" +private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat?utm_source=app" private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im" @@ -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 = 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 @@ -1149,10 +1149,10 @@ fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) { // in the center of the window the page is wide enough for the title to fit on one line - val title = "Get a stake in\nSimpleX Chat" + val title = "Invest 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 ) 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 8c134cb361..493fd4d196 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 @@ -73,6 +73,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( val simplexTeamUri = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +val simplexNewsUri = + "simplex:/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA?h=smp18.simplex.im" + @Composable fun SettingsLayout( stopped: Boolean, @@ -119,7 +122,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) } } ) } } @@ -148,6 +151,7 @@ fun HelpAndSupportView( SectionView(stringResource(MR.strings.settings_section_title_contact)) { if (!chatModel.desktopNoUserNoRemote) { + SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) 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 7c1c7ad44f..e130372434 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1035,6 +1035,7 @@ Markdown in messages Send questions and ideas Send us email + Follow SimpleX Network News SimpleX Lock Chat console Message servers @@ -2745,6 +2746,8 @@ Crowdfunding on Wefunder. Crowdfunding on Wefunder Learn more on Wefunder + Invest in SimpleX Chat! + Crowdfunding on Wefunder SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index d6663a731e..2cdcfe90bf 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,11 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0.2 -android.version_code=375 +android.version_name=7.0.3 +android.version_code=381 -desktop.version_name=7.0.2 -desktop.version_code=159 +desktop.version_name=7.0.3 +desktop.version_code=164 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 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 f808cf31ba..2359dae402 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 | diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg index 715a6daf99..93f959845f 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg index f70ecb91db..d0de67a017 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg index 48bfe6caa7..782d2b3823 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg index 2bab828717..b489708eca 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg index 704cae8710..56c0b20a37 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg index d7c1f4e088..1406f27805 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg index e39e0836cb..17e4bc94ca 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg index ce9b4a86ef..99d7484555 100644 Binary files a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index b491917b32..9526f080b2 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.2", + "version": "7.0.3", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index be8cf8108c..cc6b5b4cbd 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.2'; +const RELEASE_TAG = 'v7.0.3'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index fe2e0c50cd..8337c0102f 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.2" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.2" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.0.3" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.0.3" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/plans/2026-09-23-android-app-links-exact-paths.md b/plans/2026-09-23-android-app-links-exact-paths.md new file mode 100644 index 0000000000..605f540924 --- /dev/null +++ b/plans/2026-09-23-android-app-links-exact-paths.md @@ -0,0 +1,86 @@ +# Fix Android app opening simplex.chat web pages as connection links + +## Problem + +Tapping `https://simplex.chat/crowdfunding/` in a chat and confirming "Open" in the +"Open web link?" alert shows "Invalid connection link" instead of opening the page +in the browser. `/call/`, `/credits/` and `/about/` are affected the same way. iOS and +desktop are not. + +## Cause + +"Open" calls `safeOpenUri` → `uriHandler.openUri` (`TextItemView.kt`), which on Android +fires an `ACTION_VIEW` intent. The app's own verified app-link filter in +`android/src/main/AndroidManifest.xml` matches the `simplex.chat` host with: + +```xml + + + + + + +``` + +`pathPrefix` is a plain string prefix, so `/c` also matches `/crowdfunding/`, `/call/` +and `/credits/`, and `/a` matches `/about/`. The single-letter prefixes were added +for short links in #5824. Android delivers the intent back to SimpleX, `processIntent` +sets `appOpenUrl`, and `connectIfOpenedViaUri` → `planAndConnect` tries to connect via +the web page URL and fails. + +iOS is not affected: `website/src/.well-known/apple-app-site-association` lists exact +components (`/c` and `/c/*`, etc.), not string prefixes. Desktop registers no https +handler. + +## Fix + +Make the Android filter match the iOS one: replace each `pathPrefix="/X"` with an exact +`path="/X"` plus `pathPrefix="/X/"`. + +```xml + + +``` + +Connection links carry their data in the fragment (`/contact#/?v=…`, `/a#key`), which +is not part of the matched path, so they still match the exact path. Links with a +trailing slash or a subpath (`/contact/#…`, `/c/…`) match the `/X/` prefix, the same as +`/X/*` on iOS. + +App-link verification (`autoVerify`, `assetlinks.json`) is per host and is not changed +by the path list. + +## Alternatives considered + +- Detecting non-connection URLs in `processIntent` and re-opening them in the browser: + a second `ACTION_VIEW` resolves back to the same filter, so the app would need an + explicit browser component. That still sends every such link through SimpleX first, + including links tapped in other apps. +- `pathPattern`: it has no anchors or alternation that `path` + `pathPrefix` doesn't + already cover, and it is harder to read. + +## Verification + +Checked on an API 27 emulator using stub APKs that contain only the app's intent +filter, with `cmd package query-activities -a android.intent.action.VIEW -c +android.intent.category.BROWSABLE -d `: + +| URL | before | after | +|---|---|---| +| `https://simplex.chat/crowdfunding/` | SimpleX + browser | browser | +| `https://simplex.chat/call/` | SimpleX + browser | browser | +| `https://simplex.chat/credits/` | SimpleX + browser | browser | +| `https://simplex.chat/about/` | SimpleX + browser | browser | +| `https://simplex.chat/contacts` | SimpleX + browser | browser | +| `https://simplex.chat/blog/` | browser | browser | +| `https://simplex.chat/contact#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/contact/#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/invitation#/?v=1` | SimpleX + browser | SimpleX + browser | +| `https://smp4.simplex.im/a#abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/c#abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/c/abc` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/g#x` | SimpleX + browser | SimpleX + browser | +| `https://simplex.chat/i#x` | SimpleX + browser | SimpleX + browser | + +The built arm64 debug APK's manifest was dumped with `aapt2` and contains the new +`path` / `pathPrefix` entries. diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 0c8396e5db..71192f34e2 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.3.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

+
    +
  • Promote subscribers to contributors.
  • +
  • Publish your channel on your website.
  • +
  • Verify security code with contributors.
  • +
  • Wider messages, easier to read.
  • +
  • Host your own chat relays.
  • +
+

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index d0f121c6ea..5d18b226a8 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -148,8 +148,9 @@ for os_pair in ${oses}; do -t "${container_name}" \ sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' - # Also restore git to previous state + # Also restore git to previous state + re-initialize submodules git reset --hard && git clean -dfx + git submodule update --init --recursive # Stop containers, delete images docker stop "${container_name}" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index e34f071fd0..4f993b9230 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -272,7 +272,8 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes { agentConfig = testAgentCfg { quotaExceededTimeout = 1, - messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} + messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval}, + persistErrorInterval = 0 } } opts' = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 37827ab2ad..0be5d858a1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3838,6 +3838,7 @@ setupDesynchronizedRatchet ps alice = do bob <# "#team alice> 3" bob #> "#team 4" alice <# "#team bob> 4" + threadDelay 500000 withTestChat ps "bob_old" $ \bob -> do bob <## "subscribed 2 connections on server localhost" bob ##> "/sync #team alice"