Merge branch 'master' into ae/oklch-color-space-plan

This commit is contained in:
Evgeny Poberezkin
2026-04-21 22:39:43 +01:00
94 changed files with 3124 additions and 540 deletions
+4
View File
@@ -69,3 +69,7 @@ Libraries/
Shared/MyPlayground.playground/*
testpush.sh
# Local build config and generated assets
Local.xcconfig
Shared/SimpleXAssets.xcassets/*.imageset
+2
View File
@@ -0,0 +1,2 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
#include? "Local.xcconfig"
+21
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
#include? "Local.xcconfig"
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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:
@@ -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()
@@ -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")
}
}
+99 -26
View File
@@ -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)
@@ -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<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showInviteSomeone) {
NavigationView {
NewChatView(selection: .invite, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showCreateAddress) {
NavigationView {
UserAddressView(autoCreate: true, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
@ViewBuilder
private func cardPair<C1: View, C2: View>(
_ 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)
}
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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
@@ -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"),
+43 -5
View File
@@ -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 = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
@@ -621,6 +623,10 @@
E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = "<group>"; };
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = "<group>"; };
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = "<group>"; };
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = "<group>"; };
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -675,6 +681,7 @@
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = "<group>"; };
E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = "<group>"; };
E5E418002F83D2CA00252B9E /* OnboardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCards.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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;
@@ -1,5 +1,5 @@
{
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9",
"pins" : [
{
"identity" : "codescanner",
+8 -12
View File
@@ -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")
+4 -1
View File
@@ -16,4 +16,7 @@ android/build
android/release
common/build
desktop/build
release
release
# Generated SimpleX assets
common/src/commonMain/resources/assets/simplex/
+3
View File
@@ -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
@@ -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<Exec>("copySimplexAssets") {
commandLine(
"${rootProject.rootDir}/../../scripts/android/copy-assets.sh",
resolvedAssetsDir,
simplexAssetsLocal.absolutePath
)
}
} else {
tasks.register<Delete>("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 {
@@ -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()
@@ -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)
}
@@ -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<API?>) {
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 {
@@ -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 {
@@ -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
@@ -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),
@@ -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) {
@@ -195,7 +195,7 @@ fun SendMsgView(
)
}
}
if (timedMessageAllowed) {
if (timedMessageAllowed && !cs.editing) {
menuItems.add {
ItemAction(
generalGetString(MR.strings.disappearing_message),
@@ -196,12 +196,13 @@ class SelectionManager {
}
}
fun getSelectedCopiedText(items: List<MergedItem>, linkMode: SimplexLinkMode): String {
fun getSelectedCopiedText(items: List<MergedItem>, revealedItems: Set<Long>, 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<LazyListState>,
mergedItems: State<MergedItems>,
revealedItems: State<Set<Long>>,
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))
}
@@ -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<TextFieldValue>, 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<AnimatedViewState>
}
},
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<TextFieldValue>, 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<ImageResou
PresetTagKind.FAVORITES -> (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
}
@@ -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) {
@@ -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(
@@ -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
)
@@ -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<PendingContactConnection?> = 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<PendingContactConnection?>, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState<Boolean>) {
private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState<PendingContactConnection?>, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState<Boolean>, 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<PendingContactConnection?>) {
private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState<PendingContactConnection?>, 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<Boolean>, pastedLink: MutableState<String>, close: () -> Unit) {
private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState<Boolean>, pastedLink: MutableState<String>, 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<String>, 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()
@@ -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<Chat>): 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()
}
}
}
@@ -880,6 +880,38 @@ private val versionDescriptions: List<VersionDescription> = 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
@@ -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<Boolean>,
onboarding: Boolean = false,
createAddress: () -> Unit,
showAddShortLinkAlert: ((() -> Unit)?) -> Unit,
learnMore: () -> Unit,
@@ -259,68 +279,100 @@ private fun UserAddressLayout(
saveAddressSettings: (AddressSettingsState, MutableState<AddressSettingsState>) -> 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))
}
}
}
}
@@ -465,6 +465,15 @@
<string name="tap_to_start_new_chat">Tap to start a new chat</string>
<string name="chat_with_developers">Chat with the developers</string>
<string name="you_have_no_chats">You have no chats</string>
<string name="talk_to_someone">Talk to someone</string>
<string name="let_someone_connect_to_you">Let someone connect to you</string>
<string name="connect_via_link_or_qr_code">Connect via link or QR code</string>
<string name="connect_with_someone">Connect with someone</string>
<string name="invite_someone_privately">Invite someone privately</string>
<string name="a_link_for_one_person">A link for one person to connect</string>
<string name="create_your_public_address">Create your public address</string>
<string name="your_public_address">Your public address</string>
<string name="for_anyone_to_reach_you">For anyone to reach you</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_chats_in_list">No chats in list %s.</string>
@@ -497,6 +506,7 @@
<string name="chat_list_favorites">Favorites</string>
<string name="chat_list_contacts">Contacts</string>
<string name="chat_list_groups">Groups</string>
<string name="chat_list_channels">Channels</string>
<string name="chat_list_businesses">Businesses</string>
<string name="chat_list_notes">Notes</string>
<string name="chat_list_group_reports">Reports</string>
@@ -903,7 +913,7 @@
<string name="new_chat">New chat</string>
<string name="new_message">New message</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="scan_paste_link">Paste link / Scan</string>
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
@@ -1131,12 +1141,12 @@
<string name="all_your_contacts_will_remain_connected">All your contacts will remain connected.</string>
<string name="all_your_contacts_will_remain_connected_update_sent">All your contacts will remain connected. Profile update will be sent to your contacts.</string>
<string name="share_link">Share link</string>
<string name="add_address_to_your_profile">Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts.</string>
<string name="add_address_to_your_profile">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.</string>
<string name="create_address_and_let_people_connect">Create an address to let people connect with you.</string>
<string name="create_simplex_address">Create SimpleX address</string>
<string name="share_with_contacts">Share with contacts</string>
<string name="share_address_with_contacts_question">Share address with contacts?</string>
<string name="profile_update_will_be_sent_to_contacts">Profile update will be sent to your contacts.</string>
<string name="share_with_contacts">Share with SimpleX contacts</string>
<string name="share_address_with_contacts_question">Share address with SimpleX contacts?</string>
<string name="profile_update_will_be_sent_to_contacts">Profile update will be sent to your SimpleX contacts.</string>
<string name="stop_sharing_address">Stop sharing address?</string>
<string name="stop_sharing">Stop sharing</string>
<string name="auto_accept_contact">Auto-accept</string>
@@ -1153,6 +1163,11 @@
<string name="or_to_share_privately">Or to share privately</string>
<string name="simplex_address_or_1_time_link">SimpleX address or 1-time link?</string>
<string name="create_1_time_link">Create 1-time link</string>
<string name="new_1_time_link">New 1-time link</string>
<string name="onboarding_send_1_time_link">Send the link via any messenger - it\'s secure. Ask to paste into SimpleX.</string>
<string name="onboarding_or_show_qr_code">Or show QR in person or via video call.</string>
<string name="onboarding_post_address">Use this address in your social media profile, website, or email signature.</string>
<string name="onboarding_or_use_qr_code">Or use this QR - print or show online.</string>
<string name="address_settings">Address settings</string>
<string name="business_address">Business address</string>
<string name="add_your_team_members_to_conversations">Add your team members to the conversations.</string>
@@ -2564,6 +2579,17 @@
<string name="v6_4_1_short_address_share">Share your address</string>
<string name="v6_4_1_new_interface_languages">4 new interface languages</string>
<string name="v6_4_1_new_interface_languages_descr">Catalan, Indonesian, Romanian and Vietnamese - thanks to our users!</string>
<string name="v6_5_public_channels">Public channels - speak freely 🚀</string>
<string name="v6_5_reliability">Reliability: many relays per channel.</string>
<string name="v6_5_ownership">Ownership: you can run your own relays.</string>
<string name="v6_5_security">Security: owners hold channel keys.</string>
<string name="v6_5_privacy">Privacy: for owners and subscribers.</string>
<string name="v6_5_invite_friends">Easier to invite your friends 👋</string>
<string name="v6_5_invite_friends_descr">We made connecting simpler for new users.</string>
<string name="v6_5_safe_web_links">Safe web links</string>
<string name="v6_5_safe_web_links_descr">- opt-in to send link previews.\n- prevent hyperlink phishing.\n- remove link tracking.</string>
<string name="v6_5_non_profit_governance">Non-profit governance</string>
<string name="v6_5_non_profit_governance_descr">To make SimpleX Network last.</string>
<string name="view_updated_conditions">View updated conditions</string>
<!-- CustomTimePicker -->
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M85-704.5V-875h170.5v57.5h-113v113H85ZM85-85v-170.5h57.5v113h113V-85H85Zm619.5 0v-57.5h113v-113H875V-85H704.5Zm113-619.5v-113h-113V-875H875v170.5h-57.5ZM705-254h61.5v61.5H705V-254Zm0-123h61.5v61.5H705V-377Zm-61.5 61.5H705v61.5h-61.5v-61.5ZM582-254h61.5v61.5H582V-254Zm-61.5-61.5H582v61.5h-61.5v-61.5Zm123-123H705v61.5h-61.5v-61.5ZM582-377h61.5v61.5H582V-377Zm-61.5-61.5H582v61.5h-61.5v-61.5Zm246-329v246h-246v-246h246Zm-328 329v246h-246v-246h246Zm0-329v246h-246v-246h246Zm-48 526.5v-149.5H241V-241h149.5Zm0-328.5V-719H241v149.5h149.5Zm327.5 0V-719H568.5v149.5H718Z"/></svg>

After

Width:  |  Height:  |  Size: 689 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -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,
@@ -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
}
}
@@ -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
@@ -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. Were not asking for or accepting any money right now, and we wont accept any if sent. We cant accept any offers to buy securities or take any payments until the official filing is done and its live through a regulated platform. Our testing the waters and your possible indications of interest doesnt create any obligation or commitment of any kind._
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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;
```
+2 -2
View File
@@ -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`.
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+9 -9
View File
@@ -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?
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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.
+32 -32
View File
@@ -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 <chat@simplex.chat>"
5. Compute the hashes of the binaries you plan to use with `shu256sum <file>` or with `openssl sha256 <file>` 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 <file>` or with `openssl sha256 <file>` 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: <HOSTING_PROVIDER_LOCATION>
```
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://<fingerprint>[:<password>]@<public_hostname>[,<onion_hostname>]
- **optional** `<password>`
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.
- `<public_hostname>`, **optional** `<onion_hostname>`
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+4 -4
View File
@@ -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 <your_ip_or_domain>
```
If packets being transmitted, server is up!
If packets are being transmitted, the server is up!
- **Determine if ports are open**:
+13 -13
View File
@@ -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://<fingerprint>[:<password>]@<public_hostname>[,<onion_hostname>]
- **optional** `<password>`
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.
- `<public_hostname>`, **optional** `<onion_hostname>`
@@ -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
```
@@ -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<Chat>): 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
<string name="talk_to_someone">Talk to someone</string>
<string name="let_someone_connect_to_you">Let someone connect to you</string>
<string name="connect_via_link_or_qr">Connect via link or QR code</string>
<string name="connect_with_someone">Connect with someone</string>
<string name="invite_someone_privately">Invite someone privately</string>
<string name="a_link_for_one_person">A link for one person to connect</string>
<string name="create_your_public_address">Create your public address</string>
<string name="for_anyone_to_reach_you">For anyone to reach you</string>
```
## 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/`
+501
View File
@@ -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 + (startX0.5)·S, 0.5 + (startY0.5)·S)
finalEnd = (0.5 + (endX0.5)·S, 0.5 + (endY0.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.
+21
View File
@@ -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 <source-dir> <dest-dir>
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"
+50
View File
@@ -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"
+1 -1
View File
@@ -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";
+5 -3
View File
@@ -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
+7 -7
View File
@@ -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&apos;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 <a href=\"https://en.wikipedia.org/wiki/Man-in-the-middle_attack\">MITM attack</a> 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 <a href=\"https://en.wikipedia.org/wiki/BitTorrent\">BitTorrent</a>). SimpleX is transport agnostic &mdash; 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 <a href=\"https://en.wikipedia.org/wiki/Sybil_attack\">Sybil attack</a>, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">proof of work</a>. 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 <a href=\"https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent\">DRDoS attack</a>, 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 <a href=\"https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent\">DRDoS attack</a>, 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&apos;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 &mdash; it is known as <a href=\"https://fairbydesign.com/povertypremium/\" target=\"_blank\">\"poverty premium\"</a>.",
"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 href=\"https://en.wikipedia.org/wiki/FacebookCambridge_Analytica_data_scandal\" target=\"_blank\">a reputable consulting company</a> 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&apos;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 &mdash; 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 <a href=\"https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi\" target=\"_blank\">Mohamedou Ould Salahi</a> 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&apos;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 &mdash; 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 <strong>use SimpleX with your own servers</strong> and still communicate with people who use the servers preconfigured in the apps.",
"simplex-unique-overlay-card-4-p-2": "SimpleX network uses an <a href=\"https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md\" target=\"_blank\">open protocol</a> and provides <a href=\"https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript\" target=\"_blank\">SDK to create chat bots</a>, allowing implementation of services that users can interact with via SimpleX Chat apps &mdash; we&apos;re really looking forward to see what SimpleX services you will build.",
"simplex-unique-overlay-card-4-p-2": "SimpleX network uses an <a href=\"https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md\" target=\"_blank\">open protocol</a> and provides <a href=\"https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-client/typescript\" target=\"_blank\">SDK to create chat bots</a>, allowing implementation of services that users can interact with via SimpleX Chat apps &mdash; we&apos;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 <a href=\"https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D\" target=\"_blank\">get in touch</a> 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 &mdash; <strong>not even random numbers</strong>.",
@@ -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 <span class=\"text-active-blue\">\"connect\"</span> 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 &mdash;<br> they are contained in the hash fragment of the link URL.",
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -268,7 +268,7 @@ active_directory: true
<p>Welcome to the selected users' communities that you can join via <a href="/downloads">SimpleX Chat
app</a>.</p>
<p>SimpleX Directory is also available as a <a href="https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" target="_blank">SimpleX chat bot</a>.</p>
<p>Read about <a href="/docs/directory.html">how to add</a> your community</a>.</p>
<p>Read about <a href="/docs/directory.html">how to add</a> your community.</p>
<div class="search-container">
<input id="search" autocomplete="off">
<div id="top-pagination" class="pagination">
+6 -6
View File
@@ -16,7 +16,7 @@ Community Vouchers offer the solution - they are prepaid infrastructure credits
These vouchers are not tradable tokens or speculative assets — there will be no pre-sale or emission. It's a method to pay directly for the network infrastructure while maintaining privacy.
For early access to test vouchers, if you're familiar with cryptocurrencies, <a href="javascript:void(0);" data-show-overlay="mint-simplex-nft" class="open-overlay-btn">get a free access pass</a> to the test version &mdash; a free non-transferrable NFT on Ethereum mainnet, you only need to pay for gas.
For early access to test vouchers, if you're familiar with cryptocurrencies, <a href="javascript:void(0);" data-show-overlay="mint-simplex-nft" class="open-overlay-btn">get a free access pass</a> to the test version &mdash; a free non-transferable NFT on Ethereum mainnet, you only need to pay for gas.
## Why Community Vouchers?
@@ -37,11 +37,11 @@ In short:
<img src="/img/design_3/community_vouchers_dark.jpg" width="38%" class="float-to-right hidden dark:block">
- Buy Community Vouchers. Initially you would pay with a stablecoin (USDT/USDC). The goal to allow using other popular cryptocurrencies (BTC/ETH/XMR) and also in-app payments - to make direct usage of blockchain optional for the end users.
- Buy Community Vouchers. Initially you would pay with a stablecoin (USDT/USDC). The goal is to allow using other popular cryptocurrencies (BTC/ETH/XMR) and also in-app payments - to make direct usage of blockchain optional for the end users.
- Funds are locked in an autonomous smart contract not controlled by SimpleX Chat company or by anybody else.
- Assign the Community Voucher to a group or channel you want, using it's public address. This assignment is private, and group owners or server operators won't be able to link it to the purchase, thanks to zero-knowledge proofs.
- Assign the Community Voucher to a group or channel you want, using its public address. This assignment is private, and group owners or server operators won't be able to link it to the purchase, thanks to zero-knowledge proofs.
- Group or channel owners redeem the Vouchers to the server operators they use. The redemption is also private, and not linkable to the assignment or purchase.
@@ -57,14 +57,14 @@ It's the only way to make SimpleX network truly decentralized and secure:
- Private and secure payments based on zero-knowledge proofs in smart-contracts.
We are currently evaluating several popular blockchains that have strong support for zero-knowledge proofs - technology that support private operations on public blockchains.
We are currently evaluating several popular blockchains that have strong support for zero-knowledge proofs - technology that supports private operations on public blockchains.
## Timeline & How to Get Involved
**2025**:
- evaluating blockchains,
- drafting Community Vouchers whitepaper about system and cryptography design for Community Vouchers.
- development of large group and communities.
- development of large groups and communities.
We welcome your feedback on this proposal and any in-progress design documents.
@@ -114,7 +114,7 @@ Server operators will receive up to 70% of the infrastructure payments. A higher
- more reliable servers,
- servers that operated for a longer time.
**What is technology design?**
**What is the technology design?**
[The conceptual design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-12-10-vouchers-2.md) for Community Vouchers uses zero-knowledge proofs, making the purchase, assigning vouchers to groups and their redemptions unlinkable.