diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 3d152a0610..ea8e911891 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -69,3 +69,7 @@ Libraries/ Shared/MyPlayground.playground/* testpush.sh + +# Local build config and generated assets +Local.xcconfig +Shared/SimpleXAssets.xcassets/*.imageset diff --git a/apps/ios/Debug.xcconfig b/apps/ios/Debug.xcconfig new file mode 100644 index 0000000000..7f0389c760 --- /dev/null +++ b/apps/ios/Debug.xcconfig @@ -0,0 +1,2 @@ +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; +#include? "Local.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index fb6a6ed40d..1e987f655e 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -85,6 +85,27 @@ Workflow: - `Product > Export Localizations` - Export XLIFF files - `Product > Import Localizations` - Import updated translations +## SimpleX Assets + +The app includes optional assets behind the `SIMPLEX_ASSETS` Swift compilation flag. Without setup, the app builds normally without them. + +### Setup + +Create `Local.xcconfig` (gitignored) in the `apps/ios/` directory: +``` +SIMPLEX_ASSETS_DIR = /path/to/assets +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) SIMPLEX_ASSETS +``` + +The copy script (`scripts/ios/copy-assets.sh`) runs as a build phase on each build but exits immediately if `SIMPLEX_ASSETS` is not set. + +### Updating assets + +When source images change, regenerate resized images (requires ImageMagick): +```bash +cd path/to/assets && ./resize.sh +``` + ## Background Capabilities Configured in Info.plist: diff --git a/apps/ios/Release.xcconfig b/apps/ios/Release.xcconfig new file mode 100644 index 0000000000..234f81e782 --- /dev/null +++ b/apps/ios/Release.xcconfig @@ -0,0 +1 @@ +#include? "Local.xcconfig" diff --git a/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 3050b0d4cd..967fedf293 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -38,8 +38,9 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case favorites = 1 case contacts = 2 case groups = 3 - case business = 4 - case notes = 5 + case channels = 4 + case business = 5 + case notes = 6 var id: Int { rawValue } @@ -293,36 +294,40 @@ struct ChatListView: View { @ToolbarContentBuilder var topToolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } - ToolbarItem(placement: .principal) { SubsStatusIndicator() } + ToolbarItem(placement: .principal) { if !shouldShowOnboarding { SubsStatusIndicator() } } ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem } } - + @ToolbarContentBuilder var bottomToolbar: some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItem(placement: .bottomBar) { HStack { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } .contentShape(Rectangle()) .onTapGesture { scrollToSearchBar = true } } } - + @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } } - + @ViewBuilder var leadingToolbarItem: some View { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { @@ -348,7 +353,34 @@ struct ChatListView: View { } } - private var chatList: some View { + private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations + } + + private var hasConversations: Bool { + chatModel.chats.contains { chat in + switch chat.chatInfo { + case .local: return false + case let .direct(contact): return !contact.chatDeleted && !contact.isContactCard + case .group: return true + case .contactRequest: return false + case .contactConnection: return false + case .invalidJSON: return false + } + } + } + + @ViewBuilder private var chatList: some View { + if shouldShowOnboarding { + ConnectOnboardingView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .modifier(ThemedBackground()) + } else { + chatListContent + } + } + + private var chatListContent: some View { let cs = filteredChats() return ZStack { ScrollViewReader { scrollProxy in @@ -395,8 +427,8 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - if !addressCreationCardShown { - AddressCreationCard() + if !addressCreationCardShown && hasConversations { + ConnectBannerCard() .padding(.vertical, 6) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) @@ -881,6 +913,7 @@ struct TagsView: View { case .favorites: (active ? "star.fill" : "star", "Favorites") case .contacts: (active ? "person.fill" : "person", "Contacts") case .groups: (active ? "person.2.fill" : "person.2", "Groups") + case .channels: (active ? "antenna.radiowaves.left.and.right.circle.fill" : "antenna.radiowaves.left.and.right.circle", "Channels") case .business: (active ? "briefcase.fill" : "briefcase", "Businesses") case .notes: (active ? "folder.fill" : "folder", "Notes") } @@ -924,7 +957,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: C } case .groups: switch chatInfo { - case let .group(groupInfo, _): groupInfo.businessChat == nil + case let .group(groupInfo, _): groupInfo.businessChat == nil && !groupInfo.isChannel + default: false + } + case .channels: + switch chatInfo { + case let .group(groupInfo, _): groupInfo.isChannel default: false } case .business: diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 87cf377623..3ce4d1fa40 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct AddGroupView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @@ -66,29 +67,39 @@ struct AddGroupView: View { func createGroupView() -> some View { List { Group { - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + HStack(spacing: 0) { + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } - } - editImageButton { showChooseSource = true } - .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + } + .frame(maxWidth: .infinity) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "create-group" : "create-group-light") + .resizable() + .scaledToFit() + .frame(height: 140) + .frame(maxWidth: .infinity) + #endif } - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity) } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) Section { groupNameTextField() diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 8e62923f3f..177f8761f4 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -55,7 +55,7 @@ struct NewChatSheet: View { let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true } let v = NavigationView { viewBody(showArchive) - .navigationTitle("New message") + .navigationTitle("New chat") .navigationBarTitleDisplayMode(.large) .navigationBarHidden(searchMode) .modifier(ThemedBackground(grouped: true)) @@ -99,9 +99,8 @@ struct NewChatSheet: View { Section { NavigationLink(isActive: $isAddContactActive) { NewChatView(selection: .invite) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) { isAddContactActive = true @@ -109,9 +108,8 @@ struct NewChatSheet: View { } NavigationLink(isActive: $isScanPasteLinkActive) { NewChatView(selection: .connect, showQRCodeScanner: true) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) { isScanPasteLinkActive = true @@ -131,7 +129,7 @@ struct NewChatSheet: View { .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { - Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right") } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 7a5e8fbbc1..fab8f8a143 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -80,6 +80,7 @@ struct NewChatView: View { @EnvironmentObject var theme: AppTheme @State var selection: NewChatOption @State var showQRCodeScanner = false + var onboarding: Bool = false @State private var invitationUsed: Bool = false @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil) @State private var showShortLink = true @@ -91,17 +92,19 @@ struct NewChatView: View { var body: some View { VStack(alignment: .leading) { - Picker("New chat", selection: $selection) { - Label("1-time link", systemImage: "link") - .tag(NewChatOption.invite) - Label("Connect via link", systemImage: "qrcode") - .tag(NewChatOption.connect) - } - .pickerStyle(.segmented) - .padding() - .onChange(of: $selection.wrappedValue) { opt in - if opt == NewChatOption.connect { - showQRCodeScanner = true + if !onboarding { + Picker("New chat", selection: $selection) { + Label("1-time link", systemImage: "link") + .tag(NewChatOption.invite) + Label("Connect via link", systemImage: "qrcode") + .tag(NewChatOption.connect) + } + .pickerStyle(.segmented) + .padding() + .onChange(of: $selection.wrappedValue) { opt in + if opt == NewChatOption.connect { + showQRCodeScanner = true + } } } @@ -116,7 +119,7 @@ struct NewChatView: View { } } if case .connect = selection { - ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) + ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert, onboarding: onboarding) .transition(.move(edge: .trailing)) } } @@ -141,16 +144,22 @@ struct NewChatView: View { } default: () } - } + }, + including: onboarding ? .subviews : .all ) } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - InfoSheetButton { - AddContactLearnMore(showTitle: true) + if !onboarding { + InfoSheetButton { + AddContactLearnMore(showTitle: true) + } + } else { + Image(systemName: "info.circle").opacity(0) } } } + .if(onboarding) { $0.navigationBarTitleDisplayMode(.inline) } .modifier(ThemedBackground(grouped: true)) .onChange(of: invitationUsed) { used in if used && !(m.showingInvitation?.connChatUsed ?? true) { @@ -179,7 +188,8 @@ struct NewChatView: View { contactConnection: $contactConnection, connLinkInvitation: $connLinkInvitation, showShortLink: $showShortLink, - choosingProfile: $choosingProfile + choosingProfile: $choosingProfile, + onboarding: onboarding ) } else if creatingConnReq { creatingLinkProgressView() @@ -239,6 +249,7 @@ private func incognitoProfileImage() -> some View { } private struct InviteView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @@ -246,18 +257,19 @@ private struct InviteView: View { @Binding var connLinkInvitation: CreatedConnLink @Binding var showShortLink: Bool @Binding var choosingProfile: Bool + var onboarding: Bool = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { List { - Section(header: Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)) { + Section(header: sectionHeader) { shareLinkView() } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) qrCodeView() - if let selectedProfile = chatModel.currentUser { + if !onboarding, let selectedProfile = chatModel.currentUser { Section { NavigationLink { ActiveProfilePicker( @@ -281,9 +293,9 @@ private struct InviteView: View { } header: { Text("Share profile").foregroundColor(theme.colors.secondary) } footer: { - if incognitoDefault { - Text("A new random profile will be shared.") - } + if incognitoDefault { + Text("A new random profile will be shared.") + } } } } @@ -295,16 +307,52 @@ private struct InviteView: View { } } + private var sectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "one-time-link" : "one-time-link-small") + : (onboarding ? "one-time-link-light" : "one-time-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + sectionHeaderText + } + .padding(.bottom, 6) + #else + sectionHeaderText + .if(onboarding) { $0.padding(.bottom, 6) } + #endif + } + + @ViewBuilder private var sectionHeaderText: some View { + if onboarding { + Text("Send the link via any messenger - it's secure. Ask to paste into SimpleX.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary) + } + } + private func shareLinkView() -> some View { - HStack { + HStack(spacing: 8) { let link = connLinkInvitation.simplexChatUri(short: showShortLink) linkTextView(link) + Button { + UIPasteboard.general.string = link + setInvitationUsed() + } label: { + Image(systemName: "doc.on.doc") + .padding(.top, -7) + .padding(.horizontal, 8) + } Button { showShareSheet(items: [link]) setInvitationUsed() } label: { Image(systemName: "square.and.arrow.up") .padding(.top, -7) + .padding(.horizontal, 8) } } .frame(maxWidth: .infinity) @@ -324,7 +372,11 @@ private struct InviteView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } header: { - ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + if onboarding { + Text("Or show QR in person or via video call.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + } } } @@ -587,20 +639,24 @@ private struct ActiveProfilePicker: View { } private struct ConnectView: View { + @Environment(\.colorScheme) var colorScheme @StateObject private var connectProgressManager = ConnectProgressManager.shared @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? + var onboarding: Bool = false @State var scannerPaused: Bool = false @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { List { - Section(header: Text("Paste the link you received").foregroundColor(theme.colors.secondary)) { + Section(header: connectSectionHeader) { pasteLinkView() } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) { ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode) } @@ -630,7 +686,7 @@ private struct ConnectView: View { } } } label: { - Text("Tap to paste link") + Text("Tap to paste link").foregroundColor(theme.colors.primary) } .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) @@ -669,6 +725,23 @@ private struct ConnectView: View { } } + private var connectSectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "connect-via-link" : "connect-via-link-small") + : (onboarding ? "connect-via-link-light" : "connect-via-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + } + .padding(.bottom, 4) + #else + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + #endif + } + private func connect(_ link: String) { scannerPaused = true planAndConnect( @@ -765,7 +838,7 @@ struct ScannerInView: View { } -private func linkTextView(_ link: String) -> some View { +func linkTextView(_ link: String) -> some View { Text(link) .lineLimit(1) .font(.caption) diff --git a/apps/ios/Shared/Views/NewChat/OnboardingCards.swift b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift new file mode 100644 index 0000000000..913fdf5577 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift @@ -0,0 +1,309 @@ +// +// OnboardingCards.swift +// SimpleX (iOS) +// +// Created by simplex-chat on 06.04.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// MARK: - Card component + +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + let imageName: String + let icon: String + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil + let labelHeightRatio: CGFloat + let action: () -> Void + + static let lightStops: [Gradient.Stop] = [ + .init(color: Color(red: 0.824, green: 0.910, blue: 1.0), location: 0.0), + .init(color: Color(red: 0.800, green: 0.914, blue: 1.0), location: 0.5), + .init(color: Color(red: 0.875, green: 1.0, blue: 1.0), location: 0.9), + .init(color: Color(red: 1.0, green: 0.988, blue: 0.918), location: 1.0) + ] + + static let darkStops: [Gradient.Stop] = [ + .init(color: Color(red: 0.016, green: 0.039, blue: 0.141), location: 0.4), + .init(color: Color(red: 0.220, green: 0.329, blue: 0.671), location: 0.72), + .init(color: Color(red: 0.659, green: 0.929, blue: 0.953), location: 0.9), + .init(color: Color(red: 1.0, green: 0.965, blue: 0.878), location: 1.0) + ] + + static let gradientAngle: Double = 80.0 * .pi / 180.0 + + static func gradientPoints(aspectRatio: CGFloat, scale: CGFloat) -> (start: UnitPoint, end: UnitPoint) { + let r = Double(aspectRatio) + let s = Double(scale) + let dx = cos(gradientAngle) + let dy = -sin(gradientAngle) / r + let dLenSq = dx * dx + dy * dy + let projections = [ + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ] + let tMin = projections.min()! + let tMax = projections.max()! + let startX = 0.5 + tMin * dx / dLenSq + let startY = 0.5 + tMin * dy / dLenSq + let endX = 0.5 + tMax * dx / dLenSq + let endY = 0.5 + tMax * dy / dLenSq + return ( + start: .init(x: 0.5 + (startX - 0.5) * s, y: 0.5 + (startY - 0.5) * s), + end: .init(x: 0.5 + (endX - 0.5) * s, y: 0.5 + (endY - 0.5) * s) + ) + } + + var body: some View { + Button(action: action) { + GeometryReader { geo in + let labelHeight = geo.size.width * labelHeightRatio + let imageHeight = max(geo.size.height - labelHeight, 1) + let imageAspect = imageHeight / geo.size.width + let gp = Self.gradientPoints(aspectRatio: imageAspect, scale: colorScheme == .light ? 1.2 : 1.5) + VStack(spacing: 0) { + ZStack { + LinearGradient( + stops: colorScheme == .light ? Self.lightStops : Self.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + #else + Image(systemName: icon) + .font(.system(size: imageHeight * 0.25)) + .foregroundColor(theme.colors.primary) + #endif + } + .frame(height: imageHeight) + + labelRow(height: labelHeight) + } + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + .buttonStyle(.plain) + } + + private func labelRow(height: CGFloat) -> some View { + VStack { + HStack { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let subtitle { + Text(subtitle) + .font(.footnote) + .foregroundColor(theme.colors.onBackground.opacity(0.7)) + } + } + .frame(height: height) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 16) + .background(ToolbarMaterial.material(toolbarMaterial)) + } +} + +// MARK: - Onboarding pager + +private let backButtonHeight: CGFloat = 44 + +struct ConnectOnboardingView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var currentPage = 0 + @State private var showConnectViaLink = false + @State private var showInviteSomeone = false + @State private var showCreateAddress = false + + var body: some View { + TabView(selection: $currentPage) { + talkToSomeonePage.tag(0) + connectWithSomeonePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .sheet(isPresented: $showConnectViaLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showInviteSomeone) { + NavigationView { + NewChatView(selection: .invite, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showCreateAddress) { + NavigationView { + UserAddressView(autoCreate: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + } + + @ViewBuilder + private func cardPair( + _ geo: GeometryProxy, + @ViewBuilder card1: () -> C1, + @ViewBuilder card2: () -> C2 + ) -> some View { + let padding: CGFloat = 20 + let spacing: CGFloat = 20 + let isLandscape = verticalSizeClass == .compact + let cardWidth = isLandscape + ? (geo.size.width - padding * 2 - spacing) / 2 + : geo.size.width - padding * 2 + let maxCardHeight = cardWidth * 0.75 + + if isLandscape { + HStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } else { + VStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } + } + + // MARK: Screen 1 + + @ViewBuilder + private func pageHeader(_ title: LocalizedStringKey, showBack: Bool) -> some View { + let isLandscape = verticalSizeClass == .compact + let titleView = Text(title) + .font(.largeTitle) + .bold() + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity, alignment: .center) + if isLandscape { + ZStack(alignment: .leading) { + if showBack { backButton } + titleView + } + .padding(.horizontal, 16) + } else { + VStack(spacing: 0) { + if showBack { + backButton.frame(maxWidth: .infinity, alignment: .leading) + } else { + Color.clear.frame(height: backButtonHeight) + } + titleView + } + .padding(.horizontal, 16) + } + } + + private var backButton: some View { + Button { + withAnimation { currentPage = 0 } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Back") + } + } + .frame(height: backButtonHeight) + } + + // MARK: Screen 1 + + private var talkToSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Talk to someone", showBack: false) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link.badge.plus", + title: "Let someone connect to you", + labelHeightRatio: 0.132, + action: { withAnimation { currentPage = 1 } } + ) + } card2: { + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode.viewfinder", + title: "Connect via link or QR code", + labelHeightRatio: 0.132, + action: { showConnectViaLink = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: Screen 2 + + private var connectWithSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Connect with someone", showBack: true) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-invite-someone-privately-alpha", + icon: "link.badge.plus", + title: "Invite someone privately", + subtitle: "A link for one person to connect", + labelHeightRatio: 0.195, + action: { showInviteSomeone = true } + ) + } card2: { + OnboardingCardView( + imageName: "card-create-your-public-address-alpha", + icon: "qrcode", + title: m.userAddress != nil ? "Your public address" : "Create your public address", + subtitle: "For anyone to reach you", + labelHeightRatio: 0.195, + action: { showCreateAddress = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift deleted file mode 100644 index f22d59fcac..0000000000 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// AddressCreationCard.swift -// SimpleX (iOS) -// -// Created by Diogo Cunha on 13/11/2024. -// Copyright © 2024 SimpleX Chat. All rights reserved. -// -// Spec: spec/client/navigation.md - -import SwiftUI -import SimpleXChat - -struct AddressCreationCard: View { - @EnvironmentObject var theme: AppTheme - @EnvironmentObject private var chatModel: ChatModel - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false - @State private var showAddressCreationAlert = false - @State private var showAddressSheet = false - @State private var showAddressInfoSheet = false - - var body: some View { - let addressExists = chatModel.userAddress != nil - let chats = chatModel.chats.filter { chat in - !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard - } - ZStack(alignment: .topTrailing) { - HStack(alignment: .top, spacing: 16) { - let envelopeSize = dynamicSize(userFont).profileImageSize - Image(systemName: "envelope.circle.fill") - .resizable() - .frame(width: envelopeSize, height: envelopeSize) - .foregroundColor(.accentColor) - VStack(alignment: .leading) { - Text("Your SimpleX address") - .font(.title3) - Spacer() - Text("How to use it") + textSpace + Text(Image(systemName: "info.circle")).foregroundColor(theme.colors.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - VStack(alignment: .trailing) { - Image(systemName: "multiply") - .foregroundColor(theme.colors.secondary) - .onTapGesture { - showAddressCreationAlert = true - } - Spacer() - Text("Create") - .foregroundColor(.accentColor) - .onTapGesture { - showAddressSheet = true - } - } - } - .onTapGesture { - showAddressInfoSheet = true - } - .padding() - .background(theme.appColors.sentMessage) - .cornerRadius(12) - .frame(height: dynamicSize(userFont).rowHeight) - .alert(isPresented: $showAddressCreationAlert) { - Alert( - title: Text("SimpleX address"), - message: Text("Tap Create SimpleX address in the menu to create it later."), - dismissButton: .default(Text("Ok")) { - withAnimation { - addressCreationCardShown = true - } - } - ) - } - .sheet(isPresented: $showAddressSheet) { - NavigationView { - UserAddressView(autoCreate: true) - .navigationTitle("SimpleX address") - .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) - } - } - .sheet(isPresented: $showAddressInfoSheet) { - NavigationView { - UserAddressLearnMore(showCreateAddressButton: true) - .navigationTitle("Address or 1-time link?") - .navigationBarTitleDisplayMode(.inline) - .modifier(ThemedBackground(grouped: true)) - } - } - .onChange(of: addressExists) { exists in - if exists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onChange(of: chats.count) { size in - if size >= 3, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onAppear { - if addressExists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - } -} - -#Preview { - AddressCreationCard() -} diff --git a/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift new file mode 100644 index 0000000000..460ab9b141 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift @@ -0,0 +1,113 @@ +// +// ConnectBannerCard.swift +// SimpleX (iOS) +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let bannerImageRatio: CGFloat = 800 / 505 + +struct ConnectBannerCard: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @State private var showNewLink = false + @State private var showPasteLink = false + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + Button { + withAnimation { addressCreationCardShown = true } + } label: { + Image(systemName: "multiply") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(theme.colors.secondary) + .frame(width: 30, height: 30) + .background(theme.colors.onBackground.opacity(0.08), in: Circle()) + } + HStack(spacing: 2) { + bannerHalf( + imageName: "banner-create-link", + icon: "link.badge.plus", + title: "New 1-time link", + action: { showNewLink = true } + ) + bannerHalf( + imageName: "banner-paste-link", + icon: "qrcode.viewfinder", + title: "Paste link / Scan", + action: { showPasteLink = true } + ) + } + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .sheet(isPresented: $showNewLink) { + NavigationView { + NewChatView(selection: .invite) + .modifier(ThemedBackground(grouped: true)) + } + } + .sheet(isPresented: $showPasteLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + @ViewBuilder + private func bannerHalf(imageName: String, icon: String, title: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 0) { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + gradientFallback(icon: icon) + #endif + HStack(spacing: 8) { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.footnote) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ToolbarMaterial.material(toolbarMaterial)) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func gradientFallback(icon: String) -> some View { + let gp = OnboardingCardView.gradientPoints( + aspectRatio: 1 / bannerImageRatio, + scale: colorScheme == .light ? 1.2 : 1.5 + ) + ZStack { + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(theme.colors.primary) + } + .aspectRatio(bannerImageRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 8a7ab465d4..b7249f42ea 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -632,6 +632,38 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + VersionDescription( + version: "v6.5", + post: URL(string: "https://simplex.chat/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html"), + features: [ + .feature(Description( + icon: nil, + title: "Public channels - speak freely 🚀", + description: nil, + subfeatures: [ + ("antenna.radiowaves.left.and.right", "Reliability: many relays per channel."), + ("server.rack", "Ownership: you can run your own relays."), + ("key.2.on.ring", "Security: owners hold channel keys."), + ("person.badge.shield.checkmark", "Privacy: for owners and subscribers."), + ] + )), + .feature(Description( + icon: "link.badge.plus", + title: "Easier to invite your friends 👋", + description: "We made connecting simpler for new users." + )), + .feature(Description( + icon: "network.badge.shield.half.filled", + title: "Safe web links", + description: "- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking." + )), + .feature(Description( + icon: "network", + title: "Non-profit governance", + description: "To make SimpleX Network last." + )) + ] + ), ] private let lastVersion = versionDescriptions.last!.version diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index d40ec116f4..4df58f8b0c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -11,11 +11,13 @@ import MessageUI @preconcurrency import SimpleXChat struct UserAddressView: View { + @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + var onboarding: Bool = false @State private var showShortLink = true @State private var settings = AddressSettingsState() @State private var savedSettings = AddressSettingsState() @@ -54,6 +56,14 @@ struct UserAddressView: View { } } } + .if(onboarding) { v in + v.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Image(systemName: "info.circle").opacity(0) + } + } + .navigationBarTitleDisplayMode(.inline) + } .onAppear { if chatModel.userAddress == nil, autoCreate { createAddress() @@ -64,12 +74,16 @@ struct UserAddressView: View { private func userAddressView() -> some View { List { if let userAddress = chatModel.userAddress { - existingAddressView(userAddress) - .onAppear { - settings = AddressSettingsState(settings: userAddress.addressSettings) - savedSettings = AddressSettingsState(settings: userAddress.addressSettings) - } - } else { + if onboarding { + onboardingAddressView(userAddress) + } else { + existingAddressView(userAddress) + .onAppear { + settings = AddressSettingsState(settings: userAddress.addressSettings) + savedSettings = AddressSettingsState(settings: userAddress.addressSettings) + } + } + } else if !onboarding { Section { createAddressButton() } header: { @@ -121,8 +135,8 @@ struct UserAddressView: View { ) case .shareOnCreate: return Alert( - title: Text("Share address with contacts?"), - message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."), + title: Text("Share address with SimpleX contacts?"), + message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { setProfileAddress($progressIndicator, true) shareViaProfile = true @@ -157,7 +171,19 @@ struct UserAddressView: View { } addressSettingsButton(userAddress) } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light ? "simplex-address-small" : "simplex-address-small-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .padding(.top, -20) + ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + } + .padding(.bottom, 4) + #else ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + #endif } footer: { if settings.businessAddress { Text("Add your team members to the conversations.") @@ -184,6 +210,59 @@ struct UserAddressView: View { } } + @ViewBuilder private func onboardingAddressView(_ userAddress: UserContactLink) -> some View { + Section { + HStack(spacing: 8) { + let link = userAddress.connLinkContact.simplexChatUri(short: showShortLink) + linkTextView(link) + Button { UIPasteboard.general.string = link } label: { + Image(systemName: "doc.on.doc") + .padding(.top, -7) + .padding(.horizontal, 8) + } + Button { showShareSheet(items: [link]) } label: { + Image(systemName: "square.and.arrow.up") + .padding(.top, -7) + .padding(.horizontal, 8) + } + } + .frame(maxWidth: .infinity) + } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading) { + Image(colorScheme == .light ? "simplex-address" : "simplex-address-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + .padding(.bottom, 4) + #else + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + .padding(.bottom, 6) + #endif + } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) + + Section { + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink) + .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))") + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + Text("Or use this QR - print or show online.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + } + private func createAddressButton() -> some View { Button { createAddress() @@ -200,9 +279,22 @@ struct UserAddressView: View { DispatchQueue.main.async { if let connLinkContact { chatModel.userAddress = UserContactLink(connLinkContact) - alert = .shareOnCreate + let hasRelevantContacts = chatModel.chats.contains { chat in + if case let .direct(contact) = chat.chatInfo { + return contact.active && !contact.isContactCard && !contact.contactConnIncognito + } + return false + } + if hasRelevantContacts { + alert = .shareOnCreate + progressIndicator = false + } else { + setProfileAddress($progressIndicator, true) + shareViaProfile = true + } + } else { + progressIndicator = false } - progressIndicator = false } } catch let error { logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") @@ -486,15 +578,15 @@ struct UserAddressSettingsView: View { private func shareWithContactsButton() -> some View { settingsRow("person", color: theme.colors.secondary) { - Toggle("Share with contacts", isOn: $shareViaProfile) + Toggle("Share with SimpleX contacts", isOn: $shareViaProfile) .onChange(of: shareViaProfile) { on in if ignoreShareViaProfileChange { ignoreShareViaProfileChange = false } else { if on { showAlert( - NSLocalizedString("Share address with contacts?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + NSLocalizedString("Share address with SimpleX contacts?", comment: "alert title"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), @@ -516,7 +608,7 @@ struct UserAddressSettingsView: View { } else { showAlert( NSLocalizedString("Stop sharing address?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7dd3a6c6ed..ac9bb3ff43 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -225,7 +225,6 @@ B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; - B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; @@ -252,12 +251,16 @@ E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; + E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; }; E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; }; E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; }; + E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418002F83D2CA00252B9E /* OnboardingCards.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -596,7 +599,6 @@ B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; - B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; @@ -621,6 +623,10 @@ E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; + E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; + E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -675,6 +681,7 @@ E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = ""; }; E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = ""; }; + E5E418002F83D2CA00252B9E /* OnboardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCards.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -876,6 +883,8 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, + E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, @@ -901,6 +910,7 @@ 5C764E87279CBC8E000C6508 /* Model */, 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, + E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */, D7AA2C3429A936B400737B40 /* MediaEncryption.playground */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); @@ -947,7 +957,7 @@ 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */, 640743602CD360E600158442 /* ChooseServerOperators.swift */, - B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */, + E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */, ); path = Onboarding; sourceTree = ""; @@ -969,6 +979,7 @@ 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */, 640417CC2B29B8C200CCB412 /* NewChatView.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, + E5E418002F83D2CA00252B9E /* OnboardingCards.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, 647B15E72F4C8D2500EB431E /* AddChannelView.swift */, @@ -1236,6 +1247,7 @@ isa = PBXNativeTarget; buildConfigurationList = 5CA059F3279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (iOS)" */; buildPhases = ( + E5C0BBF02F82B50C00EA7527 /* Run Script */, 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, @@ -1426,6 +1438,7 @@ 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, + E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1457,12 +1470,35 @@ buildActionMask = 2147483647; files = ( E5DCF9712C590272007928CC /* Localizable.strings in Resources */, + E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + E5C0BBF02F82B50C00EA7527 /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${SRCROOT}/../../scripts/ios/copy-assets.sh\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 5CA059C6279559F40002BEB4 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -1475,11 +1511,11 @@ 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, + E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, - B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */, 5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */, @@ -1620,6 +1656,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */, + E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */, 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, @@ -1899,6 +1936,7 @@ /* Begin XCBuildConfiguration section */ 5CA059F1279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -1953,7 +1991,6 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1961,6 +1998,7 @@ }; 5CA059F2279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2bddf5b5b8..978e1d9630 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1", + "originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9", "pins" : [ { "identity" : "codescanner", diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 0401664d74..83b8d61ea1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2371,6 +2371,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var ready: Bool { get { true } } public var nextConnectPrepared: Bool { if let preparedGroup { !preparedGroup.connLinkStartedConnection } else { false } } public var profileChangeProhibited: Bool { preparedGroup?.connLinkPreparedConnection ?? false } + public var isChannel: Bool { groupProfile.isChannel } public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias } public var fullName: String { get { groupProfile.fullName } } public var shortDescr: String? { groupProfile.shortDescr } @@ -2499,6 +2500,8 @@ public struct GroupProfile: Codable, NamedChat, Hashable { set { memberAdmission = newValue } } + public var isChannel: Bool { publicGroup?.groupType == .channel } + public static let sampleData = GroupProfile( displayName: "team", fullName: "My Team" @@ -4864,10 +4867,7 @@ public enum MsgChatLink: Equatable, Hashable { public var iconName: String { switch self { case let .group(_, groupProfile): - switch groupProfile.publicGroup?.groupType { - case .channel: "antenna.radiowaves.left.and.right.circle.fill" - case .unknown, .none: "person.2.circle.fill" - } + groupProfile.isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.circle.fill" case let .contact(_, _, business): business ? "briefcase.circle.fill" : "person.crop.circle.fill" case .invitation: @@ -4878,10 +4878,7 @@ public enum MsgChatLink: Equatable, Hashable { public var smallIconName: String { switch self { case let .group(_, groupProfile): - switch groupProfile.publicGroup?.groupType { - case .channel: "antenna.radiowaves.left.and.right" - case .unknown, .none: "person.2" - } + groupProfile.isChannel ? "antenna.radiowaves.left.and.right" : "person.2" case let .contact(_, _, business): business ? "briefcase" : "person" case .invitation: @@ -4910,10 +4907,9 @@ public enum MsgChatLink: Equatable, Hashable { public func infoLine(signed: Bool) -> String { var s: String = switch self { case let .group(_, groupProfile): - switch groupProfile.publicGroup?.groupType { - case .channel: NSLocalizedString("Channel link", comment: "chat link info line") - case .unknown, .none: NSLocalizedString("Group link", comment: "chat link info line") - } + groupProfile.isChannel + ? NSLocalizedString("Channel link", comment: "chat link info line") + : NSLocalizedString("Group link", comment: "chat link info line") case let .contact(_, _, business): business ? NSLocalizedString("Business address", comment: "chat link info line") diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index 5d39eb29f2..bc00225c87 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -16,4 +16,7 @@ android/build android/release common/build desktop/build -release \ No newline at end of file +release + +# Generated SimpleX assets +common/src/commonMain/resources/assets/simplex/ \ No newline at end of file diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index a2e1eb07c3..64dd34afbf 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -25,6 +25,9 @@ buildscript { extra.set("application_id.suffix", prop["application_id.suffix"] ?: "") // Compression level for debug AND release apk. 0 = disable compression. Max is 9 extra.set("compression.level", (prop["compression.level"] as String?)?.toIntOrNull() ?: 0) + if (prop["simplex.assets.dir"] != null) { + extra.set("simplex.assets.dir", prop["simplex.assets.dir"]) + } // NOTE: If you need a different version of something, provide it in `local.properties` // like so: compose.version=123, or gradle.plugin.version=1.2.3, etc diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ea5579ed7d..65f0acd86c 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -11,6 +11,25 @@ plugins { group = "chat.simplex" version = extra["android.version_name"] as String +val simplexAssetsDir = rootProject.findProperty("simplex.assets.dir") as String? +val simplexAssetsLocal = file("src/commonMain/resources/assets/simplex") +val hasSimplexAssets = simplexAssetsDir != null + +if (simplexAssetsDir != null) { + val resolvedAssetsDir = rootProject.rootDir.resolve(simplexAssetsDir).absolutePath + tasks.register("copySimplexAssets") { + commandLine( + "${rootProject.rootDir}/../../scripts/android/copy-assets.sh", + resolvedAssetsDir, + simplexAssetsLocal.absolutePath + ) + } +} else { + tasks.register("cleanSimplexAssets") { + delete(simplexAssetsLocal) + } +} + kotlin { androidTarget() jvm("desktop") @@ -31,6 +50,11 @@ kotlin { } val commonMain by getting { + if (hasSimplexAssets) { + resources.srcDir(simplexAssetsLocal) + } else { + resources.srcDir("src/commonMain/resources/assets/default") + } dependencies { api(compose.runtime) api(compose.foundation) @@ -160,12 +184,18 @@ buildConfig { buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") + buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } afterEvaluate { tasks.named("generateMRcommonMain") { dependsOn("adjustFormatting") + if (hasSimplexAssets) { + dependsOn("copySimplexAssets") + } else { + dependsOn("cleanSimplexAssets") + } } tasks.create("adjustFormatting") { doLast { 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 d9439a5474..e1696fe37b 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 @@ -34,6 +34,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.ModalManager.Companion.fromEndToStartTransition import chat.simplex.common.views.helpers.ModalManager.Companion.fromStartToEndTransition import chat.simplex.common.views.localauth.VerticalDivider +import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @@ -383,7 +384,9 @@ fun CenterPartOfScreen() { } when (currentChatId.value) { null -> { - if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + if (shouldShowOnboarding()) { + ConnectOnboardingView() + } else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { Box( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 591dde89cd..bc81c958e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1157,10 +1157,10 @@ object ChatModel { showingInvitation.value = null chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId + ModalManager.start.closeModals() + ModalManager.end.closeModals() } } - ModalManager.start.closeModals() - ModalManager.end.closeModals() } } @@ -2094,6 +2094,7 @@ data class GroupInfo ( ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } + val isChannel: Boolean get() = groupProfile.isChannel override val displayName get() = localAlias.ifEmpty { groupProfile.displayName } override val fullName get() = groupProfile.fullName override val shortDescr get() = groupProfile.shortDescr @@ -2213,6 +2214,8 @@ data class GroupProfile ( val groupPreferences: GroupPreferences? = null, val memberAdmission: GroupMemberAdmission? = null ): NamedChat { + val isChannel: Boolean get() = publicGroup?.groupType == GroupType.Channel + companion object { val sampleData = GroupProfile( displayName = "team", @@ -4594,30 +4597,21 @@ sealed class MsgChatLink { val iconRes: ImageResource get() = when (this) { - is Group -> when (groupProfile.publicGroup?.groupType) { - GroupType.Channel -> MR.images.ic_bigtop_updates_circle_filled - else -> MR.images.ic_supervised_user_circle_filled - } + is Group -> if (groupProfile.isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled is Contact -> if (business) MR.images.ic_work_filled_padded else MR.images.ic_account_circle_filled is Invitation -> MR.images.ic_account_circle_filled } val smallIconRes: ImageResource get() = when (this) { - is Group -> when (groupProfile.publicGroup?.groupType) { - GroupType.Channel -> MR.images.ic_bigtop_updates - else -> MR.images.ic_group - } + is Group -> if (groupProfile.isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group is Contact -> if (business) MR.images.ic_work else MR.images.ic_person is Invitation -> MR.images.ic_person } fun infoLine(signed: Boolean): String { var s = when (this) { - is Group -> when (groupProfile.publicGroup?.groupType) { - GroupType.Channel -> generalGetString(MR.strings.chat_link_channel) - else -> generalGetString(MR.strings.chat_link_group) - } + is Group -> if (groupProfile.isChannel) generalGetString(MR.strings.chat_link_channel) else generalGetString(MR.strings.chat_link_group) is Contact -> if (business) generalGetString(MR.strings.chat_link_business_address) else generalGetString(MR.strings.chat_link_contact_address) is Invitation -> generalGetString(MR.strings.chat_link_one_time) } 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 7e0a7554ac..f62d4c262d 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 @@ -58,6 +58,7 @@ import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.util.concurrent.atomic.AtomicBoolean import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Date @@ -723,14 +724,20 @@ object ChatController { val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null if ((inProgress == null || inProgress.value) && alert != null) { return suspendCancellableCoroutine { cont -> + val resumed = AtomicBoolean(false) + fun safeResume(result: Result) { + if (resumed.compareAndSet(false, true)) { + cont.resumeWith(result) + } + } showRetryAlert( alert, onCancel = { - cont.resumeWith(Result.success(null)) + safeResume(Result.success(null)) }, onRetry = { withLongRunningApi { - cont.resumeWith( + safeResume( runCatching { coroutineScope { sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1) @@ -742,7 +749,7 @@ object ChatController { ) cont.invokeOnCancellation { - cont.resumeWith(Result.success(null)) + safeResume(Result.success(null)) } } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 88d9fbb705..cdd4140e3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -42,7 +42,9 @@ expect fun desktopOpenDir(dir: File) fun createURIFromPath(absolutePath: String): URI = URI.create(URLEncoder.encode(absolutePath, "UTF-8")) -fun URI.toFile(): File = File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:")) +fun URI.toFile(): File = + if (scheme == "file") File(this) + else File(URLDecoder.decode(rawPath, "UTF-8").removePrefix("file:")) fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 3a69f9c690..fbc17c5e56 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -666,6 +666,7 @@ val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp val DEFAULT_MIN_SECTION_ITEM_HEIGHT = 50.dp val DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL = 15.dp +val DEFAULT_WINDOW_WIDTH = 1366.dp val DEFAULT_START_MODAL_WIDTH = 388.dp val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp val DEFAULT_END_MODAL_WIDTH = 388.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 40a89dd8a6..114edeee3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -2339,7 +2339,7 @@ fun BoxScope.ChatItemsList( } val manager = LocalSelectionManager.current - val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, linkMode) else Modifier + val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, revealedItems, linkMode) else Modifier LazyColumnWithScrollBar( modifier.align(Alignment.BottomCenter), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 62cfdcbe65..95f51bf284 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1304,6 +1304,8 @@ fun ComposeView( composeState.value = cs.copy(inProgress = false, progressByTimeout = false) } else if (!cs.empty) { if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + recState.value = RecordingState.NotStarted + RecorderInterface.stopRecording?.invoke() composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) } if (saveLastDraft) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 9184071c07..0948551c7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -195,7 +195,7 @@ fun SendMsgView( ) } } - if (timedMessageAllowed) { + if (timedMessageAllowed && !cs.editing) { menuItems.add { ItemAction( generalGetString(MR.strings.disappearing_message), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 626aea0806..dee39dde7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -196,12 +196,13 @@ class SelectionManager { } } - fun getSelectedCopiedText(items: List, linkMode: SimplexLinkMode): String { + fun getSelectedCopiedText(items: List, revealedItems: Set, linkMode: SimplexLinkMode): String { val r = range ?: return "" val lo = minOf(r.startIndex, r.endIndex) val hi = maxOf(r.startIndex, r.endIndex) return (lo..hi).mapNotNull { idx -> val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + if (ci.meta.itemDeleted != null && (!revealedItems.contains(ci.id) || ci.isDeletedContent)) return@mapNotNull null val sel = selectedRange(range, idx) ?: return@mapNotNull null selectedItemCopiedText(ci, sel, linkMode) }.reversed().joinToString("\n") @@ -291,6 +292,7 @@ fun BoxScope.SelectionHandler( manager: SelectionManager, listState: State, mergedItems: State, + revealedItems: State>, linkMode: SimplexLinkMode ): Modifier { val touchSlop = LocalViewConfiguration.current.touchSlop @@ -311,7 +313,7 @@ fun BoxScope.SelectionHandler( manager.listState = listState manager.onCopySelection = { - clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, revealedItems.value, linkMode))) showToast(generalGetString(MR.strings.copied)) } 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 a42f66c6cf..8f836c3c29 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 @@ -24,7 +24,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize import chat.simplex.common.AppLock +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts @@ -46,7 +52,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds -enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES } +enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, CHANNELS, BUSINESS, NOTES } sealed class ActiveFilter { data class PresetTag(val tag: PresetTagKind) : ActiveFilter() @@ -234,53 +240,120 @@ private fun ChatListCard( } } +private const val BANNER_IMAGE_RATIO = 800f / 505f + @Composable -private fun AddressCreationCard() { - ChatListCard( - close = { - appPrefs.addressCreationCardShown.set(true) - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.simplex_address), - text = generalGetString(MR.strings.address_creation_instruction), +private fun BannerGradientBox(isDark: Boolean, content: @Composable () -> Unit) { + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val gp = gradientPoints(1f / BANNER_IMAGE_RATIO, scale) + var size by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) ) - }, - onCardClick = { - ModalManager.start.showModal { - UserAddressLearnMore(showCreateAddressButton = true) - } + } else { + Brush.linearGradient(colorStops = stops) } - ) { - Box(modifier = Modifier.matchParentSize().padding(end = (DEFAULT_PADDING_HALF + 2.dp) * fontSizeSqrtMultiplier, bottom = 2.dp), contentAlignment = Alignment.BottomEnd) { - TextButton( - onClick = { - ModalManager.start.showModalCloseable { close -> - UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) - } - }, - ) { - Text(stringResource(MR.strings.create_address_button), style = MaterialTheme.typography.body1) - } + } + Box( + Modifier.fillMaxWidth().aspectRatio(BANNER_IMAGE_RATIO).background(brush).onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ConnectBannerCard() { + val isDark = isInDarkTheme() + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + val buttonSize = 30.dp * fontSizeSqrtMultiplier + val gap = 3.dp * fontSizeSqrtMultiplier + + Column(horizontalAlignment = Alignment.End) { + IconButton( + onClick = { appPrefs.addressCreationCardShown.set(true) }, + modifier = Modifier.size(buttonSize) + ) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + modifier = Modifier + .size(buttonSize) + .background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f), CircleShape) + .padding(buttonSize * 0.15f), + tint = MaterialTheme.colors.secondary + ) } + Spacer(Modifier.height(gap)) Row( Modifier .fillMaxWidth() - .padding(DEFAULT_PADDING), - verticalAlignment = Alignment.CenterVertically + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(18.dp)) ) { - Box(Modifier.padding(vertical = 4.dp)) { - Box(Modifier.background(MaterialTheme.colors.primary, CircleShape).padding(12.dp)) { - ProfileImage(size = 37.dp, null, icon = MR.images.ic_mail_filled, color = Color.White, backgroundColor = Color.Red) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_create_link_light else MR.images.banner_create_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } - Column(modifier = Modifier.padding(start = DEFAULT_PADDING)) { - Text(stringResource(MR.strings.your_simplex_contact_address), style = MaterialTheme.typography.h3) - Spacer(Modifier.fillMaxWidth().padding(DEFAULT_PADDING_HALF)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.how_to_use_simplex_chat), Modifier.padding(end = DEFAULT_SPACE_AFTER_ICON), style = MaterialTheme.typography.body1) - Icon( - painterResource(MR.images.ic_info), - null, + Spacer(Modifier.width(2.dp).fillMaxHeight().background(MaterialTheme.colors.background)) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_paste_link_light else MR.images.banner_paste_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } } @@ -289,15 +362,31 @@ private fun AddressCreationCard() { @Composable private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState, listState: LazyListState) { - if (!chatModel.desktopNoUserNoRemote) { - ChatList(searchText = searchText, listState) + if (chatModel.chatRunning.value == null) { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } else if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) AndroidOnboardingCards() + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } } - if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { - Text( - stringResource( - if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats - ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary - ) +} + +@Composable +private fun AndroidOnboardingCards() { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPad = topPaddingToContent(false) + val bottomPad = if (oneHandUI.value) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier + } else { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + } + Box(Modifier.fillMaxSize().padding(top = topPad, bottom = bottomPad)) { + ConnectOnboardingView() } } @@ -454,31 +543,33 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow } }, title = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { - Text( - stringResource(MR.strings.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - ) - SubscriptionStatusIndicator( - click = { - ModalManager.start.closeModals() - val summary = serversSummary.value - ModalManager.start.showModalCloseable( - endButtons = { - if (summary != null) { - ShareButton { - val json = Json { - prettyPrint = true + if (!shouldShowOnboarding()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { + Text( + stringResource(MR.strings.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + SubscriptionStatusIndicator( + click = { + ModalManager.start.closeModals() + val summary = serversSummary.value + ModalManager.start.showModalCloseable( + endButtons = { + if (summary != null) { + ShareButton { + val json = Json { + prettyPrint = true + } + val text = json.encodeToString(PresentedServersSummary.serializer(), summary) + clipboard.shareText(text) } - val text = json.encodeToString(PresentedServersSummary.serializer(), summary) - clipboard.shareText(text) } } - } - ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } - } - ) + ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } + } + ) + } } }, onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null, @@ -860,14 +951,6 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } - if (!addressCreationCardShown.value) { - LaunchedEffect(chatModel.userAddress.value) { - if (chatModel.userAddress.value != null) { - appPrefs.addressCreationCardShown.set(true) - } - } - } - LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") } @@ -914,8 +997,8 @@ private fun ChatListFeatureCards() { if (!oneHandUICardShown.value && !oneHandUI.value) { ToggleChatListCard() } - if (!addressCreationCardShown.value) { - AddressCreationCard() + if (!addressCreationCardShown.value && hasConversations(chatModel.chats.value)) { + ConnectBannerCard() } if (!oneHandUICardShown.value && oneHandUI.value) { ToggleChatListCard() @@ -1236,7 +1319,11 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat else -> false } PresetTagKind.GROUPS -> when (chatInfo) { - is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null + is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null && !chatInfo.groupInfo.isChannel + else -> false + } + PresetTagKind.CHANNELS -> when (chatInfo) { + is ChatInfo.Group -> chatInfo.groupInfo.isChannel else -> false } PresetTagKind.BUSINESS -> when (chatInfo) { @@ -1255,6 +1342,7 @@ private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups + PresetTagKind.CHANNELS -> (if (active) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_bigtop_updates) to MR.strings.chat_list_channels PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index 096b6c55ac..c7553b6ed0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -64,7 +64,7 @@ private fun Modifier.androidBlurredModifier( } } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { @@ -110,7 +110,7 @@ private fun Modifier.desktopBlurredModifier( clip = blurRadius.value > 0 } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index d197460e2e..fa27672270 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -27,6 +27,8 @@ import chat.simplex.common.views.* import chat.simplex.common.views.chat.group.GroupLinkView import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* +import androidx.compose.ui.layout.ContentScale +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -99,22 +101,33 @@ fun AddGroupLayout( ) { ModalView(close = close) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId)) - Box( + AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId), bottomPadding = DEFAULT_PADDING_HALF) + Row( Modifier .fillMaxWidth() - .padding(bottom = 24.dp), - contentAlignment = Alignment.Center + .padding(vertical = DEFAULT_PADDING_HALF), + horizontalArrangement = if (BuildConfigCommon.SIMPLEX_ASSETS) Arrangement.SpaceEvenly else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Box(contentAlignment = Alignment.TopEnd) { - Box(contentAlignment = Alignment.Center) { - ProfileImage(108.dp, image = profileImage.value, icon = MR.images.ic_supervised_user_circle_filled) - EditImageButton { scope.launch { bottomSheetModalState.show() } } - } - if (profileImage.value != null) { - DeleteImageButton { profileImage.value = null } + Box(contentAlignment = Alignment.Center) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(128.dp, image = profileImage.value, icon = MR.images.ic_supervised_user_circle_filled) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) MR.images.create_group_light else MR.images.create_group), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(140.dp) + ) + } } Row(Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 292aa10f70..1eceaf4158 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -74,7 +74,7 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { Column(Modifier.align(Alignment.BottomCenter)) { DefaultAppBar( navigationButton = { NavigationButtonBack(onButtonClicked = close) }, - fixedTitleText = generalGetString(MR.strings.new_message), + fixedTitleText = generalGetString(MR.strings.new_chat), onTop = false, ) } @@ -359,7 +359,7 @@ private fun ModalData.NewChatSheetLayout( item { Box(Modifier.padding(top = blankSpaceSize)) { AppBarTitle( - stringResource(MR.strings.new_message), + stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f520a86999..72311cd7fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -21,9 +21,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -39,6 +41,7 @@ import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.* @@ -47,7 +50,7 @@ enum class NewChatOption { } @Composable -fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { +fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, onboarding: Boolean = false, close: () -> Unit) { val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } @@ -104,60 +107,71 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } } - BoxWithConstraints { + if (onboarding) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = selection.value.ordinal, - initialPageOffsetFraction = 0f - ) { NewChatOption.values().size } - KeyChangeEffect(pagerState.currentPage) { - selection.value = NewChatOption.values()[pagerState.currentPage] + Spacer(Modifier.height(DEFAULT_PADDING)) + when (selection.value) { + NewChatOption.INVITE -> PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq, onboarding = true) + NewChatOption.CONNECT -> ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close, onboarding = true) } - TabRow( - selectedTabIndex = pagerState.currentPage, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - tabTitles.forEachIndexed { index, it -> - LeadingIconTab( - selected = pagerState.currentPage == index, - onClick = { - scope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { Text(it, fontSize = 13.sp) }, - icon = { - Icon( - if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), - it - ) - }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) + SectionBottomSpacer() + } + } else { + BoxWithConstraints { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = selection.value.ordinal, + initialPageOffsetFraction = 0f + ) { NewChatOption.values().size } + KeyChangeEffect(pagerState.currentPage) { + selection.value = NewChatOption.values()[pagerState.currentPage] } - } - - HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> - Column( - Modifier - .fillMaxWidth() - .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), - verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + TabRow( + selectedTabIndex = pagerState.currentPage, + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.primary, ) { - Spacer(Modifier.height(DEFAULT_PADDING)) - when (index) { - NewChatOption.INVITE.ordinal -> { - PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) - } - NewChatOption.CONNECT.ordinal -> { - ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) - } + tabTitles.forEachIndexed { index, it -> + LeadingIconTab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(it, fontSize = 13.sp) }, + icon = { + Icon( + if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), + it + ) + }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.secondary, + ) + } + } + + HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> + Column( + Modifier + .fillMaxWidth() + .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + ) { + Spacer(Modifier.height(DEFAULT_PADDING)) + when (index) { + NewChatOption.INVITE.ordinal -> { + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) + } + NewChatOption.CONNECT.ordinal -> { + ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) + } + } + SectionBottomSpacer() } - SectionBottomSpacer() } } } @@ -165,12 +179,13 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } @Composable -private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState) { +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState, onboarding: Boolean = false) { if (connLinkInvitation.connFullLink.isNotEmpty()) { InviteView( rhId, connLinkInvitation = connLinkInvitation, contactConnection = contactConnection, + onboarding = onboarding, ) } else if (creatingConnReq.value) { CreatingLinkProgressView() @@ -448,23 +463,53 @@ fun ActiveProfilePicker( } @Composable -private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState) { +private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState, onboarding: Boolean = false) { val showShortLink = remember { mutableStateOf(true) } - Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.one_time_link_light else MR.images.one_time_link_small_light + } else { + if (onboarding) MR.images.one_time_link else MR.images.one_time_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } else { + Spacer(Modifier.height(10.dp)) + } + + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_send_1_time_link), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) - } - - Spacer(Modifier.height(DEFAULT_PADDING)) - - SectionViewWithButton( - stringResource(MR.strings.or_show_this_qr_code).uppercase(), - titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + Text( + stringResource(MR.strings.onboarding_or_show_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } else { + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + + SectionViewWithButton( + stringResource(MR.strings.or_show_this_qr_code).uppercase(), + titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } } + if (!onboarding) { Spacer(Modifier.height(DEFAULT_PADDING)) val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) { derivedStateOf { @@ -531,6 +576,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)) } } + } } @Composable @@ -577,13 +623,26 @@ fun AddContactLearnMoreButton() { } @Composable -private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit) { +private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit, onboarding: Boolean = false) { DisposableEffect(Unit) { onDispose { connectProgressManager.cancelConnectProgress() } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.connect_via_link_light else MR.images.connect_via_link_small_light + } else { + if (onboarding) MR.images.connect_via_link else MR.images.connect_via_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } + SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } @@ -625,7 +684,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC } }) { Box(Modifier.weight(1f)) { - Text(stringResource(MR.strings.tap_to_paste_link)) + Text(stringResource(MR.strings.tap_to_paste_link), color = MaterialTheme.colors.primary) } if (connectProgressManager.showConnectProgress != null) { CIFileViewScope.progressIndicator(sizeMultiplier = 0.6f) @@ -681,6 +740,13 @@ fun LinkTextView(link: String, share: Boolean) { // So using BasicTextField + manual ... Text("…", fontSize = 16.sp) if (share) { + Spacer(Modifier.width(DEFAULT_PADDING)) + IconButton({ + chatModel.markShowingInvitationUsed() + clipboard.setText(AnnotatedString(link)) + }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + } Spacer(Modifier.width(DEFAULT_PADDING)) IconButton({ chatModel.markShowingInvitationUsed() 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 new file mode 100644 index 0000000000..98954eb74f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -0,0 +1,422 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.BuildConfigCommon +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.helpers.* +import chat.simplex.common.views.usersettings.UserAddressView +import chat.simplex.res.MR +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.sin + +private const val CARD_HEIGHT_RATIO = 0.75f +private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 + +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) +} + +fun hasConversations(chats: List): Boolean = + chats.any { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> false + is ChatInfo.Direct -> !c.contact.chatDeleted && !c.contact.isContactCard + is ChatInfo.Group -> true + is ChatInfo.ContactRequest -> false + is ChatInfo.ContactConnection -> false + is ChatInfo.InvalidJSON -> false + } + } + +internal data class GradientEndpoints(val startX: Float, val startY: Float, val endX: Float, val endY: Float) + +internal fun gradientPoints(aspectRatio: Float, scale: Float): GradientEndpoints { + val r = aspectRatio.toDouble() + val s = scale.toDouble() + val dx = cos(GRADIENT_ANGLE_RAD) + val dy = -sin(GRADIENT_ANGLE_RAD) / r + val dLenSq = dx * dx + dy * dy + val projections = doubleArrayOf( + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ) + val tMin = projections.min() + val tMax = projections.max() + val startX = 0.5 + tMin * dx / dLenSq + val startY = 0.5 + tMin * dy / dLenSq + val endX = 0.5 + tMax * dx / dLenSq + val endY = 0.5 + tMax * dy / dLenSq + return GradientEndpoints( + startX = (0.5 + (startX - 0.5) * s).toFloat(), + startY = (0.5 + (startY - 0.5) * s).toFloat(), + endX = (0.5 + (endX - 0.5) * s).toFloat(), + endY = (0.5 + (endY - 0.5) * s).toFloat() + ) +} + +internal val lightStops = arrayOf( + 0.0f to Color(0xFFd2e8ff), + 0.5f to Color(0xFFcce9ff), + 0.9f to Color(0xFFdfffff), + 1.0f to Color(0xFFfffcea) +) + +internal val darkStops = arrayOf( + 0.4f to Color(0xFF040a24), + 0.72f to Color(0xFF3854ab), + 0.9f to Color(0xFFa8edf3), + 1.0f to Color(0xFFfff6e0) +) + +private fun Modifier.maxHeightByWidthRatio(ratio: Float) = layout { measurable, constraints -> + val maxH = (constraints.maxWidth * ratio).toInt().coerceAtMost(constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = 0, maxHeight = maxH)) + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } +} + +@Composable +fun OnboardingCardView( + imageName: dev.icerock.moko.resources.ImageResource, + imageNameLight: dev.icerock.moko.resources.ImageResource, + icon: dev.icerock.moko.resources.ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) { + var imageAreaSize by remember { mutableStateOf(IntSize.Zero) } + val isDark = isInDarkTheme() + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + + val brush = remember(imageAreaSize, isDark) { + if (imageAreaSize.width > 0 && imageAreaSize.height > 0) { + val aspect = imageAreaSize.height.toFloat() / imageAreaSize.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * imageAreaSize.width, gp.startY * imageAreaSize.height), + end = Offset(gp.endX * imageAreaSize.width, gp.endY * imageAreaSize.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .background(brush) + .onSizeChanged { imageAreaSize = it } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) imageNameLight else imageName), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(64.dp).align(Alignment.Center), + tint = MaterialTheme.colors.primary + ) + } + } + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f / labelHeightRatio) + .background(labelBg), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + Text( + title, + style = (if (appPlatform.isDesktop) MaterialTheme.typography.h3 else MaterialTheme.typography.h4).copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (subtitle != null) { + Text( + subtitle, + style = if (appPlatform.isDesktop) MaterialTheme.typography.body1 else MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.7f) + ) + } + } + } + } + } +} + +@Composable +private fun PageHeader(title: String, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val color = if (onBack != null) MaterialTheme.colors.primary else Color.Transparent + val baseStyle = MaterialTheme.typography.h1 + val titleView = @Composable { + var fontScale by remember(title) { mutableStateOf(1f) } + Text( + title, + style = baseStyle.copy(fontSize = baseStyle.fontSize * fontScale), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + onTextLayout = { result -> + if (result.hasVisualOverflow && fontScale > 0.5f) { + fontScale -= 0.05f + } + } + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + BackButton(Modifier.align(Alignment.CenterStart), color, onBack) + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + Box(Modifier.align(Alignment.Start)) { + BackButton(color = color, onClick = onBack) + } + titleView() + } + } +} + +@Composable +private fun BackButton(modifier: Modifier = Modifier, color: Color = MaterialTheme.colors.primary, onClick: (() -> Unit)? = null) { + Row( + modifier + .clip(RoundedCornerShape(20.dp)) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painterResource(MR.images.ic_arrow_back_ios_new), + contentDescription = stringResource(MR.strings.back), + tint = color, + modifier = Modifier.height(24.dp) + ) + Text(stringResource(MR.strings.back), color = color) + } +} + +@Composable +private fun CardPair( + isLandscape: Boolean, + heightRatio: Float, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } else { + Column( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING, Alignment.CenterVertically) + ) { + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } +} + +@Composable +private fun OnboardingPageLayout( + title: String, + onBack: (() -> Unit)? = null, + cards: @Composable (isLandscape: Boolean) -> Unit +) { + val isLandscape = appPlatform.isDesktop || windowOrientation() == WindowOrientation.LANDSCAPE + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + PageHeader(title = title, isLandscape = isLandscape, onBack = onBack) + Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = DEFAULT_PADDING)) { + cards(isLandscape) + } + } +} + +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + val startModalsOpen = appPlatform.isDesktop && ModalManager.start.hasModalsOpen + val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) + + val cardClickOverride: (() -> Unit)? = if (startModalsOpen) { + { ModalManager.start.closeModals() } + } else null + + fun goToPage(target: Int) { + if (appPlatform.isDesktop) { + scope.launch { pagerState.scrollToPage(target) } + } else { + scope.launch { pagerState.animateScrollToPage(target, animationSpec = tween(350)) } + } + } + + val pager = @Composable { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + userScrollEnabled = !appPlatform.isDesktop + ) { page -> + when (page) { + 0 -> OnboardingPageLayout(title = stringResource(MR.strings.talk_to_someone)) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_let_someone_connect_to_you_alpha, + imageNameLight = MR.images.card_let_someone_connect_to_you_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.let_someone_connect_to_you), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { goToPage(1) } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_connect_via_link_alpha, + imageNameLight = MR.images.card_connect_via_link_alpha_light, + icon = MR.images.ic_qr_code_scanner, + title = stringResource(MR.strings.connect_via_link_or_qr_code), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, onboarding = true, close = close) + } + } + ) + } + ) + } + 1 -> OnboardingPageLayout( + title = stringResource(MR.strings.connect_with_someone), + onBack = cardClickOverride ?: { goToPage(0) } + ) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_invite_someone_privately_alpha, + imageNameLight = MR.images.card_invite_someone_privately_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.invite_someone_privately), + subtitle = stringResource(MR.strings.a_link_for_one_person), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, onboarding = true, close = close) + } + } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_create_your_public_address_alpha, + imageNameLight = MR.images.card_create_your_public_address_alpha_light, + icon = MR.images.ic_qr_code, + title = stringResource(if (chatModel.userAddress.value != null) MR.strings.your_public_address else MR.strings.create_your_public_address), + subtitle = stringResource(MR.strings.for_anyone_to_reach_you), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, onboarding = true, close = close) + } + } + ) + } + ) + } + } + } + } + + if (appPlatform.isDesktop) { + val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(vertical = DEFAULT_PADDING).graphicsLayer { alpha = cardAlpha }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) { + pager() + } + } + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + pager() + } + } +} 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 f64f1dcecd..89b2f97ee7 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 @@ -880,6 +880,38 @@ private val versionDescriptions: List = listOf( ), ) ), + VersionDescription( + version = "v6.5", + post = "https://simplex.chat/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html", + features = listOf( + VersionFeature.FeatureDescription( + icon = null, + titleId = MR.strings.v6_5_public_channels, + descrId = null, + subfeatures = listOf( + MR.images.ic_wifi_tethering to MR.strings.v6_5_reliability, + MR.images.ic_dns to MR.strings.v6_5_ownership, + MR.images.ic_vpn_key_filled to MR.strings.v6_5_security, + MR.images.ic_shield to MR.strings.v6_5_privacy, + ) + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_add_link, + titleId = MR.strings.v6_5_invite_friends, + descrId = MR.strings.v6_5_invite_friends_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_security, + titleId = MR.strings.v6_5_safe_web_links, + descrId = MR.strings.v6_5_safe_web_links_descr + ), + VersionFeature.FeatureDescription( + icon = MR.images.ic_verified_user, + titleId = MR.strings.v6_5_non_profit_governance, + descrId = MR.strings.v6_5_non_profit_governance_descr + ), + ) + ), ) private val lastVersion = versionDescriptions.last().version diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 3b6cf34b7c..e5c731f3b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -7,7 +7,9 @@ import SectionTextFooter import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* @@ -28,6 +30,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR @Composable @@ -35,6 +38,7 @@ fun UserAddressView( chatModel: ChatModel, shareViaProfile: Boolean = false, autoCreateAddress: Boolean = false, + onboarding: Boolean = false, close: () -> Unit ) { // TODO close when remote host changes @@ -75,17 +79,31 @@ fun UserAddressView( addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null) ) - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.share_address_with_contacts_question), - text = generalGetString(MR.strings.add_address_to_your_profile), - confirmText = generalGetString(MR.strings.share_verb), - onConfirm = { - setProfileAddress(true) - shareViaProfile.value = true - } - ) + val hasRelevantContacts = chatModel.chats.value.any { chat -> + val ci = chat.chatInfo + ci is ChatInfo.Direct && + ci.contact.active && + !ci.contact.isContactCard && + !ci.contact.contactConnIncognito + } + if (hasRelevantContacts) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.share_address_with_contacts_question), + text = generalGetString(MR.strings.add_address_to_your_profile), + confirmText = generalGetString(MR.strings.share_verb), + onConfirm = { + setProfileAddress(true) + shareViaProfile.value = true + } + ) + progressIndicator.value = false + } else { + setProfileAddress(true) + shareViaProfile.value = true + } + } else { + progressIndicator.value = false } - progressIndicator.value = false } } @@ -103,6 +121,7 @@ fun UserAddressView( user = user.value, userAddress = userAddress.value, shareViaProfile, + onboarding = onboarding, createAddress = ::createAddress, showAddShortLinkAlert = { shareAddress: (() -> Unit)? -> showAddShortLinkAlert(progressIndicator = progressIndicator, share = ::share, shareAddress = shareAddress) @@ -249,6 +268,7 @@ private fun UserAddressLayout( user: User?, userAddress: UserContactLinkRec?, shareViaProfile: MutableState, + onboarding: Boolean = false, createAddress: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, learnMore: () -> Unit, @@ -259,68 +279,100 @@ private fun UserAddressLayout( saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + if (!onboarding) { + AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + } + if (BuildConfigCommon.SIMPLEX_ASSETS && userAddress != null) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.simplex_address_light else MR.images.simplex_address_small_light + } else { + if (onboarding) MR.images.simplex_address else MR.images.simplex_address_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { if (userAddress == null) { - SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { - CreateAddressButton(createAddress) - } + if (!onboarding) { + SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { + CreateAddressButton(createAddress) + } - SectionDividerSpaced() - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) + } } } else { - val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } - val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } val showShortLink = remember { mutableStateOf(true) } - SectionViewWithButton( - stringResource(MR.strings.for_social_media).uppercase(), - titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_post_address), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) + LinkTextView(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value), true) + Text( + stringResource(MR.strings.onboarding_or_use_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) - if (userAddress.shouldBeUpgraded) { - AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } - } - ShareAddressButton { + } else { + val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } + val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } + + SectionViewWithButton( + stringResource(MR.strings.for_social_media).uppercase(), + titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) if (userAddress.shouldBeUpgraded) { - showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } - } else { - share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } + } + ShareAddressButton { + if (userAddress.shouldBeUpgraded) { + showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } + } else { + share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + } + } + // ShareViaEmailButton { sendEmail(userAddress) } + BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) + + if (addressSettingsState.value.businessAddress) { + SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } } - // ShareViaEmailButton { sendEmail(userAddress) } - BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } - AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) - if (addressSettingsState.value.businessAddress) { - SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) + SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) } - } - SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) - } - - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - DeleteAddressButton(deleteAddress) - SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + DeleteAddressButton(deleteAddress) + SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + } } } } 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 9796d276c7..a9b2045b97 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -465,6 +465,15 @@ Tap to start a new chat Chat with the developers You have no chats + Talk to someone + Let someone connect to you + Connect via link or QR code + Connect with someone + Invite someone privately + A link for one person to connect + Create your public address + Your public address + For anyone to reach you Loading chats… No filtered chats No chats in list %s. @@ -497,6 +506,7 @@ Favorites Contacts Groups + Channels Businesses Notes Reports @@ -903,7 +913,7 @@ New chat New message Add contact - Scan / Paste link + Paste link / Scan Paste link One-time invitation link 1-time link @@ -1131,12 +1141,12 @@ All your contacts will remain connected. All your contacts will remain connected. Profile update will be sent to your contacts. Share link - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Create an address to let people connect with you. Create SimpleX address - Share with contacts - Share address with contacts? - Profile update will be sent to your contacts. + Share with SimpleX contacts + Share address with SimpleX contacts? + Profile update will be sent to your SimpleX contacts. Stop sharing address? Stop sharing Auto-accept @@ -1153,6 +1163,11 @@ Or to share privately SimpleX address or 1-time link? Create 1-time link + New 1-time link + Send the link via any messenger - it\'s secure. Ask to paste into SimpleX. + Or show QR in person or via video call. + Use this address in your social media profile, website, or email signature. + Or use this QR - print or show online. Address settings Business address Add your team members to the conversations. @@ -2564,6 +2579,17 @@ Share your address 4 new interface languages Catalan, Indonesian, Romanian and Vietnamese - thanks to our users! + Public channels - speak freely 🚀 + Reliability: many relays per channel. + Ownership: you can run your own relays. + Security: owners hold channel keys. + Privacy: for owners and subscribers. + Easier to invite your friends 👋 + We made connecting simpler for new users. + Safe web links + - opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking. + Non-profit governance + To make SimpleX Network last. View updated conditions diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg new file mode 100644 index 0000000000..6d012c8956 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt index 2a1a26df95..e4866c845d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt @@ -3,11 +3,12 @@ package chat.simplex.common import chat.simplex.common.model.json import chat.simplex.common.platform.appPreferences import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.DEFAULT_WINDOW_WIDTH import kotlinx.serialization.* @Serializable data class WindowPositionSize( - val width: Int = 1366, + val width: Int = DEFAULT_WINDOW_WIDTH.value.toInt(), val height: Int = 768, val x: Int = 0, val y: Int = 0, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index c5a38ec4a1..f88c539284 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -265,7 +265,9 @@ actual class VideoPlayer actual constructor( mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() { override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { playerThread.execute { - mediaPlayer?.audio()?.setVolume(100) + // Do not call setVolume here: on Windows VLCJ routes it through WASAPI ISimpleAudioVolume, + // which resets SimpleX Chat's per-app volume in the Windows Volume Mixer on every playback + // (VLCJ issue #985). A fresh VLCJ MediaPlayer already defaults to volume 100, so this was redundant. mediaPlayer?.audio()?.isMute = false } } diff --git a/apps/multiplatform/local.properties.example b/apps/multiplatform/local.properties.example index 8fa9a47963..9aa560d839 100644 --- a/apps/multiplatform/local.properties.example +++ b/apps/multiplatform/local.properties.example @@ -3,6 +3,8 @@ enable_debuggable=true application_id.suffix=.debug app.name=SimpleX Debug +#simplex.assets.dir=path/to/assets + #desktop.mac.signing.identity=SimpleX Chat Ltd #desktop.mac.signing.keychain=/path/to/simplex.keychain #desktop.mac.notarization.apple_id=example@example.com diff --git a/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md b/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md new file mode 100644 index 0000000000..39616304dd --- /dev/null +++ b/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md @@ -0,0 +1,38 @@ +--- +layout: layouts/article.html +title: "SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech" +date: 2026-04-28 +# previewBody: blog_previews/20260421.html +# image: images/20260421-channel.png +# imageBottom: true +draft: true +permalink: "/blog/20260428-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html" +--- + +# SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech + +**To be published:** Apr 28, 2026 + +This is a permalink for a blog post about: + +- SimpleX Channels - a new model for online publishing that preserves participation privacy, protecting both user and network operators. It is being released in v6.5 +- SimpleX Network Consortium - a cross-jurisdictional governance and licensing structure to ensure long term availability and sustainability of SimpleX Network. +- Testing the water for community crowdfunding under Reg CF. + +## SimpleX Channels - more public, more freedom, more private + +TODO + +## SimpleX Network Consortium - to govern SimpleX Network + +TODO + +## Community Crowdfunding + +TODO + +*Register your interest* to participate in crowdfunding here: https://simplexchat.typeform.com/crowdfunding + +Join the channel for updates here: https://smp4.simplex.im/g#g6pdBGlLoeOwqYmbmyvRye8EBiFd2inNUzKc87Pt3y4 + +_Disclaimer: SimpleX Chat is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ diff --git a/cabal.project b/cabal.project index e7d70bde64..557dee3951 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 95b17ada2795e1c5c84bbe2a50a0752ee66d0aad + tag: 858fac7f4f821a2df6fbea03a1bfbb82ea9717c5 source-repository-package type: git diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 61f81d1a40..d0422e1abd 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -49,7 +49,7 @@ Please, note, that if you use a modern version of SimpleX, the databases will be In order to view database data you need to decrypt it first. Install `sqlcipher` using your favorite package manager and run the following commands in the directory with databases: ```bash sqlcipher files_chat.db -pragma key="youDecryptionPassphrase"; +pragma key="yourDecryptionPassphrase"; # Ensure it works fine select * from users; ``` diff --git a/docs/BUSINESS.md b/docs/BUSINESS.md index 8fd5df5c36..b72bf00257 100755 --- a/docs/BUSINESS.md +++ b/docs/BUSINESS.md @@ -9,7 +9,7 @@ SimpleX Chat (aka SimpleX) is a decentralized communication network that provide This document aims to help you make the best use of SimpleX Chat if you choose to engage with its users. -## Communcate with customers via business address +## Communicate with customers via business address In the same way you can connect to our "SimpleX Chat team" profile via the app, you can provide the address for your existing and prospective customers: - to buy your product and services via chat, @@ -85,7 +85,7 @@ To install SimpleX Chat CLI in the cloud, follow this: simplex-chat ``` -To deattach from running CLI simply press `Ctrl+B` and then `D`. +To detach from a running CLI, simply press `Ctrl+B` and then `D`. To reattach back to CLI, run: `tmux attach -t simplex-cli`. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 6ae5418d0a..3ecfa17409 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -21,7 +21,7 @@ Please discuss the problem you want to solve and your detailed implementation pl ./contributing/CODE.md has details about general requirements common for `simplexmq` and `simplex-chat` repositories. -This files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files: +These files can be used with LLM prompts, e.g. if you use Claude Code you can create CLAUDE.md file in project root importing content from these files: ```markdown @README.md @@ -71,7 +71,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and windows: +2. To build core libraries for Android, iOS and Windows: - merge `master` branch to `master-android` branch. - push to GitHub. diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 8659222280..50e7771a1a 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -96,7 +96,7 @@ Group owners are expected to moderate the content in the groups, if members post We reserve the right to not accept the group listing in the directory or cancel its listing, and there may be cases when we can't provide an explanation. We will certainly try to avoid it by communicating with the group owners first. -The combination of display name and full name has to be unique for the listed groups. If a group uses the name or logo of SimpleX, SimpleX network or SimpleX Chat it must be consistent with [Permitted Uses or SimpleX trademark](./TRADEMARK.md). +The combination of display name and full name has to be unique for the listed groups. If a group uses the name or logo of SimpleX, SimpleX network or SimpleX Chat it must be consistent with [Permitted Uses of SimpleX trademark](./TRADEMARK.md). Once the group is listed in the directory, the bot will invite you to join the group of the group owners, where you can send any ideas or suggestions for how the groups functionality should evolve, and help steer both the product and the policies. diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md index 50eb62a401..b3046ee90b 100644 --- a/docs/DONATIONS.md +++ b/docs/DONATIONS.md @@ -7,7 +7,7 @@ permalink: /donate/index.html Huge thank you to everybody who donated to SimpleX Chat! -We are prioritizing users privacy and security - it would be impossible without your support. +We are prioritizing users' privacy and security - it would be impossible without your support. Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. diff --git a/docs/FAQ.md b/docs/FAQ.md index 401f025d9c..8c14168811 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -51,7 +51,7 @@ revision: 13.08.2025 ### How do I connect to people? -Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link to the app's search bar. The link will can also be opened via the browser, once the app is installed. +Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link to the app's search bar. The link can also be opened via the browser, once the app is installed. Alternatively, you can show the QR code when meeting in person or in a video call. @@ -103,7 +103,7 @@ Also see: [I do not see the second tick on the messages I sent](#i-do-not-see-th ### I want to see when my contacts read my messages -To know when your contact read your messages, your contact's app has to send you a confirmation message. And vice versa, for your contact to know when you read the message, your app has to send a confirmation message. +To know when your contact reads your messages, your contact's app has to send you a confirmation message. And vice versa, for your contact to know when you read the message, your app has to send a confirmation message. The important questions for this feature: - do you always want that your contacts can see when you read all their messages? Probably, even with your close friends, sometimes you would prefer to have time before you answer their message, and also have a plausible deniability that you have not seen the message. And this should be ok - in the end, this is your device, and it should be for you to decide whether this confirmation message is sent or not, and when it is sent. @@ -111,7 +111,7 @@ The important questions for this feature: Overall, it seems that this feature is more damaging to your communications with your contacts than it is helpful. It keeps senders longer in the app, nervously waiting for read receipts, exploiting addictive patterns - having you spend more time in the app is the reason why it is usually present in most messaging apps. It also creates a pressure on the recipients to reply sooner, and if read receipts are opt-in, it creates a pressure to enable it, that can be particularly damaging in any relationships with power imbalance. -We think that delivery receipts are important and equally benefit both sides as the conversation, as they confirm that communication network functions properly. But we strongly believe that read receipts is an anti-feature that only benefits the app developers, and hurts the relations between the app users. So we are not planning to add it even as opt-in. In case you want your contact to know you've read the message put a reaction to it. And if you don't want them to know it - it is also ok, what your device sends should be under your control. +We think that delivery receipts are important and equally benefit both sides as the conversation, as they confirm that communication network functions properly. But we strongly believe that read receipts are an anti-feature that only benefits the app developers, and hurts the relations between the app users. So we are not planning to add it even as opt-in. In case you want your contact to know you've read the message put a reaction to it. And if you don't want them to know it - it is also ok, what your device sends should be under your control. ### Can I use the same profile on desktop? Do messages sync cross-platform? @@ -130,7 +130,7 @@ We believe that allowing deleting information from your device to your contacts 2) it may be a business communication, and either your organization policy or a compliance requirement is that every message you receive must be preserved for some time. 3) the message can contain a legally binding promise, effectively a contract between you and your contact, in which case you both need to keep it. 4) the messages may contain threat or abuse and you may want to keep them as a proof. -5) you may have paid for the the message (e.g., it can be a design project or consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation. +5) you may have paid for the message (e.g., it can be a design project or consulting report), and you don't want it to suddenly disappear before you had a chance to store it outside of the conversation. It is also important to remember, that even if your contact enabled "Delete for everyone", you cannot really see it as a strong guarantee that the message will be deleted. Your contact's app can have a very simple modification (a one-line code change), that would prevent this deletion from happening when you request it. So you cannot see it as something that guarantees your security from your contacts. @@ -232,7 +232,7 @@ You may not have the second tick on your sent messages for these reasons: ### I see image preview but cannot open the image It can be for these reasons: -- your contact did not finish uploading the image file, possibly closing the app too quickly. When the image file is fully uploaded there will be a tick in the _top right corner_ or the image +- your contact did not finish uploading the image file, possibly closing the app too quickly. When the image file is fully uploaded there will be a tick in the _top right corner_ of the image. - your device fails to receive it. Please check server connectivity and run server tests, and also try increasing network timeouts in Advanced network settings. File reception was substantially improved in v5.7 - please make sure you are using the latest version. - file expired and can no longer be received. Files can be received only for 2 days after they were sent, after that they won't be available and will show X in the top right corner. @@ -298,7 +298,7 @@ You can resolve it by deleting the app's database: (WARNING: this results in del ### My mobile app does not connect to desktop app 1. Check that both devices are connected to the same network (e.g., it won't work if mobile is connected to mobile Internet and desktop to WiFi). -2. If you use VPN on mobile, allow connections to local network in you VPN settings, or disable VPN. +2. If you use VPN on mobile, allow connections to local network in your VPN settings, or disable VPN. 3. Allow SimpleX Chat on desktop to accept network connections in system firewall settings. You may choose a specific port in desktop app to accept connections, by default it uses a random port every time. 4. Check that your WiFi router allows connections between devices (e.g., it may have an option for "device isolation", or similar). 5. If you see an error "certificate expired", please check that your device clocks are synchronized within a few seconds. @@ -312,7 +312,7 @@ If none of the suggestions work for you, you can create a separate profile on ea ### Does SimpleX support post quantum cryptography? -Yes! Please read more about quantum resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md). +Yes! Please read more about how quantum-resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md). ### Why can't I use the same profile on different devices? @@ -355,7 +355,7 @@ If the servers didn't upgrade, the messages would temporarily fail to deliver. Y With private routing enabled, instead of connecting to your contact's server directly, your client would "instruct" one of the known servers to forward the message, preventing the destination server from observing your IP address. -Your messages are additionally end-to-end encrypted between your client and the destination server, so that the forwarding server cannot observe the destination addresses and server responses – similarly to how onion routing work. Private message routing is, effectively, a two-hop onion packet routing. +Your messages are additionally end-to-end encrypted between your client and the destination server, so that the forwarding server cannot observe the destination addresses and server responses – similarly to how onion routing works. Private message routing is, effectively, a two-hop onion packet routing. Also, this connection is protected from man-in-the-middle attack by the forwarding server, as your client will validate destination server certificate using its fingerprint in the server address. @@ -375,7 +375,7 @@ Private message routing routes packets (each message is one 16kb packet), not so As each message uses its own random encryption key and random (non-sequential) identifier, the destination server cannot link multiple message queue addresses to the same client. At the same time, the forwarding server cannot observe which (and how many) addresses on the destination server your client sends messages to, thanks to e2e encryption between the client and destination server. In that regard, this design is similar to onion routing, but with per-packet anonymity, not per-circuit. -This design is similar to mixnets (e.g. [Nym network](https://nymtech.net)), and it is tailored to the needs of message routing, providing better transport anonymity that general purpose networks, like Tor or VPN. You still can use Tor or VPN to connect to known servers, to protect your IP address from them. +This design is similar to mixnets (e.g. [Nym network](https://nymtech.net)), and it is tailored to the needs of message routing, providing better transport anonymity than general-purpose networks, like Tor or VPN. You still can use Tor or VPN to connect to known servers, to protect your IP address from them. ### Why don't you embed Tor in SimpleX Chat app? diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index fe0bd107ed..c8fdd56bdd 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -147,7 +147,7 @@ SimpleX Clients also form a network using SMP relays and IP or some other overla [Wikipedia](https://en.wikipedia.org/wiki/Overlay_network) -# Non-repudiation +## Non-repudiation The property of the cryptographic or communication system that allows the recipient of the message to prove to any third party that the sender identified by some cryptographic key sent the message. It is the opposite to [repudiation](#repudiation). While in some context non-repudiation may be desirable (e.g., for contractually binding messages), in the context of private communications it may be undesirable. @@ -157,7 +157,7 @@ The property of the cryptographic or communication system that allows the recipi Generalizing [the definition](https://csrc.nist.gov/glossary/term/pairwise_pseudonymous_identifier) from NIST Digital Identity Guidelines, it is an opaque unguessable identifier generated by a service used to access a resource by only one party. -In the context of SimpleX network, these are the identifiers generated by SMP relays to access anonymous messaging queues, with a separate identifier (and access credential) for each accessing party: recipient, sender and and optional notifications subscriber. The same approach is used by XFTP relays to access file chunks, with separate identifiers (and access credentials) for sender and each recipient. +In the context of SimpleX network, these are the identifiers generated by SMP relays to access anonymous messaging queues, with a separate identifier (and access credential) for each accessing party: recipient, sender and an optional notifications subscriber. The same approach is used by XFTP relays to access file chunks, with separate identifiers (and access credentials) for sender and each recipient. ## Peer-to-peer @@ -177,7 +177,7 @@ The quality of the end-to-end encryption scheme allowing to recover security aga ## Post-quantum cryptography -Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2025 there is no system or algorithm that is *proven* to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum hybrid cryptography - combining post-quantum and traditional algorigthms. +Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2025 there is no system or algorithm that is *proven* to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum hybrid cryptography - combining post-quantum and traditional algorithms. [Wikipedia](https://en.wikipedia.org/wiki/Post-quantum_cryptography) diff --git a/docs/REPRODUCE.md b/docs/REPRODUCE.md index 03fb6b4336..50c0b99280 100644 --- a/docs/REPRODUCE.md +++ b/docs/REPRODUCE.md @@ -46,7 +46,7 @@ echo -e "trust\n5\ny\nquit" | gpg --command-fd 0 --edit-key build@simplex.chat ## Verify release signature -**Linux dekstop apps and CLI**: +**Linux desktop apps and CLI**: Download the file with executable hashes and the signature. For example, to verify the `v6.5.0-beta.3` release: @@ -149,13 +149,13 @@ To reproduce the build you must have: The script executes these steps (please review the script to confirm): - 1) builds all Linux CLI and Dekstop binaries for the release in docker container. + 1) builds all Linux CLI and Desktop binaries for the release in docker container. 2) downloads binaries from the same GitHub release and compares them with the built binaries. 3) if they all match, generates _sha256sums file with their checksums. This will take a while. -4. After compilation, you should see the folder named as the tag and reprository name (e.g., `v6.4.8-simplex-chat`) with two subfolders: +4. After compilation, you should see the folder named as the tag and repository name (e.g., `v6.4.8-simplex-chat`) with two subfolders: ```sh ls v6.4.8-simplex-chat @@ -169,7 +169,7 @@ To reproduce the build you must have: ### Android apps -In addition to basic requirments, Android build will: +In addition to basic requirements, Android build will: - Take ~150gb of disc space - Take ~20h to build all the architectures (depends on core count) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 72db650c35..b9218fbebc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -54,7 +54,7 @@ We will determine the risk of each issue, taking into account our experience dea **Issue severity levels** -- **CRITICAL severity**. Such issues should affect common configurations and be exploitable with low or medium difficulty. For example: significant disclosure of the encrypted users messages or files either via relays or via communication channels, vulnerabilities which can be easily exploited remotely to compromise clients or servers private keys. These issues will be kept private and will trigger a new release of all supported versions. +- **CRITICAL severity**. Such issues should affect common configurations and be exploitable with low or medium difficulty. For example: significant disclosure of the encrypted users' messages or files either via relays or via communication channels, vulnerabilities which can be easily exploited remotely to compromise clients or servers private keys. These issues will be kept private and will trigger a new release of all supported versions. - **HIGH severity**. This includes issues that are of a lower risk than critical, possibly due to affecting less common configurations, or have high difficulty to be exploited. These issues will be kept private and will trigger a new release of all supported versions. - **MEDIUM severity**. This includes issues like crashes in client applications caused by the received messages or files, flaws in protocols that are less commonly used, and local flaws. These will in general be kept private until the next release, and that release will be scheduled so that it can roll up several such flaws at one time. - **LOW severity**. This includes issues such as those that only affect the SimpleX CLI app, or unlikely configurations, or issues that would be classified as medium but are very difficult to exploit. These will in general be fixed immediately in latest development versions, and may be back-ported to older versions that are still getting updates. These issues may be kept private or be included in commit messages. diff --git a/docs/SERVER.md b/docs/SERVER.md index f45403be8a..a35ede5cd0 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -59,7 +59,7 @@ To create SMP server as a systemd service, you'll need: - Your server domain, with A and AAAA records specifying server IPv4 and IPv6 addresses (`smp1.example.com`) - A basic Linux knowledge. -*Please note*: while you can run an SMP server without a domain name, in the near future client applications will start using server domain name in the invitation links (instead of `simplex.chat` domain they use now). In case a server does not have domain name and server pages (see below), the clients will be generaing the links with `simplex:` scheme that cannot be opened in the browsers. +*Please note*: while you can run an SMP server without a domain name, in the near future client applications will start using server domain name in the invitation links (instead of `simplex.chat` domain they use now). In case a server does not have domain name and server pages (see below), the clients will be generating the links with `simplex:` scheme that cannot be opened in the browsers. 1. Install server with [Installation script](https://github.com/simplex-chat/simplexmq#using-installation-script). @@ -82,7 +82,7 @@ To create SMP server as a systemd service, you'll need: --control-port \ --socks-proxy \ --source-code \ - --fqdn=smp1.example.com + --fqdn=smp1.example.com' ``` 4. Install tor: @@ -114,7 +114,7 @@ To create SMP server as a systemd service, you'll need: ```sh # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log - # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonimity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, keep standard configuration. + # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonymity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, keep standard configuration. SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 @@ -194,12 +194,12 @@ To create SMP server as a systemd service, you'll need: key_name='web.key' cert_name='web.crt' - # Copy certifiacte from Caddy directory to smp-server directory + # Copy certificate from Caddy directory to smp-server directory cp "${folder_in}/${domain}.crt" "${folder_out}/${cert_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${cert_name}" - # Copy certifiacte key from Caddy directory to smp-server directory + # Copy certificate key from Caddy directory to smp-server directory cp "${folder_in}/${domain}.key" "${folder_out}/${key_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${key_name}" @@ -535,7 +535,7 @@ To verify server binaries after you downloaded them: > Good signature from "SimpleX Chat " -5. Compute the hashes of the binaries you plan to use with `shu256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. +5. Compute the hashes of the binaries you plan to use with `sha256sum ` or with `openssl sha256 ` and compare them with the hashes in the file `_sha256sums` - they must be the same. That is it - you now verified authenticity of our GitHub server binaries. @@ -634,7 +634,7 @@ to initialize your `smp-server` configuration with: --- -After that, your installation is complete and you should see in your teminal output something like this: +After that, your installation is complete and you should see in your terminal output something like this: ```sh Certificate request self-signature ok @@ -742,7 +742,7 @@ websockets: off [PROXY] # Network configuration for SMP proxy client. # `host_mode` can be 'public' (default) or 'onion'. -# It defines prefferred hostname for destination servers with multiple hostnames. +# It defines preferred hostname for destination servers with multiple hostnames. # host_mode: public # required_host_mode: off @@ -757,7 +757,7 @@ websockets: off # or 'always' to be used for all destination hosts (can be used if it is an .onion server). # socks_mode: onion -# Limit number of threads a client can spawn to process proxy commands in parrallel. +# Limit number of threads a client can spawn to process proxy commands in parallel. # client_concurrency: 32 [INACTIVE_CLIENTS] @@ -823,7 +823,7 @@ Follow the steps to secure your CA keys: /etc/opt/simplex/ca.key ``` -3. Delete the CA key from the server. **Please make sure you've saved you CA key somewhere safe. Otherwise, you would lose the ability to [rotate the online certificate](#online-certificate-rotation)**: +3. Delete the CA key from the server. **Please make sure you've saved your CA key somewhere safe. Otherwise, you would lose the ability to [rotate the online certificate](#online-certificate-rotation)**: ```sh rm /etc/opt/simplex/ca.key @@ -913,9 +913,9 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject 1. Install tor: - We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. + We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [official tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. - - Configure offical Tor PPA repository: + - Configure official Tor PPA repository: ```sh CODENAME="$(lsb_release -c | awk '{print $2}')" @@ -951,12 +951,12 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject vim /etc/tor/torrc ``` - And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options. + And insert the following lines to the bottom of configuration. Please note lines starting with `#`: these are comments about each individual option. ```sh # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log - # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonimity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, you may want to keep standard configuration instead. + # Enable single hop routing (2 options below are dependencies of the third) - It will reduce the latency at the cost of lower anonymity of the server - as SMP-server onion address is used in the clients together with public address, this is ok. If you deploy SMP-server with onion-only address, you may want to keep standard configuration instead. SOCKSPort 0 HiddenServiceNonAnonymousMode 1 HiddenServiceSingleHopMode 1 @@ -974,7 +974,7 @@ SMP-server can also be deployed to be available via [Tor](https://www.torproject 3. Start tor: - Enable `systemd` service and start tor. Offical `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. + Enable `systemd` service and start tor. Official `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. ```sh systemctl enable --now tor && systemctl restart tor @@ -994,7 +994,7 @@ SMP-server versions starting from `v5.8.0-beta.0` can be configured to PROXY smp 1. Install tor as described in the [previous section](#installation-for-onion-address). -2. Execute the following command to creatae a new Tor daemon instance: +2. Execute the following command to create a new Tor daemon instance: ```sh tor-instance-create tor2 @@ -1101,7 +1101,7 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. hosting_country: ``` -2. Install the webserver. For easy deployment we'll describe the installtion process of [Caddy](https://caddyserver.com) webserver on Ubuntu server: +2. Install the webserver. For easy deployment we'll describe the installation process of [Caddy](https://caddyserver.com) webserver on Ubuntu server: 1. Install the packages: @@ -1127,7 +1127,7 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. sudo apt update && sudo apt install caddy ``` - [Full Caddy instllation instructions](https://caddyserver.com/docs/install) + [Full Caddy installation instructions](https://caddyserver.com/docs/install) 3. Replace Caddy configuration with the following: @@ -1176,12 +1176,12 @@ _Please note:_ this configuration is supported since `v6.1.0-beta.2`. key_name='web.key' cert_name='web.crt' - # Copy certifiacte from Caddy directory to smp-server directory + # Copy certificate from Caddy directory to smp-server directory cp "${folder_in}/${domain}.crt" "${folder_out}/${cert_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${cert_name}" - # Copy certifiacte key from Caddy directory to smp-server directory + # Copy certificate key from Caddy directory to smp-server directory cp "${folder_in}/${domain}.key" "${folder_out}/${key_name}" # Assign correct permissions chown "$user":"$group" "${folder_out}/${key_name}" @@ -1237,7 +1237,7 @@ smp://[:]@[,] - **optional** `` - Your configured password of `smp-server`. You can check your configured pasword in `/etc/opt/simplex/smp-server.ini`, under `[AUTH]` section in `create_password:` field. + Your configured password of `smp-server`. You can check your configured password in `/etc/opt/simplex/smp-server.ini`, under `[AUTH]` section in `create_password:` field. - ``, **optional** `` @@ -1368,9 +1368,9 @@ Here's the full list of commands, their descriptions and who can access them. | `stats-rts` | GHC/Haskell statistics. Can be enabled with `+RTS -T -RTS` option | - | | `clients` | Clients information. Useful for debugging. | yes | | `sockets` | General sockets information. | - | -| `socket-threads` | Thread infomation per socket. Useful for debugging. | yes | +| `socket-threads` | Thread information per socket. Useful for debugging. | yes | | `threads` | Threads information. Useful for debugging. | yes | -| `server-info` | Aggregated server infomation. | - | +| `server-info` | Aggregated server information. | - | | `delete` | Delete known queue. Useful for content moderation. | - | | `save` | Save queues/messages from memory. | yes | | `help` | Help menu. | - | @@ -1417,31 +1417,31 @@ fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,m | 20 | `pRelays_pRequests` | - requests | | 21 | `pRelays_pSuccesses` | - successes | | 22 | `pRelays_pErrorsConnect` | - connection errors | -| 23 | `pRelays_pErrorsCompat` | - compatability errors | +| 23 | `pRelays_pErrorsCompat` | - compatibility errors | | 24 | `pRelays_pErrorsOther` | - other errors | | Requested sessions with own relays: | | 25 | `pRelaysOwn_pRequests` | - requests | | 26 | `pRelaysOwn_pSuccesses` | - successes | | 27 | `pRelaysOwn_pErrorsConnect` | - connection errors | -| 28 | `pRelaysOwn_pErrorsCompat` | - compatability errors | +| 28 | `pRelaysOwn_pErrorsCompat` | - compatibility errors | | 29 | `pRelaysOwn_pErrorsOther` | - other errors | | Message forwards to all relays: | | 30 | `pMsgFwds_pRequests` | - requests | | 31 | `pMsgFwds_pSuccesses` | - successes | | 32 | `pMsgFwds_pErrorsConnect` | - connection errors | -| 33 | `pMsgFwds_pErrorsCompat` | - compatability errors | +| 33 | `pMsgFwds_pErrorsCompat` | - compatibility errors | | 34 | `pMsgFwds_pErrorsOther` | - other errors | | Message forward to own relays: | | 35 | `pMsgFwdsOwn_pRequests` | - requests | | 36 | `pMsgFwdsOwn_pSuccesses` | - successes | | 37 | `pMsgFwdsOwn_pErrorsConnect` | - connection errors | -| 38 | `pMsgFwdsOwn_pErrorsCompat` | - compatability errors | +| 38 | `pMsgFwdsOwn_pErrorsCompat` | - compatibility errors | | 39 | `pMsgFwdsOwn_pErrorsOther` | - other errors | | Received message forwards: | | 40 | `pMsgFwdsRecv` | | -| Message queue subscribtion errors: | +| Message queue subscription errors: | | 41 | `qSub` | All | -| 42 | `qSubAuth` | Authentication erorrs | +| 42 | `qSubAuth` | Authentication errors | | 43 | `qSubDuplicate` | Duplicate SUB errors | | 44 | `qSubProhibited` | Prohibited SUB errors | | Message errors: | @@ -1526,9 +1526,9 @@ To update your smp-server to latest version, choose your installation method and sudo systemctl start smp-server ``` - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + - [Official installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - 1. Execute the followin command: + 1. Execute the following command: ```sh sudo simplex-servers-update @@ -1640,7 +1640,7 @@ To reproduce the build you must have: ## Configuring the app to use the server -To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. +To configure the app to use your messaging server copy its full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. It is also possible to share the address of your server with your friends by letting them scan QR code from server settings - it will include server password, so they will be able to receive messages via your server as well. diff --git a/docs/SIMPLEX.md b/docs/SIMPLEX.md index ec25afaf88..e24275d656 100644 --- a/docs/SIMPLEX.md +++ b/docs/SIMPLEX.md @@ -89,7 +89,7 @@ There are several P2P chat/messaging protocols and implementations that aim to s 5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The SimpleX network is fragmented and operates as multiple isolated connections. It makes network-wide attacks on SimpleX network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can switch to using other servers without losing contacts or messages. -6. P2P networks are likely to be [vulnerable][14] to [DRDoS attack][15]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. +6. P2P networks are likely to be [vulnerable][14] to [DRDoS attack][15]. In the proposed design clients only relay traffic from known trusted connections and cannot be used to reflect and amplify the traffic in the whole network. [1]: https://en.wikipedia.org/wiki/End-to-end_encryption [2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md index 2b1febb6f2..a0250b6ab2 100644 --- a/docs/TRANSLATIONS.md +++ b/docs/TRANSLATIONS.md @@ -35,7 +35,7 @@ The steps are: ### Translating Android app -1. Please start from [Android app](https://hosted.weblate.org/projects/simplex-chat/android/), both when you do the most time-consuming initial translation, and add any strings later. Firstly, iOS strings can be a bit delayed from appearing in Weblate, as it requires a manual step from us before they are visible. Secondary, Android app is set up as a glossary for iOS app, and 2/3 of all strings require just to clicks to transfer them from Android to iOS (it still takes some time, Weblate doesn't automate it, unfortunately). +1. Please start from [Android app](https://hosted.weblate.org/projects/simplex-chat/android/), both when you do the most time-consuming initial translation, and add any strings later. Firstly, iOS strings can be a bit delayed from appearing in Weblate, as it requires a manual step from us before they are visible. Secondly, Android app is set up as a glossary for iOS app, and 2/3 of all strings require just two clicks to transfer them from Android to iOS (it still takes some time, Weblate doesn't automate it, unfortunately). 2. Some of the strings do not need translations, but they still need to be copied over - there is a button in weblate UI for that: diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index a48cd12b00..b4862e0d5b 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -18,7 +18,7 @@ For this guide, we'll be using the most featureful and battle-tested STUN/TURN s 1. Install `coturn` package from the main repository. ```sh -apt update && apt install coturn` +apt update && apt install coturn ``` 2. Uncomment `TURNSERVER_ENABLED=1` from `/etc/default/coturn`: @@ -44,7 +44,7 @@ user=$YOUR_LOGIN:$YOUR_PASSWORD server-name=$YOUR_DOMAIN # The default realm to be used for the users when no explicit origin/realm relationship was found realm=$YOUR_DOMAIN -# Path to your certificates. Make sure they're readable by cotun process user/group +# Path to your certificates. Make sure they're readable by coturn process user/group cert=/var/lib/turn/cert.pem pkey=/var/lib/turn/key.pem # Use 2066 bits predefined DH TLS key @@ -97,7 +97,7 @@ To configure your mobile app to use your server: 1. Open `Settings / Network & Servers / WebRTC ICE servers` and switch toggle `Configure ICE servers`. -2. Enter all server addresses in the field, one per line, for example if you servers are on the port 5349: +2. Enter all server addresses in the field, one per line, for example if your servers are on the port 5349: ``` stun:stun.example.com:5349 @@ -116,7 +116,7 @@ This is it - you now can make audio and video calls via your own server, without ping ``` - If packets being transmitted, server is up! + If packets are being transmitted, the server is up! - **Determine if ports are open**: diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index ba4770644e..43edbdda7f 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -9,7 +9,7 @@ revision: 31.07.2023 - [Overview](#overview) - [Installation options](#installation-options) - [systemd service](#systemd-service) with [installation script](#installation-script) or [manually](#manual-deployment) - - [docker container](#docker-сontainer) + - [docker container](#docker-container) - [Linode marketplace](#linode-marketplace) - [Tor installation](#tor-installation) - [Configuration](#configuration) @@ -72,7 +72,7 @@ Manual installation is the most advanced deployment that provides the most flexi 1. Install binary: - - Using offical binaries: + - Using official binaries: ```sh curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server @@ -129,9 +129,9 @@ Manual installation is the most advanced deployment that provides the most flexi And execute `sudo systemctl daemon-reload`. -### Docker сontainer +### Docker container -You can deploy smp-server using Docker Compose. This is second recommended option due to its popularity and relatively easy deployment. +You can deploy xftp-server using Docker Compose. This is the second recommended option due to its popularity and relatively easy deployment. This deployment provides two Docker Compose files: the **automatic** one and **manual**. If you're not sure, choose **automatic**. @@ -197,9 +197,9 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) 1. Install tor: - We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [offical tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. + We're assuming you're using Ubuntu/Debian based distributions. If not, please refer to [official tor documentation](https://community.torproject.org/onion-services/setup/install/) or your distribution guide. - - Configure offical Tor PPA repository: + - Configure official Tor PPA repository: ```sh CODENAME="$(lsb_release -c | awk '{print $2}')" @@ -235,10 +235,10 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) vim /etc/tor/torrc ``` - And insert the following lines to the bottom of configuration. Please note lines starting with `#`: this is comments about each individual options. + And insert the following lines to the bottom of configuration. Please note lines starting with `#`: these are comments about each individual option. ```sh - # Enable log (otherwise, tor doesn't seemd to deploy onion address) + # Enable log (otherwise, tor doesn't seem to deploy onion address) Log notice file /var/log/tor/notices.log # Enable single hop routing (2 options below are dependencies of third). Will reduce latency in exchange of anonimity (since tor runs alongside xftp-server and onion address will be displayed in clients, this is totally fine) SOCKSPort 0 @@ -257,7 +257,7 @@ xftp-server can also be deployed to serve from [tor](https://www.torproject.org) 3. Start tor: - Enable `systemd` service and start tor. Offical `tor` is a bit flunky on the first start and may not create onion host address, so we're restarting it just in case. + Enable `systemd` service and start tor. Official `tor` is a bit flaky on the first start and may not create onion host address, so we're restarting it just in case. ```sh systemctl enable tor && systemctl start tor && systemctl restart tor @@ -356,7 +356,7 @@ To password-protect your `xftp-server`, change it in the configuration: ``` --- -After that, your installation is complete and you should see in your teminal output something like this: +After that, your installation is complete and you should see in your terminal output something like this: ```sh Certificate request self-signature ok @@ -398,7 +398,7 @@ xftp://[:]@[,] - **optional** `` - Your configured password of `xftp-server`. You can check your configured pasword in `/etc/opt/simplex-xftp/file-server.ini`, under `[AUTH]` section in `create_password:` field. + Your configured password of `xftp-server`. You can check your configured password in `/etc/opt/simplex-xftp/file-server.ini`, under `[AUTH]` section in `create_password:` field. - ``, **optional** `` @@ -609,8 +609,8 @@ To update your XFTP server to latest version, choose your installation method an sudo systemctl start xftp-server ``` - - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - 1. Execute the followin command: + - [Official installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the following command: ```sh sudo simplex-servers-update ``` diff --git a/plans/2026-04-06-onboarding-cards-compose.md b/plans/2026-04-06-onboarding-cards-compose.md new file mode 100644 index 0000000000..00df159eee --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-compose.md @@ -0,0 +1,492 @@ +# Onboarding Cards — Compose (Android/Desktop) Implementation Plan + +References the layout specification in `plans/2026-04-06-onboarding-cards-ios.md`. + +## Scope + +Same as iOS: Screens 1 and 2 with paging transition. Modal sheets for deeper views. No banner, no standalone onboarding variants. + +## New file + +`common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt` + +## Assets + +8 card stub SVGs needed in `assets/default/MR/images/` (same names as the real PNGs, with `.svg` extension): +- `card_let_someone_connect_to_you_alpha.svg` / `_light.svg` +- `card_connect_via_link_alpha.svg` / `_light.svg` +- `card_invite_someone_privately_alpha.svg` / `_light.svg` +- `card_create_your_public_address_alpha.svg` / `_light.svg` + +Real PNGs already generated in art repo `multiplatform/resources/MR/images/`. + +## Onboarding condition (shared by Android and Desktop) + +Placed in `ConnectOnboardingView.kt` as top-level functions, accessible from both `ChatListView.kt` and `App.kt`: + +```kotlin +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && noConversationChatsYet(chats) +} + +fun noConversationChatsYet(chats: List): Boolean = + chats.all { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> true + is ChatInfo.Direct -> c.contact.chatDeleted || c.contact.isContactCard + is ChatInfo.Group -> false + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.InvalidJSON -> true + } + } +``` + +`shouldShowOnboarding` is `@Composable` (reads reactive state) and public — called from both `ChatListView.kt` and `App.kt`. `noConversationChatsYet` is a pure function, also public (used by auto-dismiss LaunchedEffect). + +### Auto-dismiss + +```kotlin +LaunchedEffect(chatModel.chats.value.size) { + if (!noConversationChatsYet(chatModel.chats.value)) { + appPrefs.addressCreationCardShown.set(true) + } +} +``` + +Placed in `ChatListWithLoadingScreen`. + +## Android integration + +### In `ChatListView.kt` — `ChatListWithLoadingScreen` (line 291) + +Change from: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (!chatModel.desktopNoUserNoRemote) { ChatList(...) } + if (chatModel.chats.value.isEmpty() && ...) { Text("Loading/empty") } +} +``` + +To: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + val chats = chatModel.chats.value + when { + chats.isEmpty() && !chatModel.switchingUsersAndHosts.value + && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == null -> { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + shouldShowOnboarding() -> { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + // Desktop: empty — overlay in DesktopScreen handles it + } + !chatModel.desktopNoUserNoRemote -> { + ChatList(searchText = searchText, listState) + } + } + // Auto-dismiss + LaunchedEffect(chats.size) { + if (chats.isNotEmpty() && !noConversationChatsYet(chats)) { + appPrefs.addressCreationCardShown.set(true) + } + } +} +``` + +Toolbar is a sibling in the parent `Box` (lines 150-174), stays visible. + +## Desktop integration + +### Architecture + +The overlay is the PRIMARY UI surface during onboarding. ALL interaction happens inside it — card taps, toolbar button modals, everything. `ModalManager.start` renders INTO the overlay instead of into the start panel. + +### Overlay structure in `DesktopScreen` (App.kt) + +Two visual layers in the overlay, both full-width: + +1. **Background layer:** covers center+end area only (padded left by start panel width). Opaque `MaterialTheme.colors.background`. Hides center panel content ("No selected chat") while leaving start panel fully visible underneath. + +2. **Content layer:** full window width, no background. Cards render here, centered in the full window. Clicks outside cards fall through to the start panel below. + +Both layers have top/bottom padding for toolbar height (`AppBarHeight * fontSizeSqrtMultiplier`). + +```kotlin +if (shouldShowOnboarding()) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val toolbarPadding = AppBarHeight * fontSizeSqrtMultiplier + val topPad = if (!oneHandUI.value) toolbarPadding else 0.dp + val bottomPad = if (oneHandUI.value) toolbarPadding else 0.dp + + // Background — center+end only + Box( + Modifier + .fillMaxSize() + .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier, top = topPad, bottom = bottomPad) + .background(MaterialTheme.colors.background) + ) + + // Content — full width, cards centered + Box( + Modifier + .fillMaxSize() + .padding(top = topPad, bottom = bottomPad), + contentAlignment = Alignment.Center + ) { + ConnectOnboardingView() + } +} +``` + +Z-order: above panels and vertical divider, below `ModalManager.fullscreen`. + +### Start panel modal redirection + +During onboarding, `ModalManager.start.showInView()` renders INSIDE the overlay instead of in the start panel Box. + +In `DesktopScreen`: +```kotlin +// Start panel modals — normal location +Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { + if (!shouldShowOnboarding()) { + ModalManager.start.showInView() + } + SwitchingUsersView() +} +``` + +Inside `ConnectOnboardingView`, on desktop: +- Watch `ModalManager.start.hasModalsOpen` +- When a start modal opens (from toolbar + button, avatar, or card tap): + 1. Cards shift RIGHT and fade to ~30% opacity (animated) + 2. `ModalManager.start.showInView()` renders on the LEFT side of the overlay with left-to-right slide animation + 3. This is the FIRST modal opening — it slides left-to-right + 4. Subsequent modals within the start modal stack open right-to-left as usual (standard `ModalManager` behavior inside the rendered area) +- When all start modals close: reverse animation — modal area slides left, cards restore position and opacity +- Clicking a faded card triggers `ModalManager.start.closeModals()` to dismiss and restore cards. This requires swapping card `onClick` handlers when `startModalsOpen` is true — each card's onClick becomes `{ ModalManager.start.closeModals() }` instead of its normal action. + +```kotlin +// Inside ConnectOnboardingView, desktop only: +val startModalsOpen = ModalManager.start.hasModalsOpen +val cardOffset by animateFloatAsState(if (startModalsOpen) 0.3f else 0f) +val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) +val modalSlide by animateFloatAsState(if (startModalsOpen) 0f else -1f) + +Box(Modifier.fillMaxSize()) { + // Modal area — slides from left + if (appPlatform.isDesktop) { + Box( + Modifier + .fillMaxHeight() + .widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) + .graphicsLayer { translationX = modalSlide * size.width } + ) { + ModalManager.start.showInView() + } + } + + // Cards — shift right and fade when modal open + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + if (appPlatform.isDesktop) { + translationX = cardOffset * size.width + alpha = cardAlpha + } + } + ) { + HorizontalPager(...) { /* pages */ } + } +} +``` + +Card taps use `ModalManager.start.showModalCloseable` on ALL platforms — same code. On Android, the modal renders in the normal start panel modal area. On desktop during onboarding, the modal renders inside the overlay via the redirected `showInView()`. + +### Card tap actions — same on all platforms + +```kotlin +val openConnectViaLink = { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, ..., close = close) + } +} +``` + +No platform branching needed. `ModalManager.start` handles the modal lifecycle. Only the rendering location changes. + +### Suppress "No selected chat" in `CenterPartOfScreen` (line 373) + +```kotlin +null -> { + if (!shouldShowOnboarding() && !rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + Box(...) { Text(stringResource(...)) } + } else if (!shouldShowOnboarding()) { + ModalManager.center.showInView() + } +} +``` + +When onboarding active: center panel shows nothing (overlay covers it visually). + +### Desktop HorizontalPager: tap only + +`userScrollEnabled = !appPlatform.isDesktop` — disables mouse swipe on desktop. + +## Revision 2 — Bug fixes from initial implementation + +### Fix 1: `fillMaxSize()` overrides `widthIn(max:)` + +In both page composables, the Column has `Modifier.fillMaxSize().widthIn(max = 500.dp)`. `fillMaxSize()` sets width to maximum, overriding the `widthIn` constraint. + +Fix: `Modifier.fillMaxHeight().widthIn(max = 500.dp)` — only fill height, let widthIn cap the width. + +### Fix 2: Modifier order — background before padding + +In the overlay Box: `.fillMaxSize().background(color).padding(...)` paints background over toolbar area. + +Fix: `.fillMaxSize().padding(...).background(color)` — padding first, background only fills content area. + +Superseded by the two-layer approach above — background layer is separate from content layer. + +### Fix 3: "You have no chats" text dropped + +The `when` block in `ChatListWithLoadingScreen` replaced two independent `if` blocks with mutually exclusive branches, dropping the "You have no chats" case. + +Fix: revert to the original `if` block structure, adding onboarding as the first check: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats + ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + } + // Auto-dismiss + LaunchedEffect(chatModel.chats.value.size) { ... } +} +``` + +This preserves the original loading/empty behavior exactly. The onboarding branch is checked first — when active, it replaces everything. When inactive, original code runs unchanged. + +## ConnectOnboardingView composable + +### Structure + +```kotlin +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + HorizontalPager(state = pagerState, userScrollEnabled = true) { page -> + when (page) { + 0 -> TalkToSomeonePage( + onLetSomeoneConnect = { scope.launch { pagerState.animateScrollToPage(1) } }, + onConnectViaLink = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + }} + ) + 1 -> ConnectWithSomeonePage( + onBack = { scope.launch { pagerState.animateScrollToPage(0) } }, + onInviteSomeone = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + }}, + onCreateAddress = { ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) + }} + ) + } + } +} +``` + +### Page layout + +Each page uses `BoxWithConstraints` to compute card dimensions: + +```kotlin +@Composable +private fun TalkToSomeonePage(onLetSomeoneConnect: () -> Unit, onConnectViaLink: () -> Unit) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + val padding = 16.dp + val spacing = 16.dp + val cardWidth = if (isLandscape) (maxWidth - padding * 2 - spacing) / 2 else maxWidth - padding * 2 + val maxCardHeight = cardWidth * 0.75f + + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + pageHeader("Talk to someone", showBack = false, isLandscape = isLandscape) + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + cardPair(isLandscape, padding, spacing, maxCardHeight) { + // card1 and card2 + } + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + } + } +} +``` + +### pageHeader composable + +Shared by both pages. No duplication: + +```kotlin +@Composable +private fun pageHeader(title: String, showBack: Boolean, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val titleView = @Composable { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, // largeTitle equivalent + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.CenterStart)) + } + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.Start)) + } else { + Spacer(Modifier.height(AppBarHeight)) + } + titleView() + } + } +} +``` + +Back button spacer uses `AppBarHeight` (56.dp) to match the platform's back button area, not iOS's 44pt. + +### cardPair composable + +Shared layout helper, no card duplication: + +```kotlin +@Composable +private fun cardPair( + isLandscape: Boolean, + padding: Dp, + spacing: Dp, + maxCardHeight: Dp, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row(Modifier.padding(horizontal = padding), horizontalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card2() } + } + } else { + Column(Modifier.padding(horizontal = padding), verticalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card2() } + } + } +} +``` + +### OnboardingCardView composable + +```kotlin +@Composable +fun OnboardingCardView( + imageName: ImageResource, + imageNameLight: ImageResource, + icon: ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) +``` + +Key Compose details (from layout-compose.md checklist): +- **Image:** `contentScale = ContentScale.Fit`, `Modifier.fillMaxSize()` — scaled AND centered ✓ +- **Gradient:** `Brush.linearGradient(colorStops, start, end)` with pixel Offsets computed from image area measured size via `Modifier.onSizeChanged` or `BoxWithConstraints` +- **Gradient math:** identical to iOS — same function ported to Kotlin, same angle/scale/aspect-ratio correction +- **Corner radius:** `RoundedCornerShape(24.dp)` with `Modifier.clip()` +- **Dark/light:** `if (isInDarkTheme()) imageNameLight else imageName` for image, gradient stops selected by theme +- **Conditional assets:** `if (BuildConfigCommon.SIMPLEX_ASSETS) { Image(...) }` +- **Clickable:** `Modifier.clip(RoundedCornerShape(24.dp)).clickable(onClick = onClick)` — clip first so ripple is bounded + +#### Label stripe background + +Use the same pattern as the toolbar (from DefaultTopAppBar.kt line 43-65): +```kotlin +MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) +``` + +This exactly matches the toolbar appearance, including the user's bar transparency preference. + +#### Gradient in Compose + +```kotlin +// Compute inside BoxWithConstraints or onSizeChanged callback +val imageAreaSize = Size(width, imageHeight) +val (startUnit, endUnit) = gradientPoints( + aspectRatio = imageAreaSize.height / imageAreaSize.width, + scale = if (isInDarkTheme()) 1.5f else 1.2f +) +val brush = Brush.linearGradient( + colorStops = if (isInDarkTheme()) darkStops else lightStops, + start = Offset(startUnit.x * imageAreaSize.width, startUnit.y * imageAreaSize.height), + end = Offset(endUnit.x * imageAreaSize.width, endUnit.y * imageAreaSize.height) +) +``` + +### Card icons (Moko resource names) + +Screen 1: +- "Let someone connect to you" — `MR.images.ic_add_link` +- "Connect via link or QR code" — `MR.images.ic_qr_code` + +Screen 2: +- "Invite someone privately" — `MR.images.ic_add_link` +- "Create your public address" — `MR.images.ic_qr_code` + +### Strings + +8 new entries in `strings.xml` (`MR/base/strings.xml`): +```xml +Talk to someone +Let someone connect to you +Connect via link or QR code +Connect with someone +Invite someone privately +A link for one person to connect +Create your public address +For anyone to reach you +``` + +## Files changed + +- `ChatListView.kt` — add `shouldShowOnboarding`, `noConversationChatsYet`, modify `ChatListWithLoadingScreen`, add auto-dismiss `LaunchedEffect` +- `App.kt` — add desktop overlay in `DesktopScreen`, suppress "No selected chat" in `CenterPartOfScreen` +- `MR/base/strings.xml` — 8 new strings +- **New:** `OnboardingCards.kt` — `ConnectOnboardingView`, `OnboardingCardView`, `TalkToSomeonePage`, `ConnectWithSomeonePage`, `pageHeader`, `cardPair`, `shouldShowOnboarding`, `noConversationChatsYet`, gradient math +- **New:** 8 stub SVGs in `assets/default/MR/images/` diff --git a/plans/2026-04-06-onboarding-cards-ios.md b/plans/2026-04-06-onboarding-cards-ios.md new file mode 100644 index 0000000000..f20936ea05 --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-ios.md @@ -0,0 +1,501 @@ +# Onboarding Cards — Layout Specification & iOS Implementation Plan + +## Layout Specification (cross-platform) + +This section is the authoritative reference for implementing on any platform. + +### Overall structure + +Two screens, each with a title and two tappable cards. Screens are connected by a horizontal paging transition (swipe or tap). Screen 1 has no back button; Screen 2 has a back button. Deeper views (1-time link, connect via link, SimpleX address) open as modal sheets from card taps, NOT as navigation pushes. + +The chat list toolbar (top or bottom depending on platform/settings) remains visible on both screens — the onboarding content occupies only the chat list content area. + +### Page header + +Each page has a header area containing: +- **Back button area:** fixed height 44pt. Screen 1: empty space. Screen 2: "< Back" button left-aligned. +- **Title:** centered, largeTitle font, bold, single line, shrinks to 75% minimum scale factor. +- Screen 1 title: "Talk to someone" +- Screen 2 title: "Connect with someone" + +**Portrait:** back button area and title are two separate rows (VStack). +**Landscape:** back button and title share one row (ZStack — back button leading, title centered). No separate back button row — saves vertical space. + +Padding: 16pt horizontal on the header container. Back button has no padding of its own. + +### Card layout + +**Portrait:** two cards stacked vertically (VStack, spacing 16pt). +**Landscape:** two cards side-by-side (HStack, spacing 16pt). + +Card horizontal padding: 16pt each side. + +Cards are vertically centered in the remaining space below the header. Equal space above and below the card group (Spacer with minLength 16pt on both sides). + +### Card max height + +Max total card height = card width × 0.75. + +In portrait: card width = screen width − 32pt (16pt padding each side). +In landscape: card width = (screen width − 32pt − 16pt spacing) / 2. + +Card height can be less than max on small screens. Height never exceeds max. + +### Card component + +Each card is a rounded rectangle (corner radius 24pt) containing: + +1. **Image area** (top) — gradient background + alpha-channel illustration overlay +2. **Label stripe** (bottom) — toolbar material background, fixed proportional height + +#### Image area + +- Gradient fills only the image area, NOT the label stripe +- Illustration: `.resizable().scaledToFit()`, fills available space, clipped to image area + +#### Gradient + +Stops (light mode): +- `#d2e8ff` (rgb 0.824, 0.910, 1.0) at 0% +- `#cce9ff` (rgb 0.800, 0.914, 1.0) at 50% +- `#dfffff` (rgb 0.875, 1.0, 1.0) at 90% +- `#fffcea` (rgb 1.0, 0.988, 0.918) at 100% + +Stops (dark mode): +- `#040a24` (rgb 0.016, 0.039, 0.141) at 40% +- `#3854ab` (rgb 0.220, 0.329, 0.671) at 72% +- `#a8edf3` (rgb 0.659, 0.929, 0.953) at 90% +- `#fff6e0` (rgb 1.0, 0.965, 0.878) at 100% + +Angle: 80° counter-clockwise from horizontal (almost vertical, slight rightward lean at top). + +Gradient scale (center-anchored): 1.2× in light mode, 1.5× in dark mode. This pushes start/end points further from center, reducing colored corner area. + +**Gradient endpoint calculation** (accounts for variable card aspect ratio): + +The gradient must maintain a constant 80° visual angle regardless of card proportions. Given the IMAGE AREA aspect ratio `r = imageHeight / width`: + +``` +θ = 80° (in radians: 80 × π / 180) +dx = cos(θ) +dy = −sin(θ) / r + +Project four corners (±0.5, ±0.5) onto direction (dx, dy): + projections = [−0.5·dx + (−0.5)·dy, 0.5·dx + (−0.5)·dy, −0.5·dx + 0.5·dy, 0.5·dx + 0.5·dy] + tMin = min(projections), tMax = max(projections) + dLenSq = dx² + dy² + +Base endpoints: + startX = 0.5 + tMin·dx/dLenSq, startY = 0.5 + tMin·dy/dLenSq + endX = 0.5 + tMax·dx/dLenSq, endY = 0.5 + tMax·dy/dLenSq + +Apply scale S (1.2 or 1.5) center-anchored: + finalStart = (0.5 + (startX−0.5)·S, 0.5 + (startY−0.5)·S) + finalEnd = (0.5 + (endX−0.5)·S, 0.5 + (endY−0.5)·S) +``` + +Important: aspect ratio uses IMAGE AREA height (card height minus label stripe), not total card height. + +#### Label stripe + +Height relative to card width: +- Single-line labels (Screen 1): 0.132 × card width +- Two-line labels (Screen 2): 0.195 × card width + +Background: platform toolbar material (matches the app toolbar appearance). On iOS: `Material` from `ToolbarMaterial` user setting. On Android: equivalent translucent material. + +Content layout: centered horizontally. +- Icon: 24pt, theme primary/accent color +- Title: body font (17pt), medium weight, theme foreground color, single line, shrinks to 75% +- Subtitle (Screen 2 only): footnote (13pt), theme foreground at 70% opacity + +Label stripe sits below the image area — gradient does NOT extend under it. + +### Card images + +8 alpha-channel PNGs (4 illustrations × light/dark variants). + +Screen 1: +- `card-let-someone-connect-to-you-alpha` / `-light` +- `card-connect-via-link-alpha` / `-light` + +Screen 2: +- `card-invite-someone-privately-alpha` / `-light` +- `card-create-your-public-address-alpha` / `-light` + +Light/dark selection: use base name on light backgrounds, `-light` suffix on dark backgrounds. + +Gated behind build flag (`#if SIMPLEX_ASSETS` on iOS, `BuildConfigCommon.SIMPLEX_ASSETS` on Android). Without assets: gradient-only cards with label stripe, still functional. + +### Card icons (SF Symbols / Material equivalents) + +Screen 1: +- "Let someone connect to you" — `link.badge.plus` +- "Connect via link or QR code" — `qrcode.viewfinder` + +Screen 2: +- "Invite someone privately" — `link.badge.plus` +- "Create your public address" — `qrcode` + +### Card actions + +Screen 1: +- Left card ("Let someone connect to you") → paging transition to Screen 2 +- Right card ("Connect via link or QR code") → modal sheet with ConnectView + +Screen 2: +- Left card ("Invite someone privately") → modal sheet with InviteView (1-time link) +- Right card ("Create your public address") → modal sheet with UserAddressView (auto-create) + +### Onboarding visibility + +Controlled by existing user default `addressCreationCardShown` (key: `"AddressCreationCardShown"`). + +Show onboarding when: +- `addressCreationCardShown == false` +- Chat list is not empty (chats have loaded) +- All chats are "ignorable" (note folders, deleted contacts, contact cards, pending connections/requests, invalid JSON) +- Any group = real conversation → onboarding hidden + +Auto-dismiss: when first real conversation appears, set `addressCreationCardShown = true` permanently. Observed via chat list count changes. + +### Strings (8) + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Connect with someone" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +--- + +## Scope + +Screens 1 and 2 only — two card selection screens with slide navigation between them. No standalone onboarding variants of existing views. No banner. Those are separate future work. + +## New file + +`Shared/Views/NewChat/OnboardingCards.swift` — all new code in one file. + +## What it contains + +### `OnboardingCardView` — reusable card component + +```swift +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + let imageName: String // base asset name (without -light suffix) + let icon: String // SF Symbol name + let title: LocalizedStringKey + let subtitle: LocalizedStringKey? // nil for screen 1 cards + let action: () -> Void +} +``` + +Image selection follows the project convention: +- `colorScheme == .light` → `imageName` (base name, dark-colored image for light backgrounds) +- `colorScheme == .dark` → `"\(imageName)-light"` (light-colored image for dark backgrounds) + +Note: this only works when the base name does NOT already contain `-light`. The card image base names are like `card-let-someone-connect-to-you-alpha` — the `-alpha` suffix distinguishes them, and appending `-light` gives `card-let-someone-connect-to-you-alpha-light`. Correct. + +Structure (inside → out): +1. `Button(action:)` wrapping the entire card for tap handling, with `.buttonStyle(.plain)` to prevent default blue tint +2. Clipped to `RoundedRectangle(cornerRadius: 18)` +3. Inside, `ZStack(alignment: .bottom)`: + - `LinearGradient` filling the card shape + - `VStack(spacing: 0)`: + - `#if SIMPLEX_ASSETS` block: `Image` with `.resizable().scaledToFit().frame(maxWidth: .infinity, maxHeight: .infinity)` — takes all space above label. Image uses `.clipped()` to prevent overflow into label area. + - `#else` block: `Spacer()` — gradient-only card, label still functional. + - Label area with fixed height: `HStack(spacing: 8)` with `Image(systemName: icon)` (20pt) + `VStack(alignment: .leading, spacing: 2)` containing title + optional subtitle. Padded `(.horizontal, 16)` and `(.vertical, 12)`. + +Gradient stops (using `Color(red:green:blue:)` with values 0-1, no hex extension exists in the project): +- Light: + - `Color(red: 0.824, green: 0.910, blue: 1.0)` at 0.0 (#d2e8ff) + - `Color(red: 0.800, green: 0.914, blue: 1.0)` at 0.5 (#cce9ff) + - `Color(red: 0.875, green: 1.0, blue: 1.0)` at 0.9 (#dfffff) + - `Color(red: 1.0, green: 0.988, blue: 0.918)` at 1.0 (#fffcea) +- Dark: + - `Color(red: 0.016, green: 0.039, blue: 0.141)` at 0.4 (#040a24) + - `Color(red: 0.220, green: 0.329, blue: 0.671)` at 0.72 (#3854ab) + - `Color(red: 0.659, green: 0.929, blue: 0.953)` at 0.9 (#a8edf3) + - `Color(red: 1.0, green: 0.965, blue: 0.878)` at 1.0 (#fff6e0) +- Angle: 80° from vertical = 10° from horizontal. `LinearGradient(stops:..., startPoint: .init(x: 0.0, y: 0.6), endPoint: .init(x: 1.0, y: 0.4))`. Must verify visually — the exact start/end points for 80° depend on the view's aspect ratio. May need adjustment. + +Define the gradient stops as static properties on `OnboardingCardView` to avoid recomputing them on every recomposition. + +Label text styles: +- Title: `.body` weight `.semibold`, color `Color.white` in dark mode, `Color.primary` in light mode (from design: dark text on light gradient, light text on dark gradient). +- Subtitle: `.footnote`, color `.secondary` (adapts to theme). +- Icon: same color as title. + +### `TalkToSomeoneView` — Screen 1 + +```swift +struct TalkToSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showConnectWithSomeone = false + @State private var showConnectViaLink = false +``` + +Body — NOT scrollable, fills available space: + +```swift +var body: some View { + VStack(spacing: 16) { + Text("Talk to someone") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link", + title: "Let someone connect to you", + subtitle: nil, + action: { showConnectWithSomeone = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode", + title: "Connect via link or QR code", + subtitle: nil, + action: { showConnectViaLink = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + } + .padding(.vertical, 16) + .background( + NavigationLink(isActive: $showConnectWithSomeone) { + ConnectWithSomeoneView() + } label: { EmptyView() } + ) + .background( + NavigationLink(isActive: $showConnectViaLink) { + NewChatView(selection: .connect, showQRCodeScanner: true) + .navigationBarTitleDisplayMode(.inline) + } label: { EmptyView() } + ) +} +``` + +Key layout decisions: +- `.frame(maxHeight: .infinity)` on each card makes them share remaining vertical space equally after the title takes its natural height. +- `.padding(.vertical, 16)` on the VStack adds 16pt above the title and 16pt below the second card (VStack `spacing` only applies between children, not before first or after last). +- Hidden `NavigationLink(isActive:)` in `.background()` — drives navigation without affecting layout. This is the deprecated iOS 15 API but it works on iOS 16+ inside `NavigationStack` and is used throughout the existing codebase (e.g., `NewChatMenuButton.swift` lines 100-110). + +**`oneHandUI` inversion handling:** `TalkToSomeoneView` replaces `chatList` content. `chatListView` applies `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` to the root page. The onboarding view gets inverted. It must counter-invert with `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)`. This is applied in `ChatListView.chatList`, NOT inside `TalkToSomeoneView` — the caller is responsible. When NavigationLink pushes Screen 2 or further views, those are new navigation pages outside the root page's scale effect, so they render normally. + +### `ConnectWithSomeoneView` — Screen 2 + +```swift +struct ConnectWithSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showInviteSomeone = false + @State private var showCreateAddress = false +``` + +Same VStack layout as Screen 1, with these differences: +- Title: "Connect with someone" +- Card 1: imageName `"card-invite-someone-privately-alpha"`, icon `"link"`, title "Invite someone privately", subtitle "A link for one person to connect" → sets `showInviteSomeone = true` +- Card 2: imageName `"card-create-your-public-address-alpha"`, icon `"qrcode"`, title "Create your public address", subtitle "For anyone to reach you" → sets `showCreateAddress = true` + +Navigation destinations (existing views, unmodified — onboarding variants are future work): +- `showInviteSomeone` → `NewChatView(selection: .invite)` — tabbed view, 1-time link tab pre-selected. Has tabs (not ideal) but functional. +- `showCreateAddress` → `UserAddressView(shareViaProfile: false, autoCreate: true)` — auto-creates address on appear. + +Both wrapped with `.navigationBarTitleDisplayMode(.inline)`. + +Navigation bar back button shows automatically (pushed via NavigationLink within the stack). + +## Integration into ChatListView + +### In `chatList` property (line 351 of ChatListView.swift) + +Current code: +```swift +private var chatList: some View { + let cs = filteredChats() + return ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } +} +``` + +Changed to: +```swift +@ViewBuilder +private var chatList: some View { + if shouldShowOnboarding { + TalkToSomeoneView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + } else { + let cs = filteredChats() + ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } + } +} +``` + +Requires `@ViewBuilder` because `if/else` returns different view types. + +**`oneHandUI` inversion:** The `.scaleEffect(y: -1)` is applied by `chatListView` to the root page of the navigation stack. `TalkToSomeoneView` counter-inverts at the call site. When `NavigationLink` pushes Screen 2 or further, those are new navigation pages NOT affected by the root page's `.scaleEffect`. Only the root content needs the flip. + +### `shouldShowOnboarding` and `noConversationChatsYet` + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && noConversationChatsYet +} + +private var noConversationChatsYet: Bool { + chatModel.chats.allSatisfy { chat in + switch chat.chatInfo { + case .local: return true + case let .direct(contact): return contact.chatDeleted || contact.isContactCard + case let .group(groupInfo, _): return groupInfo.chatDeleted + case let .contactRequest(req): return req.chatDeleted + case let .contactConnection(conn): return conn.chatDeleted + case .invalidJSON: return true + } + } +} +``` + +Both are computed properties on `ChatListView`. `noConversationChatsYet` reads `chatModel.chats` which is `@Published` on `ChatModel` (`@EnvironmentObject`). SwiftUI re-evaluates the body when it changes, so `shouldShowOnboarding` is reactive. + +Note: `chatModel.chats` may be empty during initial load (before `APIGetChats` completes). `allSatisfy` on an empty array returns `true`. Combined with `!addressCreationCardShown`, this means the onboarding flashes briefly on app launch for users who have conversations but `chats` hasn't loaded yet. Mitigation: also check `chatModel.chats.isEmpty` and show a loading indicator instead: + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && noConversationChatsYet +} +``` + +When `chats` is empty (loading), neither onboarding nor chat list shows — the existing loading state (if any) handles it. + +### Auto-dismiss + +`addressCreationCardShown` must be set to `true` when the first real conversation appears, so the onboarding never returns. + +```swift +.onChange(of: chatModel.chats.count) { _ in + if !noConversationChatsYet && !addressCreationCardShown { + addressCreationCardShown = true + } +} +``` + +Placed on `chatList` view. Observes `.count` as a proxy for chat list changes. When count changes and `noConversationChatsYet` is false, the user default is set permanently. This covers: receiving a contact request, establishing a connection, creating a group, etc. + +Edge case: chat count can change without affecting `noConversationChatsYet` (e.g., adding a second note folder). The check `!noConversationChatsYet` prevents unnecessary writes — only sets the default when there's actually a real conversation. + +## User default + +Existing `@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false` at ChatListView line 165. Constant defined in `SettingsView.swift` line 55 as `let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"`. Also referenced in `AddressCreationCard.swift` line 17 and in `SettingsView.swift` defaults reset (line 114, 144). + +No new user default needed. + +## String localization + +8 new strings for `Localizable.strings` (en). Use `NSLocalizedString` or `LocalizedStringKey` inline — project uses both patterns. + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Connect with someone" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +## Assets + +8 card images in art repo (4 base + 4 light variants). Run `resize.sh` then `copy-assets.sh` to populate `SimpleXAssets.xcassets`. Gated with `#if SIMPLEX_ASSETS`. Without assets: gradient-only cards with labels, still tappable and functional. + +Image base names for the `imageName` parameter: +- Screen 1: `"card-let-someone-connect-to-you-alpha"`, `"card-connect-via-link-alpha"` +- Screen 2: `"card-invite-someone-privately-alpha"`, `"card-create-your-public-address-alpha"` + +The `-light` suffix is appended automatically by `OnboardingCardView` when `colorScheme == .dark`. + +## Files changed + +- `Shared/Views/ChatList/ChatListView.swift` — add `shouldShowOnboarding`, `noConversationChatsYet`, add `@ViewBuilder` to `chatList`, branch to `TalkToSomeoneView`, add `.onChange` for auto-dismiss +- **New:** `Shared/Views/NewChat/OnboardingCards.swift` — `OnboardingCardView`, `TalkToSomeoneView`, `ConnectWithSomeoneView` + +No modifications to NewChatView, UserAddressView, or ConnectView in this phase. + +## Revision 1 — corrections from design review + +### Navigation scope (critical) +Both screens must keep the bottom/top toolbar visible. The onboarding NavigationView is SCOPED to just the card area — it does NOT replace the full chatListView. In `chatList`, wrap `TalkToSomeoneView()` in its own `NavigationView { }.navigationViewStyle(.stack)`. The toolbar from `chatListView.withToolbar()` stays outside and visible on both screens. + +Screen 1 → Screen 2: real NavigationLink push within the scoped NavigationView. +Screen 2 → deeper views: also NavigationLink pushes within same scoped NavigationView. + +### Screen 1 — reserve nav bar space +Screen 2 has a back button (navigation bar). Screen 1 must reserve the same height to prevent content shift on slide. Set `.navigationTitle("")` with `.navigationBarTitleDisplayMode(.inline)` on Screen 1's root — shows an empty inline nav bar matching Screen 2's bar height. + +### Gradient direction fix +Current gradient is nearly horizontal — wrong. Correct angle is 80° CCW from horizontal (almost vertical, slight rightward lean). + +Formula for full-coverage gradient at angle θ: +``` +startPoint = (0.5 - 0.5·cos(θ), 0.5 + 0.5·sin(θ)) +endPoint = (0.5 + 0.5·cos(θ), 0.5 - 0.5·sin(θ)) +``` + +For θ = 80°: `startPoint: .init(x: 0.413, y: 0.992), endPoint: .init(x: 0.587, y: 0.008)` + +### Corner radius +Change from 18 to 24. + +### Label stripe background +The label area has a distinct semi-transparent background strip at the bottom of the card. Add to `labelRow`: +- Light mode: `Color.white.opacity(0.5)` +- Dark mode: `Color.black.opacity(0.3)` +Exact opacity values need visual tuning. + +### Card max height ratio +Cards have a max total height/width ratio of 0.75. On tall screens, cards are capped at this ratio with extra space distributed equally above and below. On short screens, cards shrink — ratio goes below 0.75, label stripe stays fixed height, only image area shrinks. + +Implementation: use `GeometryReader` to get available width, compute `maxCardHeight = cardWidth * 0.75`, apply `.frame(maxHeight: maxCardHeight)` on each card. The VStack centers vertically in the GeometryReader — equal space above and below on tall screens. + +### Title alignment +Change from `.leading` to `.center` — design shows centered titles on both screens. + +### Subtitle color in dark mode +Change `.foregroundColor(.secondary)` to `.foregroundColor(colorScheme == .dark ? .white.opacity(0.7) : .secondary)` — standard `.secondary` is too gray on the dark gradient. + +### Label stripe height proportions +The label stripe has fixed proportional heights relative to card width: +- Screen 1 (single-line labels): 0.132 × card width +- Screen 2 (two-line labels): 0.195 × card width + +These are achieved via fixed padding on the label row. The image area is the remainder of the card height. When cards shrink on short screens, only the image area shrinks — the label stripe stays at its proportional height. + +### Spacing between title and cards, between cards, and below cards +The gaps above first card and below second card should be EQUAL and LARGER than the gap between the two cards. The inter-card gap is the VStack spacing (~16pt). The outer gaps are larger — achieved by the GeometryReader centering the VStack vertically, which distributes extra space equally above and below. + +### ThemedBackground on TalkToSomeoneView +`TalkToSomeoneView` needs `.modifier(ThemedBackground())` — it replaces `chatList` content and needs its own background. Currently missing. + +### `oneHandUI` inversion on Screen 2 +The scoped `NavigationView` sits inside `chatList` which is visually inverted by `chatListView`'s `.scaleEffect(y: -1)`. This inversion applies to the NavigationView's rendered frame — ALL pages inside it (both Screen 1 and Screen 2) are inverted. `TalkToSomeoneView` counter-inverts at the call site. `ConnectWithSomeoneView` (pushed within the NavigationView) also needs counter-inversion. Pass `oneHandUI` as a binding or read from `@AppStorage(GROUP_DEFAULT_ONE_HAND_UI)` directly inside `ConnectWithSomeoneView`, and apply `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` on its root VStack. Same for any deeper pushed views — but those are existing views not modified in this phase, so their inversion behavior needs testing. + +### Plan cleanup note +The original sections above contain outdated code snippets (wrong gradient, wrong corner radius, wrong switch cases, wrong alignment). The Revision 1 sections are authoritative. When implementing, follow Revision 1 values; treat original sections as structural context only. + diff --git a/scripts/android/copy-assets.sh b/scripts/android/copy-assets.sh new file mode 100755 index 0000000000..c76383faae --- /dev/null +++ b/scripts/android/copy-assets.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +# Copies generated multiplatform assets into the SimpleX assets directory. +# Called by Gradle build when simplex.assets.dir property is set. +# +# Usage: copy-assets.sh + +SRC_DIR="$1/multiplatform/resources/MR/images" +DEST_DIR="$2/MR/images" + +if [ ! -d "$SRC_DIR" ]; then + echo "Error: source assets not found: $SRC_DIR (run resize.sh first)" >&2 + exit 1 +fi + +rm -rf "$DEST_DIR" +mkdir -p "$DEST_DIR" + +cp "$SRC_DIR"/* "$DEST_DIR/" +echo "Copied multiplatform assets to $DEST_DIR" diff --git a/scripts/ios/copy-assets.sh b/scripts/ios/copy-assets.sh new file mode 100755 index 0000000000..f29ebd2464 --- /dev/null +++ b/scripts/ios/copy-assets.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +# Copies generated iOS assets into SimpleXAssets.xcassets. +# Intended to run as an Xcode Run Script build phase. +# Skips silently if SIMPLEX_ASSETS is not in SWIFT_ACTIVE_COMPILATION_CONDITIONS +# or if the source directory is not found. +# +# The source path is resolved in order: +# 1. Command-line argument +# 2. SIMPLEX_ASSETS_DIR build setting (set in Local.xcconfig) +# 3. No default — skips if neither is set +# +# Manual usage: ./scripts/copy-assets.sh path/to/assets + +# Skip if SIMPLEX_ASSETS flag is not set (unless run manually outside Xcode) +if [ -n "${SWIFT_ACTIVE_COMPILATION_CONDITIONS:-}" ]; then + case " $SWIFT_ACTIVE_COMPILATION_CONDITIONS " in + *" SIMPLEX_ASSETS "*) ;; + *) exit 0 ;; + esac +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +IOS_DIR="$SCRIPT_DIR/../../apps/ios/Shared/SimpleXAssets.xcassets" + +ASSETS_ROOT="${1:-${SIMPLEX_ASSETS_DIR:-}}" +if [ -z "$ASSETS_ROOT" ]; then + echo "warning: SIMPLEX_ASSETS_DIR not set and no path argument provided" >&2 + exit 0 +fi + +SRC_DIR="$ASSETS_ROOT/ios/Assets.xcassets" + +if [ ! -d "$SRC_DIR" ]; then + echo "warning: source assets not found: $SRC_DIR (run resize.sh first)" >&2 + exit 0 +fi + +# Remove old imagesets but keep root Contents.json +find "$IOS_DIR" -name "*.imageset" -type d -exec rm -rf {} + 2>/dev/null || true + +# Copy imagesets +for imageset in "$SRC_DIR"/*.imageset; do + [ -d "$imageset" ] || continue + cp -r "$imageset" "$IOS_DIR/" + echo "Copied $(basename "$imageset")" +done + +echo "Done. Assets copied to $IOS_DIR" diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 82bea05f89..9fe726a02b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."95b17ada2795e1c5c84bbe2a50a0752ee66d0aad" = "0n10vjsslay4lkhripjwgyiclsx714prwcblmnf1vgwgc97md14s"; + "https://github.com/simplex-chat/simplexmq.git"."858fac7f4f821a2df6fbea03a1bfbb82ea9717c5" = "1fhzynf80db7h6y2wv61fsdfd80f0blja9ljsfh405r11yg2yxvi"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index ca2581eb01..714aa0c0ed 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -41,7 +41,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) -import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) +import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig (..), defaultFileExpiration) +import Simplex.FileTransfer.Server.Store import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite @@ -589,11 +590,12 @@ xftpTestPort = "7002" xftpServerFiles :: FilePath xftpServerFiles = "tests/tmp/xftp-server-files" -xftpServerConfig :: XFTPServerConfig +xftpServerConfig :: XFTPServerConfig STMFileStore xftpServerConfig = XFTPServerConfig { xftpPort = xftpTestPort, fileIdSize = 16, + serverStoreCfg = XSCMemory $ Just "tests/tmp/xftp-server-store.log", storeLogFile = Just "tests/tmp/xftp-server-store.log", filesPath = xftpServerFiles, fileSizeQuota = Nothing, @@ -628,7 +630,7 @@ xftpServerConfig = withXFTPServer :: IO () -> IO () withXFTPServer = withXFTPServer' xftpServerConfig -withXFTPServer' :: XFTPServerConfig -> IO () -> IO () +withXFTPServer' :: XFTPServerConfig STMFileStore -> IO () -> IO () withXFTPServer' cfg = serverBracket ( \started -> do diff --git a/website/langs/en.json b/website/langs/en.json index 51b5b3d547..25e5f128ed 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -18,7 +18,7 @@ "simplex-explained-tab-2-p-1": "For each connection you use two separate messaging queues to send and receive messages via different servers.", "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", "simplex-explained-tab-3-p-1": "The servers have separate anonymous credentials for each queue, and do not know which users they belong to.", - "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing corellation by IP address.", + "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing correlation by IP address.", "chat-bot-example": "Chat bot example", "smp-protocol": "SMP protocol", "chat-protocol": "Chat protocol", @@ -76,7 +76,7 @@ "simplex-private-card-9-point-1": "Each message queue passes messages in one direction, with the different send and receive addresses.", "simplex-private-card-9-point-2": "It reduces the attack vectors, compared with traditional message brokers, and available meta-data.", "simplex-private-card-10-point-1": "SimpleX uses temporary anonymous pairwise addresses and credentials for each user contact or group member.", - "simplex-private-card-10-point-2": "It allows to deliver messages without user profile identifiers, providing better meta-data privacy than alternatives.", + "simplex-private-card-10-point-2": "It allows messages to be delivered without user profile identifiers, providing better meta-data privacy than alternatives.", "privacy-matters-1-title": "Advertising and price discrimination", "privacy-matters-1-overlay-1-title": "Privacy saves you money", "privacy-matters-1-overlay-1-linkText": "Privacy saves you money", @@ -113,13 +113,13 @@ "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", - "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", + "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connections and cannot be used by an attacker to amplify the traffic in the whole network.", "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as \"poverty premium\".", - "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", + "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph from becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", - "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication network that does not have access to your social graph.", + "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use a private communication network that does not have access to your social graph.", "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", @@ -135,7 +135,7 @@ "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", - "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to seeing what SimpleX services you will build.", "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", @@ -166,7 +166,7 @@ "to-make-a-connection": "To make a connection:", "install-simplex-app": "Install SimpleX app", "connect-in-app": "Connect in app", - "open-simplex-app": "Open Simplex app", + "open-simplex-app": "Open SimpleX app", "tap-the-connect-button-in-the-app": "Tap the \"connect\" button in the app", "scan-the-qr-code-with-the-simplex-chat-app": "Scan the QR code with the SimpleX Chat app", "scan-the-qr-code-with-the-simplex-chat-app-description": "The public keys and message queue address in this link are NOT sent over the network when you view this page —
they are contained in the hash fragment of the link URL.", diff --git a/website/src/css/design3.css b/website/src/css/design3.css index 5e5be66c2f..ea6f20273b 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -830,7 +830,7 @@ main .section-bg { .page .text-container h2 { font-family: "GT-Walsheim", sans-serif; - font-weight: 300; + font-weight: 400; font-size: calc(var(--sec-vwu)*4.94); letter-spacing: -0.025em; line-height: 1.05; diff --git a/website/src/directory.html b/website/src/directory.html index b20e279d82..4ea42f0c3b 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -268,7 +268,7 @@ active_directory: true

Welcome to the selected users' communities that you can join via SimpleX Chat app.

SimpleX Directory is also available as a SimpleX chat bot.

-

Read about how to add your community.

+

Read about how to add your community.