mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24d126a125 | |||
| 6bae86d93b | |||
| 4d82afe602 | |||
| 591f74a8e3 | |||
| aede65db14 | |||
| 0bf82c08a1 | |||
| ddef5a122c | |||
| 0fdd2e04cc | |||
| d1e66386f5 | |||
| 708368c9c5 | |||
| 5d8e11c3a2 | |||
| df8ce2b06d | |||
| 6ff5f31bee | |||
| 5e29cda27b | |||
| 2e573662d5 | |||
| 4075c26dd2 | |||
| d64351b760 | |||
| b06211bd4e | |||
| 33bc539e16 | |||
| febea096db | |||
| f0781adbd3 | |||
| df30bb99cb | |||
| 7c86484978 | |||
| 307211a47f | |||
| 7d6c7c58d7 | |||
| 93319d947d | |||
| ea4927c9b0 | |||
| fe0d811bf7 | |||
| cbb3da8f83 | |||
| 83f0bd9fd3 | |||
| 615c483912 | |||
| e0c2272fcb | |||
| 362581432c | |||
| df1a471c56 | |||
| 7d43a43e82 | |||
| ae8ad5c639 | |||
| 1408d75eb3 | |||
| f408988035 | |||
| 945c5015d8 | |||
| 2e431c5afa | |||
| 924273191e | |||
| 9b82cc3303 | |||
| 19e2cebd68 | |||
| 5ef14ca95e | |||
| 886dc56de8 | |||
| bd2ca74987 | |||
| 586671c307 | |||
| ff504702de | |||
| 69e23ad58f | |||
| e9bf229a9d | |||
| 60e0e454e8 | |||
| de76e271a8 | |||
| 5f66c29dbd | |||
| 97cd2682d7 | |||
| 4f8a70a6c1 | |||
| c1a0943448 | |||
| c1c17d1f19 | |||
| 009d13210f | |||
| 892f6498be | |||
| 3fa1d7b07c | |||
| ee146cdc7b | |||
| 219381f941 | |||
| 89f380400e | |||
| 4f1cf6e79f | |||
| 6ff7d4a73c | |||
| 3acc69c6d8 | |||
| b9777c92a5 | |||
| f3be723cde | |||
| a182cf5730 | |||
| 247d12fa40 | |||
| 6593de89c2 | |||
| a1e25620f7 | |||
| 29b9abf241 | |||
| a0b8cf62be | |||
| 43fa4c43a2 | |||
| 85e7a13dba | |||
| 9d992735f4 | |||
| e61babdc8f | |||
| c04e952620 | |||
| 92967dfe0c | |||
| bc96000131 | |||
| a588e7003d | |||
| 665501026d | |||
| a62ce9168e | |||
| f6b611aa30 | |||
| 5a59fdd91c | |||
| 5f01dc1a3f | |||
| c488c4fcd5 | |||
| 3143cc960e | |||
| b8442d92a4 | |||
| 98a3437f43 | |||
| 8f32c6a61a | |||
| 79d5573169 | |||
| a9e7635e00 | |||
| 2c0de36439 | |||
| 94377d0b7a | |||
| e9853fe3fc | |||
| 4738286f4e | |||
| 961bdbfc59 | |||
| 879c117269 | |||
| 03bc4e5d01 | |||
| 80bd4cd337 | |||
| b0f3f0a523 | |||
| 9915e5572e | |||
| 9c6e0a7051 | |||
| 68be4b4ba5 | |||
| 13efdf2595 | |||
| c3991aad87 | |||
| 614846465f | |||
| 096dec2c7b | |||
| d19708ed77 | |||
| 22d7db89d8 | |||
| ba7abcf6f7 | |||
| b512d3a743 | |||
| 9fa968a593 | |||
| 41b7ad01f9 | |||
| 15fae29e5b | |||
| 8c1abcccfb | |||
| 25893177d0 | |||
| 345e0acdec | |||
| 1f04984a34 | |||
| 7a91ed2ab2 | |||
| d912fe07a1 | |||
| cfc21dfb51 | |||
| e5c83b20c9 | |||
| 97b472fd9c | |||
| d40d690f86 | |||
| 6581e27524 | |||
| 909edac64f | |||
| 30a24df9c0 | |||
| 7bcb514baf | |||
| bda84b08a1 | |||
| 2f0fe50f88 | |||
| 4f640c96d1 | |||
| 9b71702ac8 | |||
| 76aedb4a15 | |||
| a6f5ba541b | |||
| e47b16f3b4 | |||
| 494ef6e671 | |||
| bff2d7d3b6 | |||
| 2adfa0c18b | |||
| b5170684ad | |||
| 396fa7f988 | |||
| ea9ee987cf | |||
| 49d1b26bba | |||
| bab63d8f27 | |||
| 1083a0727a | |||
| 78b3b12ec1 | |||
| 61d7df8906 | |||
| 522f99aadd | |||
| 927a04d45f | |||
| 2b155db57d | |||
| f3cef7ce12 | |||
| 29b54ec5b2 | |||
| 313acefb19 | |||
| e5534c0402 | |||
| 4e37efdc4a | |||
| 58c92ed004 | |||
| 181f72fa1f | |||
| 4b9c618ae3 | |||
| 70a29512b7 | |||
| fcae5e9925 | |||
| 619985730e | |||
| 3d4a47cdae | |||
| d1ae3ba2d3 | |||
| feb4ecbb6b | |||
| a17bfc52ce | |||
| b605ebfd2a | |||
| feb687d3b8 | |||
| ff8e29c0eb | |||
| 1fbf21d395 | |||
| d42cab8e22 | |||
| 807f698cf2 | |||
| 457e12880c | |||
| ef0f21a11c | |||
| 8396e70e7b | |||
| 2da89c2cf1 | |||
| 3b0205b25f | |||
| bdaec30fa0 | |||
| 97df069730 | |||
| 9a1166f097 | |||
| 37b78edb91 |
+11
-11
@@ -124,12 +124,12 @@ jobs:
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package simplexmq" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@1.1/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Unix prepare cabal.project.local for Mac
|
||||
@@ -138,21 +138,21 @@ jobs:
|
||||
run: |
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package simplexmq" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@1.1/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
|
||||
echo " flags: +openssl" >> cabal.project.local
|
||||
|
||||
- name: Install AppImage dependencies
|
||||
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
|
||||
run: sudo apt install -y desktop-file-utils
|
||||
|
||||
- name: Install pkg-config for Mac
|
||||
- name: Install openssl for Mac
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
|
||||
run: brew install pkg-config
|
||||
run: brew install openssl@3.0
|
||||
|
||||
- name: Unix prepare cabal.project.local for Ubuntu
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||
@@ -334,7 +334,7 @@ jobs:
|
||||
run: |
|
||||
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
|
||||
scripts/desktop/prepare-openssl-windows.sh
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
rm cabal.project.local 2>/dev/null || true
|
||||
echo "ignore-project: False" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> cabal.project.local
|
||||
|
||||
@@ -233,6 +233,8 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
|
||||
|
||||
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
|
||||
|
||||
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
|
||||
@@ -241,20 +243,14 @@ Recent and important updates:
|
||||
|
||||
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
|
||||
|
||||
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
|
||||
|
||||
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
|
||||
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
|
||||
[All updates](./blog)
|
||||
|
||||
## :zap: Quick installation of a terminal app
|
||||
@@ -382,9 +378,11 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Improve sending videos (including encryption of locally stored videos).
|
||||
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- ✅ Support multiple network operators in the app.
|
||||
- 🏗 Large groups, communities and public channels.
|
||||
- 🏗 Short links to connect and join groups.
|
||||
- 🏗 Improve stability and reduce battery usage.
|
||||
- 🏗 Improve experience for the new users.
|
||||
- 🏗 Large groups, communities and public channels.
|
||||
- Privacy & security slider - a simple way to set all settings at once.
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
|
||||
@@ -17,6 +17,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
application.registerForRemoteNotifications()
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
deleteOldChatArchive()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Flux_logo_blue_white.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Flux_logo_blue.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Flux_symbol_blue-white.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -9,6 +9,18 @@ import SwiftUI
|
||||
import Intents
|
||||
import SimpleXChat
|
||||
|
||||
private enum NoticesSheet: Identifiable {
|
||||
case whatsNew(updatedConditions: Bool)
|
||||
case updatedConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .whatsNew: return "whatsNew"
|
||||
case .updatedConditions: return "updatedConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@@ -30,7 +42,8 @@ struct ContentView: View {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showWhatsNew = false
|
||||
@State private var noticesShown = false
|
||||
@State private var noticesSheetItem: NoticesSheet? = nil
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@@ -261,8 +274,15 @@ struct ContentView: View {
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
if !showWhatsNew {
|
||||
showWhatsNew = shouldShowWhatsNew()
|
||||
if !noticesShown {
|
||||
let showWhatsNew = shouldShowWhatsNew()
|
||||
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
|
||||
noticesShown = showWhatsNew || showUpdatedConditions
|
||||
if showWhatsNew {
|
||||
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
|
||||
} else if showUpdatedConditions {
|
||||
noticesSheetItem = .updatedConditions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,8 +290,22 @@ struct ContentView: View {
|
||||
connectViaUrl()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
.sheet(item: $noticesSheetItem) { item in
|
||||
switch item {
|
||||
case let .whatsNew(updatedConditions):
|
||||
WhatsNewView(updatedConditions: updatedConditions)
|
||||
.modifier(ThemedBackground())
|
||||
.if(updatedConditions) { v in
|
||||
v.task { await setConditionsNotified_() }
|
||||
}
|
||||
case .updatedConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.task { await setConditionsNotified_() }
|
||||
}
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
@@ -283,6 +317,15 @@ struct ContentView: View {
|
||||
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
|
||||
}
|
||||
|
||||
private func setConditionsNotified_() async {
|
||||
do {
|
||||
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
||||
try await setConditionsNotified(conditionsId: conditionsId)
|
||||
} catch let error {
|
||||
logger.error("setConditionsNotified error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
private func processUserActivity(_ activity: NSUserActivity) {
|
||||
let intent = activity.interaction?.intent
|
||||
if let intent = intent as? INStartCallIntent {
|
||||
|
||||
@@ -193,6 +193,8 @@ final class ChatModel: ObservableObject {
|
||||
@Published var draft: ComposeState?
|
||||
@Published var draftChatId: String?
|
||||
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
|
||||
// usage conditions
|
||||
@Published var conditions: ServerOperatorConditions = .empty
|
||||
|
||||
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
|
||||
|
||||
@@ -843,7 +845,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func dismissConnReqView(_ id: String) {
|
||||
if id == showingInvitation?.connId {
|
||||
if id == showingInvitation?.pcc.id {
|
||||
markShowingInvitationUsed()
|
||||
dismissAllSheets()
|
||||
}
|
||||
@@ -896,7 +898,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
var connId: String
|
||||
var pcc: PendingContactConnection
|
||||
var connChatUsed: Bool
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ enum NtfCallAction {
|
||||
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NtfManager()
|
||||
|
||||
public var navigatingToChat = false
|
||||
private var granted = false
|
||||
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
|
||||
|
||||
@@ -74,7 +75,10 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
} else {
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
self.navigatingToChat = true
|
||||
ItemsModel.shared.loadOpenChat(chatId) {
|
||||
self.navigatingToChat = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +446,13 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
|
||||
let userId = try currentUserId("apiGetReactionMemebers")
|
||||
let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
|
||||
if case let .reactionMembers(_, memberReactions) = r { return memberReactions }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
@@ -500,18 +507,6 @@ func apiDeleteToken(token: DeviceToken) async throws {
|
||||
try await sendCommandOkResp(.apiDeleteToken(token: token))
|
||||
}
|
||||
|
||||
func getUserProtoServers(_ serverProtocol: ServerProtocol) throws -> UserProtoServers {
|
||||
let userId = try currentUserId("getUserProtoServers")
|
||||
let r = chatSendCmdSync(.apiGetUserProtoServers(userId: userId, serverProtocol: serverProtocol))
|
||||
if case let .userProtoServers(_, servers) = r { return servers }
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserProtoServers(_ serverProtocol: ServerProtocol, servers: [ServerCfg]) async throws {
|
||||
let userId = try currentUserId("setUserProtoServers")
|
||||
try await sendCommandOkResp(.apiSetUserProtoServers(userId: userId, serverProtocol: serverProtocol, servers: servers))
|
||||
}
|
||||
|
||||
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
|
||||
let userId = try currentUserId("testProtoServer")
|
||||
let r = await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
|
||||
@@ -524,6 +519,72 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
|
||||
throw r
|
||||
}
|
||||
|
||||
func getServerOperators() async throws -> ServerOperatorConditions {
|
||||
let r = await chatSendCmd(.apiGetServerOperators)
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("getServerOperators error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getServerOperatorsSync() throws -> ServerOperatorConditions {
|
||||
let r = chatSendCmdSync(.apiGetServerOperators)
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("getServerOperators error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
|
||||
let r = await chatSendCmd(.apiSetServerOperators(operators: operators))
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("setServerOperators error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getUserServers() async throws -> [UserOperatorServers] {
|
||||
let userId = try currentUserId("getUserServers")
|
||||
let r = await chatSendCmd(.apiGetUserServers(userId: userId))
|
||||
if case let .userServers(_, userServers) = r { return userServers }
|
||||
logger.error("getUserServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setUserServers(userServers: [UserOperatorServers]) async throws {
|
||||
let userId = try currentUserId("setUserServers")
|
||||
let r = await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("setUserServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
|
||||
let userId = try currentUserId("validateServers")
|
||||
let r = await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
|
||||
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
|
||||
logger.error("validateServers error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
|
||||
let r = await chatSendCmd(.apiGetUsageConditions)
|
||||
if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
|
||||
logger.error("getUsageConditions error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func setConditionsNotified(conditionsId: Int64) async throws {
|
||||
let r = await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("setConditionsNotified error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
|
||||
let r = await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
logger.error("acceptConditions error: \(String(describing: r))")
|
||||
throw r
|
||||
}
|
||||
|
||||
func getChatItemTTL() throws -> ChatItemTTL {
|
||||
let userId = try currentUserId("getChatItemTTL")
|
||||
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
|
||||
@@ -1014,8 +1075,8 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
func apiChatRead(type: ChatType, id: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id))
|
||||
}
|
||||
|
||||
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
|
||||
@@ -1321,15 +1382,13 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
|
||||
func markChatRead(_ chat: Chat) async {
|
||||
do {
|
||||
if chat.chatStats.unreadCount > 0 {
|
||||
let minItemId = chat.chatStats.minUnreadItemId
|
||||
let itemRange = (minItemId, aboveItem?.id ?? chat.chatItems.last?.id ?? minItemId)
|
||||
let cInfo = chat.chatInfo
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
|
||||
}
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
@@ -1352,17 +1411,6 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
|
||||
}
|
||||
}
|
||||
|
||||
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
do {
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
|
||||
}
|
||||
} catch {
|
||||
logger.error("apiChatRead error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
|
||||
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
|
||||
do {
|
||||
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
|
||||
@@ -1558,6 +1606,16 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni
|
||||
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
|
||||
m.chatInitialized = true
|
||||
m.currentUser = try apiGetActiveUser()
|
||||
m.conditions = try getServerOperatorsSync()
|
||||
if shouldImportAppSettingsDefault.get() {
|
||||
do {
|
||||
let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
appSettings.importIntoApp()
|
||||
shouldImportAppSettingsDefault.set(false)
|
||||
} catch {
|
||||
logger.error("Error while importing app settings: \(error)")
|
||||
}
|
||||
}
|
||||
if m.currentUser == nil {
|
||||
onboardingStageDefault.set(.step1_SimpleXInfo)
|
||||
privacyDeliveryReceiptsSet.set(true)
|
||||
@@ -1602,7 +1660,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
|
||||
}
|
||||
}
|
||||
|
||||
func startChat(refreshInvitations: Bool = true) throws {
|
||||
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
@@ -1621,13 +1679,15 @@ func startChat(refreshInvitations: Bool = true) throws {
|
||||
if let token = m.deviceToken {
|
||||
registerToken(token: token)
|
||||
}
|
||||
withAnimation {
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_CreateSimpleXAddress
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
if !onboarding {
|
||||
withAnimation {
|
||||
let savedOnboardingStage = onboardingStageDefault.get()
|
||||
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
|
||||
? .step3_ChooseServerOperators
|
||||
: savedOnboardingStage
|
||||
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
|
||||
m.setDeliveryReceipts = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1961,6 +2021,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
m.removeChat(hostConn.id)
|
||||
}
|
||||
}
|
||||
case let .businessLinkConnecting(user, groupInfo, _, fromContact):
|
||||
if !active(user) { return }
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
if m.chatId == fromContact.id {
|
||||
ItemsModel.shared.loadOpenChat(groupInfo.id)
|
||||
}
|
||||
await MainActor.run {
|
||||
m.removeChat(fromContact.id)
|
||||
}
|
||||
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -143,7 +143,8 @@ struct SimpleXApp: App {
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id) {
|
||||
let chat = chatModel.getChat(id),
|
||||
!NtfManager.shared.navigatingToChat {
|
||||
Task { await loadChat(chat: chat, clearItems: false) }
|
||||
}
|
||||
if let ncr = chatModel.ntfContactRequest {
|
||||
|
||||
@@ -45,7 +45,7 @@ struct ChatInfoToolbar: View {
|
||||
}
|
||||
|
||||
private var contactVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.baselineOffset(1)
|
||||
|
||||
@@ -96,6 +96,8 @@ struct ChatInfoView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@State var contact: Contact
|
||||
@State var localAlias: String
|
||||
@State var featuresAllowed: ContactFeaturesAllowed
|
||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
var onSearch: () -> Void
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var customUserProfile: Profile? = nil
|
||||
@@ -154,8 +156,8 @@ struct ChatInfoView: View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
@@ -312,7 +314,15 @@ struct ChatInfoView: View {
|
||||
case .networkStatusAlert: return networkStatusAlert()
|
||||
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
|
||||
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
|
||||
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
|
||||
case .syncConnectionForceAlert:
|
||||
return syncConnectionForceAlert({
|
||||
Task {
|
||||
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
|
||||
connectionStats = stats
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
case let .queueInfo(info): return queueInfoAlert(info)
|
||||
case let .someAlert(a): return a.alert
|
||||
case let .error(title, error): return mkAlert(title: title, message: error)
|
||||
@@ -327,6 +337,16 @@ struct ChatInfoView: View {
|
||||
$0.content
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if currentFeaturesAllowed != featuresAllowed {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Save preferences?", comment: "alert title"),
|
||||
buttonTitle: NSLocalizedString("Save and notify contact", comment: "alert button"),
|
||||
buttonAction: { savePreferences() },
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func contactInfoHeader() -> some View {
|
||||
@@ -339,7 +359,7 @@ struct ChatInfoView: View {
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ textSpace
|
||||
+ Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
@@ -447,8 +467,9 @@ struct ChatInfoView: View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
contact: $contact,
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||
featuresAllowed: $featuresAllowed,
|
||||
currentFeaturesAllowed: $currentFeaturesAllowed,
|
||||
savePreferences: savePreferences
|
||||
)
|
||||
.navigationBarTitle("Contact preferences")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
@@ -480,7 +501,12 @@ struct ChatInfoView: View {
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
syncContactConnection(force: false)
|
||||
Task {
|
||||
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
|
||||
connectionStats = stats
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.orange)
|
||||
@@ -599,29 +625,50 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func syncContactConnection(force: Bool) {
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
connectionStats = stats
|
||||
await MainActor.run {
|
||||
chatModel.updateContactConnectionStats(contact, stats)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
contact = toContact
|
||||
chatModel.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateContactConnectionStats(contact, stats)
|
||||
}
|
||||
return stats
|
||||
} catch let error {
|
||||
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error synchronizing connection")
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
SomeAlert(
|
||||
alert: mkAlert(title: a.title, message: a.message),
|
||||
id: "syncContactConnection error"
|
||||
)
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioCallButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var width: CGFloat
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
|
||||
@@ -629,6 +676,7 @@ struct AudioCallButton: View {
|
||||
CallButton(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
connectionStats: $connectionStats,
|
||||
image: "phone.fill",
|
||||
title: "call",
|
||||
mediaType: .audio,
|
||||
@@ -641,6 +689,7 @@ struct AudioCallButton: View {
|
||||
struct VideoButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var width: CGFloat
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
|
||||
@@ -648,6 +697,7 @@ struct VideoButton: View {
|
||||
CallButton(
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
connectionStats: $connectionStats,
|
||||
image: "video.fill",
|
||||
title: "video",
|
||||
mediaType: .video,
|
||||
@@ -660,6 +710,7 @@ struct VideoButton: View {
|
||||
private struct CallButton: View {
|
||||
var chat: Chat
|
||||
var contact: Contact
|
||||
@Binding var connectionStats: ConnectionStats?
|
||||
var image: String
|
||||
var title: LocalizedStringKey
|
||||
var mediaType: CallMediaType
|
||||
@@ -671,12 +722,40 @@ private struct CallButton: View {
|
||||
|
||||
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
|
||||
if canCall {
|
||||
if CallController.useCallKit() {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else {
|
||||
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
|
||||
dismissAllSheets(animated: true) {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
if let connStats = connectionStats {
|
||||
if connStats.ratchetSyncState == .ok {
|
||||
if CallController.useCallKit() {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
} else {
|
||||
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
|
||||
dismissAllSheets(animated: true) {
|
||||
CallController.shared.startCall(contact, mediaType)
|
||||
}
|
||||
}
|
||||
} else if connStats.ratchetSyncAllowed {
|
||||
showAlert(SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Fix connection?"),
|
||||
message: Text("Connection requires encryption renegotiation."),
|
||||
primaryButton: .default(Text("Fix")) {
|
||||
Task {
|
||||
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
|
||||
connectionStats = stats
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "can't call contact, fix connection"
|
||||
))
|
||||
} else {
|
||||
showAlert(SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't call contact",
|
||||
message: "Encryption renegotiation in progress."
|
||||
),
|
||||
id: "can't call contact, encryption renegotiation in progress"
|
||||
))
|
||||
}
|
||||
}
|
||||
} else if contact.nextSendGrpInv {
|
||||
@@ -1173,6 +1252,8 @@ struct ChatInfoView_Previews: PreviewProvider {
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
|
||||
contact: Contact.sampleData,
|
||||
localAlias: "",
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ struct CIFeaturePreferenceView: View {
|
||||
+ Text(acceptText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
|
||||
@@ -45,7 +45,7 @@ struct CIGroupInvitationView: View {
|
||||
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
|
||||
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
@@ -53,7 +53,7 @@ struct CIGroupInvitationView: View {
|
||||
} else {
|
||||
(
|
||||
groupInvitationText()
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
.overlay(DetermineWidth())
|
||||
|
||||
@@ -23,7 +23,7 @@ struct CIMemberCreatedContactView: View {
|
||||
.onTapGesture {
|
||||
dismissAllSheets(animated: true)
|
||||
DispatchQueue.main.async {
|
||||
m.chatId = "@\(contactId)"
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -45,7 +45,7 @@ struct CIMemberCreatedContactView: View {
|
||||
+ Text(openText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
|
||||
@@ -83,7 +83,7 @@ enum MetaColorMode {
|
||||
? Image("checkmark.wide")
|
||||
: Image(systemName: "circlebadge.fill")
|
||||
).foregroundColor(.clear)
|
||||
case .invertedMaterial: Text(" ").kerning(13)
|
||||
case .invertedMaterial: textSpace.kerning(13)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func ciMetaText(
|
||||
if ttl != chatTTL {
|
||||
r = r + colored(Text(shortTimeText(ttl)), resolved)
|
||||
}
|
||||
space = Text(" ")
|
||||
space = textSpace
|
||||
}
|
||||
if showViaProxy, meta.sentViaProxy == true {
|
||||
appendSpace()
|
||||
@@ -138,12 +138,12 @@ func ciMetaText(
|
||||
} else if !meta.disappearing {
|
||||
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
|
||||
}
|
||||
space = Text(" ")
|
||||
space = textSpace
|
||||
}
|
||||
if let enc = encrypted {
|
||||
appendSpace()
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
|
||||
space = Text(" ")
|
||||
space = textSpace
|
||||
}
|
||||
if showTimesamp {
|
||||
appendSpace()
|
||||
|
||||
@@ -121,11 +121,11 @@ struct CIRcvDecryptionError: View {
|
||||
Text(Image(systemName: "exclamationmark.arrow.triangle.2.circlepath"))
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ textSpace
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ struct CIRcvDecryptionError: View {
|
||||
Text(chatItem.content.text)
|
||||
.foregroundColor(.red)
|
||||
.italic()
|
||||
+ Text(" ")
|
||||
+ Text(verbatim: " ")
|
||||
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -17,7 +17,7 @@ struct MarkedDeletedItemView: View {
|
||||
var chatItem: ChatItem
|
||||
|
||||
var body: some View {
|
||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
|
||||
(Text(mergedMarkedDeletedText).italic() + textSpace + chatItem.timestampText)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
|
||||
@@ -11,7 +11,7 @@ import SimpleXChat
|
||||
|
||||
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
|
||||
|
||||
private let noTyping = Text(" ")
|
||||
private let noTyping = Text(verbatim: " ")
|
||||
|
||||
private let typingIndicators: [Text] = [
|
||||
(typing(.black) + typing() + typing()),
|
||||
@@ -85,7 +85,7 @@ struct MsgContentView: View {
|
||||
}
|
||||
|
||||
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
(rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
|
||||
}
|
||||
|
||||
if let i = icon {
|
||||
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
|
||||
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res
|
||||
}
|
||||
|
||||
if let s = sender {
|
||||
|
||||
@@ -170,7 +170,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
|
||||
private func eventItemViewText(_ secondaryColor: Color) -> Text {
|
||||
if !revealed, let t = mergedGroupEventText {
|
||||
return chatEventText(t + Text(" ") + chatItem.timestampText, secondaryColor)
|
||||
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
|
||||
} else if let member = chatItem.memberDisplayName {
|
||||
return Text(member + " ")
|
||||
.font(.caption)
|
||||
@@ -203,7 +203,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
} else if ns.count == 0 {
|
||||
Text("\(count) group events")
|
||||
} else if count > ns.count {
|
||||
Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
|
||||
Text(members) + textSpace + Text("and \(count - ns.count) other events")
|
||||
} else {
|
||||
Text(members)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func chatEventText(_ text: Text, _ secondaryColor: Color) -> Text {
|
||||
}
|
||||
|
||||
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor: Color) -> Text {
|
||||
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
|
||||
chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||
|
||||
@@ -133,7 +133,12 @@ struct ChatView: View {
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
Group {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
groupMember: member,
|
||||
navigation: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +231,8 @@ struct ChatView: View {
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
localAlias: chat.chatInfo.localAlias,
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
@@ -433,6 +440,7 @@ struct ChatView: View {
|
||||
maxWidth: maxWidth,
|
||||
composeState: $composeState,
|
||||
selectedMember: $selectedMember,
|
||||
showChatInfoSheet: $showChatInfoSheet,
|
||||
revealedChatItem: $revealedChatItem,
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems
|
||||
@@ -886,12 +894,14 @@ struct ChatView: View {
|
||||
private struct ChatItemWithMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileRadius = defaultProfileImageCorner
|
||||
@Binding @ObservedObject var chat: Chat
|
||||
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
|
||||
let chatItem: ChatItem
|
||||
let maxWidth: CGFloat
|
||||
@Binding var composeState: ComposeState
|
||||
@Binding var selectedMember: GMember?
|
||||
@Binding var showChatInfoSheet: Bool
|
||||
@Binding var revealedChatItem: ChatItem?
|
||||
|
||||
@State private var deletingItem: ChatItem? = nil
|
||||
@@ -901,7 +911,7 @@ struct ChatView: View {
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@State private var msgWidth: CGFloat = 0
|
||||
|
||||
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
@Binding var forwardedChatItems: [ChatItem]
|
||||
|
||||
@@ -987,7 +997,7 @@ struct ChatView: View {
|
||||
}
|
||||
} else if chatItem.isRcvNew {
|
||||
waitToMarkRead {
|
||||
await apiMarkChatItemRead(chat.chatInfo, chatItem)
|
||||
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1117,14 +1127,13 @@ struct ChatView: View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if let member = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = member
|
||||
if let mem = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = mem
|
||||
} else {
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
let mem = GMember.init(member)
|
||||
m.groupMembers.append(mem)
|
||||
m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
|
||||
selectedMember = mem
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
@@ -1244,12 +1253,27 @@ struct ChatView: View {
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
.if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
|
||||
v.onTapGesture {
|
||||
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
switch chat.chatInfo {
|
||||
case let .group(groupInfo):
|
||||
v.contextMenu {
|
||||
ReactionContextMenu(
|
||||
groupInfo: groupInfo,
|
||||
itemId: ci.id,
|
||||
reactionCount: r,
|
||||
selectedMember: $selectedMember,
|
||||
profileRadius: profileRadius
|
||||
)
|
||||
}
|
||||
case let .direct(contact):
|
||||
v.contextMenu {
|
||||
contactReactionMenu(contact, r)
|
||||
}
|
||||
default:
|
||||
v
|
||||
}
|
||||
}
|
||||
@@ -1752,6 +1776,20 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func contactReactionMenu(_ contact: Contact, _ r: CIReactionCount) -> some View {
|
||||
if !r.userReacted || r.totalReacted > 1 {
|
||||
Button { showChatInfoSheet = true } label: {
|
||||
profileMenuItem(Text(contact.displayName), contact.image, radius: profileRadius)
|
||||
}
|
||||
}
|
||||
if r.userReacted {
|
||||
Button {} label: {
|
||||
profileMenuItem(Text("you"), m.currentUser?.profile.image, radius: profileRadius)
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectedChatItem: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -1838,6 +1876,104 @@ private func buildTheme() -> AppTheme {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactionContextMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let groupInfo: GroupInfo
|
||||
var itemId: Int64
|
||||
var reactionCount: CIReactionCount
|
||||
@Binding var selectedMember: GMember?
|
||||
var profileRadius: CGFloat
|
||||
@State private var memberReactions: [MemberReaction] = []
|
||||
|
||||
var body: some View {
|
||||
groupMemberReactionList()
|
||||
.task {
|
||||
await loadChatItemReaction()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupMemberReactionList() -> some View {
|
||||
if memberReactions.isEmpty {
|
||||
ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
|
||||
Text(verbatim: " ")
|
||||
}
|
||||
} else {
|
||||
ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in
|
||||
let mem = mr.groupMember
|
||||
let userMember = mem.groupMemberId == groupInfo.membership.groupMemberId
|
||||
Button {
|
||||
if let member = m.getGroupMember(mem.groupMemberId) {
|
||||
selectedMember = member
|
||||
} else {
|
||||
let member = GMember.init(mem)
|
||||
m.groupMembers.append(member)
|
||||
m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
|
||||
selectedMember = member
|
||||
}
|
||||
} label: {
|
||||
profileMenuItem(Text(mem.displayName), mem.image, radius: profileRadius)
|
||||
}
|
||||
.disabled(userMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChatItemReaction() async {
|
||||
do {
|
||||
let memberReactions = try await apiGetReactionMembers(
|
||||
groupId: groupInfo.groupId,
|
||||
itemId: itemId,
|
||||
reaction: reactionCount.reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
self.memberReactions = memberReactions
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetReactionMembers error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profileMenuItem(_ nameText: Text, _ image: String?, radius: CGFloat) -> some View {
|
||||
HStack {
|
||||
nameText
|
||||
if let image, let img = imageFromBase64(image) {
|
||||
Image(uiImage: maskToCustomShape(img, size: 30, radius: radius))
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maskToCustomShape(_ image: UIImage, size: CGFloat, radius: CGFloat) -> UIImage {
|
||||
let path = Path { path in
|
||||
if radius >= 50 {
|
||||
path.addEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
|
||||
} else if radius <= 0 {
|
||||
path.addRect(CGRect(x: 0, y: 0, width: size, height: size))
|
||||
} else {
|
||||
let cornerRadius = size * CGFloat(radius) / 100
|
||||
path.addRoundedRect(
|
||||
in: CGRect(x: 0, y: 0, width: size, height: size),
|
||||
cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
|
||||
style: .continuous
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in
|
||||
context.cgContext.addPath(path.cgPath)
|
||||
context.cgContext.clip()
|
||||
let scale = size / max(image.size.width, image.size.height)
|
||||
let imageSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
let imageOrigin = CGPoint(
|
||||
x: (size - imageSize.width) / 2,
|
||||
y: (size - imageSize.height) / 2
|
||||
)
|
||||
image.draw(in: CGRect(origin: imageOrigin, size: imageSize))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleNtfsButton: View {
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ struct ContextItemView: View {
|
||||
}
|
||||
|
||||
func image(_ s: String) -> Text {
|
||||
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
|
||||
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + textSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ struct ContactPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var contact: Contact
|
||||
@State var featuresAllowed: ContactFeaturesAllowed
|
||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
@Binding var featuresAllowed: ContactFeaturesAllowed
|
||||
@Binding var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
@State private var showSaveDialogue = false
|
||||
let savePreferences: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let user: User = chatModel.currentUser!
|
||||
@@ -48,7 +49,10 @@ struct ContactPreferencesView: View {
|
||||
savePreferences()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
Button("Exit without saving") {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,31 +122,15 @@ struct ContactPreferencesView: View {
|
||||
private func featureFooter(_ feature: ChatFeature, _ enabled: FeatureEnabled) -> some View {
|
||||
Text(feature.enabledDescription(enabled))
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
let prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||
if let toContact = try await apiSetContactPrefs(contactId: contact.contactId, preferences: prefs) {
|
||||
await MainActor.run {
|
||||
contact = toContact
|
||||
chatModel.updateContact(toContact)
|
||||
currentFeaturesAllowed = featuresAllowed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContactPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContactPreferencesView(
|
||||
contact: Binding.constant(Contact.sampleData),
|
||||
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||
featuresAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
|
||||
currentFeaturesAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
|
||||
savePreferences: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,12 @@ struct AddGroupMembersViewCommon: View {
|
||||
let count = selectedContacts.count
|
||||
Section {
|
||||
if creatingGroup {
|
||||
groupPreferencesButton($groupInfo, true)
|
||||
GroupPreferencesButton(
|
||||
groupInfo: $groupInfo,
|
||||
preferences: groupInfo.fullGroupPreferences,
|
||||
currentPreferences: groupInfo.fullGroupPreferences,
|
||||
creatingGroup: true
|
||||
)
|
||||
}
|
||||
rolePicker()
|
||||
inviteMembersButton()
|
||||
@@ -140,12 +145,13 @@ struct AddGroupMembersViewCommon: View {
|
||||
return dummy
|
||||
}()
|
||||
|
||||
private func inviteMembersButton() -> some View {
|
||||
@ViewBuilder private func inviteMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
|
||||
Button {
|
||||
inviteMembers()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Invite to group")
|
||||
Text(label)
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
@@ -231,6 +237,7 @@ func searchFieldView(text: Binding<String>, focussed: FocusState<Bool>.Binding,
|
||||
.focused(focussed)
|
||||
.foregroundColor(onBackgroundColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.autocorrectionDisabled(true)
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
@@ -81,13 +81,13 @@ struct GroupChatInfoView: View {
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.canEdit {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
groupPreferencesButton($groupInfo)
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
@@ -101,13 +101,20 @@ struct GroupChatInfoView: View {
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
Text("Only group owners can change group preferences.")
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
groupLinkButton()
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
}
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
@@ -276,10 +283,15 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
NavigationLink {
|
||||
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
|
||||
case .customer: "Add team members"
|
||||
case .business: "Add friends"
|
||||
case .none: "Invite members"
|
||||
}
|
||||
return NavigationLink {
|
||||
addMembersDestinationView()
|
||||
} label: {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
Label(label, systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +430,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
@@ -427,7 +439,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
|
||||
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
|
||||
@@ -487,11 +499,12 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteGroupButton() -> some View {
|
||||
@ViewBuilder private func deleteGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteGroupAlert
|
||||
} label: {
|
||||
Label("Delete group", systemImage: "trash")
|
||||
Label(label, systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
@@ -505,20 +518,22 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func leaveGroupButton() -> some View {
|
||||
@ViewBuilder private func leaveGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
|
||||
Button(role: .destructive) {
|
||||
alert = .leaveGroupAlert
|
||||
} label: {
|
||||
Label("Leave group", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
Label(label, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO reuse this and clearChatAlert with ChatInfoView
|
||||
private func deleteGroupAlert() -> Alert {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
return Alert(
|
||||
title: Text("Delete group?"),
|
||||
message: deleteGroupAlertMessage(),
|
||||
title: Text(label),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -537,10 +552,6 @@ struct GroupChatInfoView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteGroupAlertMessage() -> Text {
|
||||
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
|
||||
}
|
||||
|
||||
private func clearChatAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Clear conversation?"),
|
||||
@@ -556,9 +567,15 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Leave group?"),
|
||||
message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
|
||||
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "You will stop receiving messages from this group. Chat history will be preserved."
|
||||
: "You will stop receiving messages from this chat. Chat history will be preserved."
|
||||
)
|
||||
return Alert(
|
||||
title: Text(titleLabel),
|
||||
message: Text(messageLabel),
|
||||
primaryButton: .destructive(Text("Leave")) {
|
||||
Task {
|
||||
await leaveGroup(chat.chatInfo.apiId)
|
||||
@@ -602,9 +619,14 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Member will be removed from group - this cannot be undone!"
|
||||
: "Member will be removed from chat - this cannot be undone!"
|
||||
)
|
||||
return Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
message: Text(messageLabel),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -624,26 +646,80 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
|
||||
NavigationLink {
|
||||
GroupPreferencesView(
|
||||
groupInfo: groupInfo,
|
||||
preferences: groupInfo.wrappedValue.fullGroupPreferences,
|
||||
currentPreferences: groupInfo.wrappedValue.fullGroupPreferences,
|
||||
creatingGroup: creatingGroup
|
||||
)
|
||||
.navigationBarTitle("Group preferences")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
if creatingGroup {
|
||||
Text("Set group preferences")
|
||||
} else {
|
||||
Label("Group preferences", systemImage: "switch.2")
|
||||
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
|
||||
groupInfo.businessChat == nil ? (
|
||||
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
|
||||
) : (
|
||||
groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!")
|
||||
)
|
||||
}
|
||||
|
||||
struct GroupPreferencesButton: View {
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var preferences: FullGroupPreferences
|
||||
@State var currentPreferences: FullGroupPreferences
|
||||
var creatingGroup: Bool = false
|
||||
|
||||
private var label: LocalizedStringKey {
|
||||
groupInfo.businessChat == nil ? "Group preferences" : "Chat preferences"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
GroupPreferencesView(
|
||||
groupInfo: $groupInfo,
|
||||
preferences: $preferences,
|
||||
currentPreferences: currentPreferences,
|
||||
creatingGroup: creatingGroup,
|
||||
savePreferences: savePreferences
|
||||
)
|
||||
.navigationBarTitle(label)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onDisappear {
|
||||
let saveText = NSLocalizedString(
|
||||
creatingGroup ? "Save" : "Save and notify group members",
|
||||
comment: "alert button"
|
||||
)
|
||||
|
||||
if groupInfo.fullGroupPreferences != preferences {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Save preferences?", comment: "alert title"),
|
||||
buttonTitle: saveText,
|
||||
buttonAction: { savePreferences() },
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if creatingGroup {
|
||||
Text("Set group preferences")
|
||||
} else {
|
||||
Label(label, systemImage: "switch.2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
var gp = groupInfo.groupProfile
|
||||
gp.groupPreferences = toGroupPreferences(preferences)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
ChatModel.shared.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
} catch {
|
||||
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
func cantInviteIncognitoAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Can't invite contacts!"),
|
||||
|
||||
@@ -14,11 +14,15 @@ struct GroupMemberInfoView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var groupInfo: GroupInfo
|
||||
@ObservedObject var chat: Chat
|
||||
@ObservedObject var groupMember: GMember
|
||||
var navigation: Bool = false
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var connectionLoaded: Bool = false
|
||||
@State private var knownContactChat: Chat? = nil
|
||||
@State private var knownContact: Contact? = nil
|
||||
@State private var knownContactConnectionStats: ConnectionStats? = nil
|
||||
@State private var newRole: GroupMemberRole = .member
|
||||
@State private var alert: GroupMemberInfoViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@@ -118,8 +122,8 @@ struct GroupMemberInfoView: View {
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let contactId = member.memberContactId {
|
||||
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if member.memberContactId != nil {
|
||||
if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
connectViaAddressButton(contactLink)
|
||||
}
|
||||
} else {
|
||||
@@ -135,7 +139,8 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
|
||||
infoRow(label, groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
@@ -227,6 +232,18 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
|
||||
}
|
||||
if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
|
||||
knownContactChat = contactChat
|
||||
knownContact = contact
|
||||
do {
|
||||
let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
|
||||
await MainActor.run {
|
||||
knownContactConnectionStats = stats
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiContactInfo error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: newRole) { newRole in
|
||||
if newRole != member.memberRole {
|
||||
@@ -260,6 +277,11 @@ struct GroupMemberInfoView: View {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.onChange(of: chat.chatInfo) { c in
|
||||
if case let .group(gI) = chat.chatInfo {
|
||||
groupInfo = gI
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
@@ -267,10 +289,10 @@ struct GroupMemberInfoView: View {
|
||||
GeometryReader { g in
|
||||
let buttonWidth = g.size.width / 4
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
|
||||
if let chat = knownContactChat, let contact = knownContact {
|
||||
knownDirectChatButton(chat, width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
|
||||
if let contactId = member.memberContactId {
|
||||
newDirectChatButton(contactId, width: buttonWidth)
|
||||
@@ -305,10 +327,15 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
func showDirectMessagesProhibitedAlert(_ title: LocalizedStringKey) {
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Direct messages between members are prohibited."
|
||||
: "Direct messages between members are prohibited in this chat."
|
||||
)
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: title,
|
||||
message: "Direct messages between members are prohibited in this group."
|
||||
message: messageLabel
|
||||
),
|
||||
id: "can't message member, direct messages prohibited"
|
||||
))
|
||||
@@ -354,25 +381,49 @@ struct GroupMemberInfoView: View {
|
||||
|
||||
func createMemberContactButton(width: CGFloat) -> some View {
|
||||
InfoViewButton(image: "message.fill", title: "message", width: width) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
if let connStats = connectionStats {
|
||||
if connStats.ratchetSyncState == .ok {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
|
||||
ItemsModel.shared.loadOpenChat(memberContact.id) {
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
}
|
||||
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating member contact")
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
}
|
||||
} else if connStats.ratchetSyncAllowed {
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Fix connection?"),
|
||||
message: Text("Connection requires encryption renegotiation."),
|
||||
primaryButton: .default(Text("Fix")) {
|
||||
syncMemberConnection(force: false)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
),
|
||||
id: "can't message member, fix connection"
|
||||
))
|
||||
} else {
|
||||
alert = .someAlert(alert: SomeAlert(
|
||||
alert: mkAlert(
|
||||
title: "Can't message member",
|
||||
message: "Encryption renegotiation in progress."
|
||||
),
|
||||
id: "can't message contact, encryption renegotiation in progress"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,7 +439,7 @@ struct GroupMemberInfoView: View {
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.title2)
|
||||
+ Text(" ")
|
||||
+ textSpace
|
||||
+ Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
@@ -537,9 +588,14 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Member will be removed from group - this cannot be undone!"
|
||||
: "Member will be removed from chat - this cannot be undone!"
|
||||
)
|
||||
return Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
message: Text(label),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -562,7 +618,15 @@ struct GroupMemberInfoView: View {
|
||||
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Change member role?"),
|
||||
message: mem.memberCurrent ? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.") : Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation."),
|
||||
message: (
|
||||
mem.memberCurrent
|
||||
? (
|
||||
groupInfo.businessChat == nil
|
||||
? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
|
||||
: Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
|
||||
)
|
||||
: Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
|
||||
),
|
||||
primaryButton: .default(Text("Change")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -570,7 +634,7 @@ struct GroupMemberInfoView: View {
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
|
||||
|
||||
} catch let error {
|
||||
newRole = mem.memberRole
|
||||
logger.error("apiMemberRole error: \(responseError(error))")
|
||||
@@ -739,6 +803,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
chat: Chat.sampleData,
|
||||
groupMember: GMember.sampleData
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ struct GroupPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@State var preferences: FullGroupPreferences
|
||||
@State var currentPreferences: FullGroupPreferences
|
||||
@Binding var preferences: FullGroupPreferences
|
||||
var currentPreferences: FullGroupPreferences
|
||||
let creatingGroup: Bool
|
||||
let savePreferences: () -> Void
|
||||
@State private var showSaveDialogue = false
|
||||
|
||||
var body: some View {
|
||||
@@ -38,7 +39,7 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||
featureSection(.history, $preferences.history.enable)
|
||||
|
||||
if groupInfo.canEdit {
|
||||
if groupInfo.isOwner {
|
||||
Section {
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
Button(saveText) { savePreferences() }
|
||||
@@ -68,7 +69,10 @@ struct GroupPreferencesView: View {
|
||||
savePreferences()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
Button("Exit without saving") {
|
||||
preferences = currentPreferences
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +81,7 @@ struct GroupPreferencesView: View {
|
||||
let color: Color = enableFeature.wrappedValue == .on ? .green : theme.colors.secondary
|
||||
let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon
|
||||
let timedOn = feature == .timedMessages && enableFeature.wrappedValue == .on
|
||||
if groupInfo.canEdit {
|
||||
if groupInfo.isOwner {
|
||||
let enable = Binding(
|
||||
get: { enableFeature.wrappedValue == .on },
|
||||
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
|
||||
@@ -123,7 +127,7 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.onChange(of: enableFeature.wrappedValue) { enabled in
|
||||
@@ -132,32 +136,16 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreferences() {
|
||||
Task {
|
||||
do {
|
||||
var gp = groupInfo.groupProfile
|
||||
gp.groupPreferences = toGroupPreferences(preferences)
|
||||
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
|
||||
await MainActor.run {
|
||||
groupInfo = gInfo
|
||||
chatModel.updateGroup(gInfo)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
} catch {
|
||||
logger.error("GroupPreferencesView apiUpdateGroup error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupPreferencesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupPreferencesView(
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
preferences: FullGroupPreferences.sampleData,
|
||||
preferences: Binding.constant(FullGroupPreferences.sampleData),
|
||||
currentPreferences: FullGroupPreferences.sampleData,
|
||||
creatingGroup: false
|
||||
creatingGroup: false,
|
||||
savePreferences: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct GroupWelcomeView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if groupInfo.canEdit {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editorView()
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if welcomeTextUnchanged() {
|
||||
|
||||
@@ -42,7 +42,8 @@ struct ChatHelp: View {
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
Text("**Add contact**: to create a new invitation link, or connect via a link you received.")
|
||||
Text("**Create 1-time link**: to create and share a new invitation link.")
|
||||
Text("**Scan / Paste link**: to connect via a link you received.")
|
||||
Text("**Create group**: to create a new group.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -404,8 +404,9 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Delete group?"),
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
return Alert(
|
||||
title: Text(label),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task { await deleteChat(chat) }
|
||||
@@ -414,10 +415,6 @@ struct ChatListNavLink: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
|
||||
groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!")
|
||||
}
|
||||
|
||||
private func clearChatAlert() -> Alert {
|
||||
Alert(
|
||||
title: Text("Clear conversation?"),
|
||||
@@ -441,9 +438,15 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
Alert(
|
||||
title: Text("Leave group?"),
|
||||
message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
|
||||
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "You will stop receiving messages from this group. Chat history will be preserved."
|
||||
: "You will stop receiving messages from this chat. Chat history will be preserved."
|
||||
)
|
||||
return Alert(
|
||||
title: Text(titleLabel),
|
||||
message: Text(messageLabel),
|
||||
primaryButton: .destructive(Text("Leave")) {
|
||||
Task { await leaveGroup(groupInfo.groupId) }
|
||||
},
|
||||
|
||||
@@ -31,9 +31,21 @@ enum UserPickerSheet: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
class SaveableSettings: ObservableObject {
|
||||
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
|
||||
}
|
||||
|
||||
struct ServerSettings {
|
||||
public var currUserServers: [UserOperatorServers]
|
||||
public var userServers: [UserOperatorServers]
|
||||
public var serverErrors: [UserServersError]
|
||||
}
|
||||
|
||||
struct UserPickerSheetView: View {
|
||||
let sheet: UserPickerSheet
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@StateObject private var ss = SaveableSettings()
|
||||
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
@@ -76,6 +88,21 @@ struct UserPickerSheetView: View {
|
||||
{ loaded = true }
|
||||
)
|
||||
}
|
||||
.onDisappear {
|
||||
if serversCanBeSaved(
|
||||
ss.servers.currUserServers,
|
||||
ss.servers.userServers,
|
||||
ss.servers.serverErrors
|
||||
) {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Save servers?", comment: "alert title"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) },
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(ss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +121,7 @@ struct ChatListView: View {
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
|
||||
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
|
||||
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
var body: some View {
|
||||
@@ -276,12 +304,6 @@ struct ChatListView: View {
|
||||
.padding(.top, oneHandUI ? 8 : 0)
|
||||
.id("searchBar")
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
ForEach(cs, id: \.viewId) { chat in
|
||||
ChatListNavLink(chat: chat)
|
||||
@@ -307,6 +329,20 @@ struct ChatListView: View {
|
||||
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
if !oneHandUICardShown {
|
||||
OneHandUICard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
if !addressCreationCardShown {
|
||||
AddressCreationCard()
|
||||
.padding(.vertical, 6)
|
||||
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onChange(of: chatModel.chatId) { currentChatId in
|
||||
|
||||
@@ -172,7 +172,7 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
private var verifiedIcon: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
@@ -232,12 +232,12 @@ struct ChatPreviewView: View {
|
||||
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
|
||||
|
||||
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
|
||||
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
||||
Text(Image(systemName: s)).foregroundColor(color) + textSpace
|
||||
}
|
||||
|
||||
func attachment() -> Text {
|
||||
switch draft.preview {
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + textSpace
|
||||
case .mediaPreviews: return image("photo")
|
||||
case let .voicePreview(_, duration): return image("play.fill") + Text(durationText(duration))
|
||||
default: return Text("")
|
||||
@@ -367,11 +367,11 @@ struct ChatPreviewView: View {
|
||||
case .sndErrorAuth, .sndError:
|
||||
return Text(Image(systemName: "multiply"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.red) + Text(" ")
|
||||
.foregroundColor(.red) + textSpace
|
||||
case .sndWarning:
|
||||
return Text(Image(systemName: "exclamationmark.triangle.fill"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange) + Text(" ")
|
||||
.foregroundColor(.orange) + textSpace
|
||||
default: return Text("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ struct OneHandUICard: View {
|
||||
.background(theme.appColors.sentMessage)
|
||||
.cornerRadius(12)
|
||||
.frame(height: dynamicSize(userFont).rowHeight)
|
||||
.padding(.vertical, 12)
|
||||
.alert(isPresented: $showOneHandUIAlert) {
|
||||
Alert(
|
||||
title: Text("Reachable chat toolbar"),
|
||||
|
||||
@@ -491,15 +491,6 @@ struct SMPServerSummaryView: View {
|
||||
Section("Server address") {
|
||||
Text(summary.smpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
@@ -596,7 +587,7 @@ struct SMPStatsView: View {
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,15 +670,6 @@ struct XFTPServerSummaryView: View {
|
||||
Section("Server address") {
|
||||
Text(summary.xftpServer)
|
||||
.textSelection(.enabled)
|
||||
if summary.known == true {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Open server settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stats = summary.stats {
|
||||
@@ -721,7 +703,7 @@ struct XFTPStatsView: View {
|
||||
} header: {
|
||||
Text("Statistics")
|
||||
} footer: {
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
|
||||
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ struct ContactListNavLink: View {
|
||||
}
|
||||
|
||||
private var verifiedIcon: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.foregroundColor(.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// ChatArchiveView.swift
|
||||
// SimpleXChat
|
||||
//
|
||||
// Created by Evgeny on 23/06/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChatArchiveView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var archiveName: String
|
||||
@AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String?
|
||||
@AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0
|
||||
@State private var showDeleteAlert = false
|
||||
|
||||
var body: some View {
|
||||
let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName)
|
||||
let fileTs = chatArchiveTimeDefault.get()
|
||||
List {
|
||||
Section {
|
||||
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||
Button {
|
||||
showShareSheet(items: [fileUrl])
|
||||
} label: {
|
||||
Text("Save archive")
|
||||
}
|
||||
}
|
||||
settingsRow("trash", color: theme.colors.secondary) {
|
||||
Button {
|
||||
showDeleteAlert = true
|
||||
} label: {
|
||||
Text("Delete archive").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Chat archive")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("Created on \(fileTs)")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showDeleteAlert) {
|
||||
Alert(
|
||||
title: Text("Delete chat archive?"),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: fileUrl.path)
|
||||
chatArchiveName = nil
|
||||
chatArchiveTime = 0
|
||||
} catch let error {
|
||||
logger.error("removeItem error \(String(describing: error))")
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatArchiveView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatArchiveView(archiveName: "")
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ struct DatabaseEncryptionView: View {
|
||||
@State private var confirmNewKey = ""
|
||||
@State private var currentKeyShown = false
|
||||
|
||||
let stopChatRunBlockStartChat: (Binding<Bool>, @escaping () async throws -> Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
List {
|
||||
@@ -134,46 +136,61 @@ struct DatabaseEncryptionView: View {
|
||||
.onAppear {
|
||||
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
|
||||
}
|
||||
.disabled(m.chatRunning != false)
|
||||
.disabled(progressIndicator)
|
||||
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
|
||||
}
|
||||
|
||||
private func encryptDatabase() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
encryptionStartedDefault.set(true)
|
||||
encryptionStartedAtDefault.set(Date.now)
|
||||
if !m.chatDbChanged {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
|
||||
encryptionStartedDefault.set(false)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
if migration {
|
||||
storeDBPassphraseGroupDefault.set(useKeychain)
|
||||
}
|
||||
if useKeychain {
|
||||
if kcDatabasePassword.set(newKey) {
|
||||
await resetFormAfterEncryption(true)
|
||||
await operationEnded(.databaseEncrypted)
|
||||
} else {
|
||||
await resetFormAfterEncryption()
|
||||
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
|
||||
}
|
||||
} else {
|
||||
if migration {
|
||||
removePassphraseFromKeyChain()
|
||||
}
|
||||
await resetFormAfterEncryption()
|
||||
private func encryptDatabaseAsync() async -> Bool {
|
||||
await MainActor.run {
|
||||
progressIndicator = true
|
||||
}
|
||||
do {
|
||||
encryptionStartedDefault.set(true)
|
||||
encryptionStartedAtDefault.set(Date.now)
|
||||
if !m.chatDbChanged {
|
||||
try apiSaveAppSettings(settings: AppSettings.current.prepareForExport())
|
||||
}
|
||||
try await apiStorageEncryption(currentKey: currentKey, newKey: newKey)
|
||||
encryptionStartedDefault.set(false)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
if migration {
|
||||
storeDBPassphraseGroupDefault.set(useKeychain)
|
||||
}
|
||||
if useKeychain {
|
||||
if kcDatabasePassword.set(newKey) {
|
||||
await resetFormAfterEncryption(true)
|
||||
await operationEnded(.databaseEncrypted)
|
||||
}
|
||||
} catch let error {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
||||
await operationEnded(.currentPassphraseError)
|
||||
} else {
|
||||
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
|
||||
await resetFormAfterEncryption()
|
||||
await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain"))
|
||||
}
|
||||
} else {
|
||||
if migration {
|
||||
removePassphraseFromKeyChain()
|
||||
}
|
||||
await resetFormAfterEncryption()
|
||||
await operationEnded(.databaseEncrypted)
|
||||
}
|
||||
return true
|
||||
} catch let error {
|
||||
if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse {
|
||||
await operationEnded(.currentPassphraseError)
|
||||
} else {
|
||||
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func encryptDatabase() {
|
||||
// it will try to stop and start the chat in case of: non-migration && successful encryption. In migration the chat will remain stopped
|
||||
if migration {
|
||||
Task {
|
||||
await encryptDatabaseAsync()
|
||||
}
|
||||
} else {
|
||||
stopChatRunBlockStartChat($progressIndicator) {
|
||||
return await encryptDatabaseAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +388,6 @@ func validKey(_ s: String) -> Bool {
|
||||
|
||||
struct DatabaseEncryptionView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false, stopChatRunBlockStartChat: { _, _ in true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
struct DatabaseErrorView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var status: DBMigrationResult
|
||||
@State private var dbKey = ""
|
||||
@State private var storedDBKey = kcDatabasePassword.get()
|
||||
@@ -28,23 +29,39 @@ struct DatabaseErrorView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func databaseErrorView() -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
switch status {
|
||||
case let .errorNotADatabase(dbFile):
|
||||
if useKeychain && storedDBKey != nil && storedDBKey != "" {
|
||||
titleText("Wrong database passphrase")
|
||||
Text("Database passphrase is different from saved in the keychain.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
|
||||
databaseKeyField(onSubmit: saveAndRunChat)
|
||||
saveAndOpenButton()
|
||||
fileNameText(dbFile)
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
saveAndOpenButton()
|
||||
fileNameText(dbFile)
|
||||
}
|
||||
} else {
|
||||
titleText("Encrypted database")
|
||||
Text("Database passphrase is required to open chat.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.bottom, 5)
|
||||
|
||||
if useKeychain {
|
||||
databaseKeyField(onSubmit: saveAndRunChat)
|
||||
Spacer()
|
||||
saveAndOpenButton()
|
||||
} else {
|
||||
databaseKeyField(onSubmit: { runChat() })
|
||||
Spacer()
|
||||
openChatButton()
|
||||
}
|
||||
}
|
||||
@@ -52,73 +69,105 @@ struct DatabaseErrorView: View {
|
||||
switch migrationError {
|
||||
case let .upgrade(upMigrations):
|
||||
titleText("Database upgrade")
|
||||
Button("Upgrade and open chat") { runChat(confirmMigrations: .yesUp) }
|
||||
fileNameText(dbFile)
|
||||
migrationsText(upMigrations.map(\.upName))
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
Button("Upgrade and open chat") {
|
||||
runChat(confirmMigrations: .yesUp)
|
||||
}.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
fileNameText(dbFile)
|
||||
}
|
||||
case let .downgrade(downMigrations):
|
||||
titleText("Database downgrade")
|
||||
Text("Warning: you may lose some data!").bold()
|
||||
Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
|
||||
fileNameText(dbFile)
|
||||
Text("Warning: you may lose some data!")
|
||||
.bold()
|
||||
.padding(.horizontal, 25)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
migrationsText(downMigrations)
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
Button("Downgrade and open chat") {
|
||||
runChat(confirmMigrations: .yesUpDown)
|
||||
}.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
fileNameText(dbFile)
|
||||
}
|
||||
case let .migrationError(mtrError):
|
||||
titleText("Incompatible database version")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
fileNameText(dbFile, font: .callout)
|
||||
errorView(Text(mtrErrorDescription(mtrError)))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
fileNameText(dbFile)
|
||||
Text("Error: \(migrationSQLError)")
|
||||
fileNameText(dbFile, font: .callout)
|
||||
errorView(Text("Error: \(migrationSQLError)"))
|
||||
case .errorKeychain:
|
||||
titleText("Keychain error")
|
||||
Text("Cannot access keychain to save database password")
|
||||
errorView(Text("Cannot access keychain to save database password"))
|
||||
case .invalidConfirmation:
|
||||
// this can only happen if incorrect parameter is passed
|
||||
Text(String("Invalid migration confirmation")).font(.title)
|
||||
titleText("Invalid migration confirmation")
|
||||
errorView()
|
||||
|
||||
case let .unknown(json):
|
||||
titleText("Database error")
|
||||
Text("Unknown database error: \(json)")
|
||||
errorView(Text("Unknown database error: \(json)"))
|
||||
case .ok:
|
||||
EmptyView()
|
||||
}
|
||||
if showRestoreDbButton {
|
||||
Spacer().frame(height: 10)
|
||||
Spacer()
|
||||
Text("The attempt to change database passphrase was not completed.")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
.font(.footnote)
|
||||
|
||||
restoreDbButton()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
|
||||
}
|
||||
|
||||
private func titleText(_ s: LocalizedStringKey) -> Text {
|
||||
Text(s).font(.title)
|
||||
private func titleText(_ s: LocalizedStringKey) -> some View {
|
||||
Text(s).font(.largeTitle).bold().multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
private func fileNameText(_ f: String) -> Text {
|
||||
Text("File: \((f as NSString).lastPathComponent)")
|
||||
private func fileNameText(_ f: String, font: Font = .caption) -> Text {
|
||||
Text("File: \((f as NSString).lastPathComponent)").font(font)
|
||||
}
|
||||
|
||||
private func migrationsText(_ ms: [String]) -> Text {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
private func migrationsText(_ ms: [String]) -> some View {
|
||||
(Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
}
|
||||
|
||||
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
|
||||
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
}
|
||||
|
||||
private func saveAndOpenButton() -> some View {
|
||||
Button("Save passphrase and open chat") {
|
||||
saveAndRunChat()
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
|
||||
private func openChatButton() -> some View {
|
||||
Button("Open chat") {
|
||||
runChat()
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
|
||||
private func saveAndRunChat() {
|
||||
@@ -192,8 +241,9 @@ struct DatabaseErrorView: View {
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
} label: {
|
||||
Text("Restore database backup").foregroundColor(.red)
|
||||
Text("Restore database backup")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
|
||||
private func restoreDb() {
|
||||
@@ -208,6 +258,23 @@ struct DatabaseErrorView: View {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func errorView(_ s: Text? = nil) -> some View {
|
||||
VStack(spacing: 35) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.foregroundColor(.red)
|
||||
|
||||
if let text = s {
|
||||
text
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct DatabaseErrorView_Previews: PreviewProvider {
|
||||
|
||||
@@ -46,6 +46,7 @@ struct DatabaseView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let dismissSettingsSheet: DismissAction
|
||||
@State private var runChat = false
|
||||
@State private var stoppingChat = false
|
||||
@State private var alert: DatabaseAlert? = nil
|
||||
@State private var showFileImporter = false
|
||||
@State private var importedArchivePath: URL?
|
||||
@@ -57,6 +58,8 @@ struct DatabaseView: View {
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var appFilesCountAndSize: (Int, Int)?
|
||||
|
||||
@State private var showDatabaseEncryptionView = false
|
||||
|
||||
@State var chatItemTTL: ChatItemTTL
|
||||
@State private var currentChatItemTTL: ChatItemTTL = .none
|
||||
|
||||
@@ -69,7 +72,20 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chatDatabaseView() -> some View {
|
||||
NavigationLink(isActive: $showDatabaseEncryptionView) {
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
|
||||
stopChatRunBlockStartChat(false, progressIndicator, block)
|
||||
})
|
||||
.navigationTitle("Database passphrase")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
|
||||
List {
|
||||
let stopped = m.chatRunning == false
|
||||
Section {
|
||||
@@ -101,9 +117,10 @@ struct DatabaseView: View {
|
||||
isOn: $runChat
|
||||
)
|
||||
.onChange(of: runChat) { _ in
|
||||
if (runChat) {
|
||||
startChat()
|
||||
} else {
|
||||
if runChat {
|
||||
DatabaseView.startChat($runChat, $progressIndicator)
|
||||
} else if !stoppingChat {
|
||||
stoppingChat = false
|
||||
alert = .stopChat
|
||||
}
|
||||
}
|
||||
@@ -123,7 +140,9 @@ struct DatabaseView: View {
|
||||
let color: Color = unencrypted ? .orange : theme.colors.secondary
|
||||
settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) {
|
||||
NavigationLink {
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false, stopChatRunBlockStartChat: { progressIndicator, block in
|
||||
stopChatRunBlockStartChat(false, progressIndicator, block)
|
||||
})
|
||||
.navigationTitle("Database passphrase")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
@@ -133,9 +152,14 @@ struct DatabaseView: View {
|
||||
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||
Button("Export database") {
|
||||
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
|
||||
alert = .exportProhibited
|
||||
showDatabaseEncryptionView = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
alert = .exportProhibited
|
||||
}
|
||||
} else {
|
||||
exportArchive()
|
||||
stopChatRunBlockStartChat(stopped, $progressIndicator) {
|
||||
await exportArchive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,20 +168,6 @@ struct DatabaseView: View {
|
||||
showFileImporter = true
|
||||
}
|
||||
}
|
||||
if let archiveName = chatArchiveName {
|
||||
let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get()
|
||||
? "Old database archive"
|
||||
: "New database archive"
|
||||
settingsRow("archivebox", color: theme.colors.secondary) {
|
||||
NavigationLink {
|
||||
ChatArchiveView(archiveName: archiveName)
|
||||
.navigationTitle(title)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
settingsRow("trash.slash", color: theme.colors.secondary) {
|
||||
Button("Delete database", role: .destructive) {
|
||||
alert = .deleteChat
|
||||
@@ -167,14 +177,10 @@ struct DatabaseView: View {
|
||||
Text("Chat database")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text(
|
||||
stopped
|
||||
? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts."
|
||||
: "Stop chat to enable database actions"
|
||||
)
|
||||
Text("You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.disabled(!stopped)
|
||||
.disabled(progressIndicator)
|
||||
|
||||
if case .group = dbContainer, legacyDatabase {
|
||||
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
|
||||
@@ -190,7 +196,7 @@ struct DatabaseView: View {
|
||||
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
|
||||
alert = .deleteFilesAndMedia
|
||||
}
|
||||
.disabled(!stopped || appFilesCountAndSize?.0 == 0)
|
||||
.disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
|
||||
} header: {
|
||||
Text("Files & media")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -255,7 +261,10 @@ struct DatabaseView: View {
|
||||
title: Text("Import chat database?"),
|
||||
message: Text("Your current chat database will be DELETED and REPLACED with the imported one.") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
|
||||
primaryButton: .destructive(Text("Import")) {
|
||||
importArchive(fileURL)
|
||||
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
|
||||
_ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert)
|
||||
return true
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -263,19 +272,15 @@ struct DatabaseView: View {
|
||||
return Alert(title: Text("Error: no database file"))
|
||||
}
|
||||
case .archiveImported:
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database")
|
||||
)
|
||||
let (title, message) = archiveImportedAlertText()
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
case let .archiveImportedWithErrors(errs):
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database") + Text(verbatim: "\n") + Text("Some non-fatal errors occurred during import:") + archiveErrorsText(errs)
|
||||
)
|
||||
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
showShareSheet(items: [archivePath])
|
||||
}
|
||||
@@ -285,15 +290,17 @@ struct DatabaseView: View {
|
||||
title: Text("Delete chat profile?"),
|
||||
message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
deleteChat()
|
||||
let wasStopped = m.chatRunning == false
|
||||
stopChatRunBlockStartChat(wasStopped, $progressIndicator) {
|
||||
_ = await deleteChat()
|
||||
return true
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .chatDeleted:
|
||||
return Alert(
|
||||
title: Text("Chat database deleted"),
|
||||
message: Text("Restart the app to create a new chat profile")
|
||||
)
|
||||
let (title, message) = chatDeletedAlertText()
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
case .deleteLegacyDatabase:
|
||||
return Alert(
|
||||
title: Text("Delete old database?"),
|
||||
@@ -308,7 +315,10 @@ struct DatabaseView: View {
|
||||
title: Text("Delete files and media?"),
|
||||
message: Text("This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
deleteFiles()
|
||||
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
|
||||
deleteFiles()
|
||||
return true
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -328,95 +338,180 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func authStopChat() {
|
||||
private func authStopChat(_ onStop: (() -> Void)? = nil) {
|
||||
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
|
||||
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success: stopChat()
|
||||
case .unavailable: stopChat()
|
||||
case .success: stopChat(onStop)
|
||||
case .unavailable: stopChat(onStop)
|
||||
case .failed: withAnimation { runChat = true }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopChat()
|
||||
stopChat(onStop)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopChat() {
|
||||
private func stopChat(_ onStop: (() -> Void)? = nil) {
|
||||
Task {
|
||||
do {
|
||||
try await stopChatAsync()
|
||||
onStop?()
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
runChat = true
|
||||
alert = .error(title: "Error stopping chat", error: responseError(error))
|
||||
showAlert("Error stopping chat", message: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportArchive() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let (archivePath, archiveErrors) = try await exportChatArchive()
|
||||
if archiveErrors.isEmpty {
|
||||
showShareSheet(items: [archivePath])
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} else {
|
||||
await MainActor.run {
|
||||
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
|
||||
progressIndicator = false
|
||||
func stopChatRunBlockStartChat(
|
||||
_ stopped: Bool,
|
||||
_ progressIndicator: Binding<Bool>,
|
||||
_ block: @escaping () async throws -> Bool
|
||||
) {
|
||||
// if the chat was running, the sequence is: stop chat, run block, start chat.
|
||||
// Otherwise, just run block and do nothing - the toggle will be visible anyway and the user can start the chat or not
|
||||
if stopped {
|
||||
Task {
|
||||
do {
|
||||
_ = try await block()
|
||||
} catch {
|
||||
logger.error("Error while executing block: \(error)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authStopChat {
|
||||
stoppingChat = true
|
||||
runChat = false
|
||||
Task {
|
||||
// if it throws, let's start chat again anyway
|
||||
var canStart = false
|
||||
do {
|
||||
canStart = try await block()
|
||||
} catch {
|
||||
logger.error("Error executing block: \(error)")
|
||||
canStart = true
|
||||
}
|
||||
if canStart {
|
||||
await MainActor.run {
|
||||
DatabaseView.startChat($runChat, $progressIndicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func startChat(_ runChat: Binding<Bool>, _ progressIndicator: Binding<Bool>) {
|
||||
progressIndicator.wrappedValue = true
|
||||
let m = ChatModel.shared
|
||||
if m.chatDbChanged {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
let hadDatabase = hasDatabase()
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
if m.chatDbStatus != .ok || !hadDatabase {
|
||||
// Hide current view and show `DatabaseErrorView`
|
||||
dismissAllSheets(animated: true)
|
||||
}
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
progressIndicator.wrappedValue = false
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
_ = try apiStartChat()
|
||||
runChat.wrappedValue = true
|
||||
m.chatRunning = true
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
} catch let error {
|
||||
runChat.wrappedValue = false
|
||||
showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error))
|
||||
}
|
||||
progressIndicator.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
private func exportArchive() async -> Bool {
|
||||
await MainActor.run {
|
||||
progressIndicator = true
|
||||
}
|
||||
do {
|
||||
let (archivePath, archiveErrors) = try await exportChatArchive()
|
||||
if archiveErrors.isEmpty {
|
||||
showShareSheet(items: [archivePath])
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} else {
|
||||
await MainActor.run {
|
||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||
alert = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func importArchive(_ archivePath: URL) {
|
||||
static func importArchive(
|
||||
_ archivePath: URL,
|
||||
_ progressIndicator: Binding<Bool>,
|
||||
_ alert: Binding<DatabaseAlert?>
|
||||
) async -> Bool {
|
||||
if archivePath.startAccessingSecurityScopedResource() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
do {
|
||||
let config = ArchiveConfig(archivePath: archivePath.path)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
_ = kcDatabasePassword.remove()
|
||||
if archiveErrors.isEmpty {
|
||||
await operationEnded(.archiveImported)
|
||||
} else {
|
||||
await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors))
|
||||
}
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)))
|
||||
}
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
|
||||
}
|
||||
archivePath.stopAccessingSecurityScopedResource()
|
||||
await MainActor.run {
|
||||
progressIndicator.wrappedValue = true
|
||||
}
|
||||
do {
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
do {
|
||||
let config = ArchiveConfig(archivePath: archivePath.path)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
shouldImportAppSettingsDefault.set(true)
|
||||
_ = kcDatabasePassword.remove()
|
||||
if archiveErrors.isEmpty {
|
||||
await operationEnded(.archiveImported, progressIndicator, alert)
|
||||
} else {
|
||||
await operationEnded(.archiveImportedWithErrors(archiveErrors: archiveErrors), progressIndicator, alert)
|
||||
}
|
||||
return true
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
|
||||
}
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
|
||||
}
|
||||
archivePath.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
alert = .error(title: "Error accessing database file")
|
||||
showAlert("Error accessing database file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func deleteChat() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
try await deleteChatAsync()
|
||||
await operationEnded(.chatDeleted)
|
||||
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error deleting database", error: responseError(error)))
|
||||
}
|
||||
private func deleteChat() async -> Bool {
|
||||
await MainActor.run {
|
||||
progressIndicator = true
|
||||
}
|
||||
do {
|
||||
try await deleteChatAsync()
|
||||
appFilesCountAndSize = directoryFileCountAndSize(getAppFilesDirectory())
|
||||
await DatabaseView.operationEnded(.chatDeleted, $progressIndicator, $alert)
|
||||
return true
|
||||
} catch let error {
|
||||
await DatabaseView.operationEnded(.error(title: "Error deleting database", error: responseError(error)), $progressIndicator, $alert)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,39 +523,28 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func operationEnded(_ dbAlert: DatabaseAlert) async {
|
||||
private static func operationEnded(_ dbAlert: DatabaseAlert, _ progressIndicator: Binding<Bool>, _ alert: Binding<DatabaseAlert?>) async {
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
progressIndicator = false
|
||||
alert = dbAlert
|
||||
progressIndicator.wrappedValue = false
|
||||
}
|
||||
}
|
||||
|
||||
private func startChat() {
|
||||
if m.chatDbChanged {
|
||||
dismissSettingsSheet()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
try initializeChat(start: true)
|
||||
m.chatDbChanged = false
|
||||
AppChatState.shared.set(.active)
|
||||
} catch let error {
|
||||
fatalError("Error starting chat \(responseError(error))")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
_ = try apiStartChat()
|
||||
runChat = true
|
||||
m.chatRunning = true
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
} catch let error {
|
||||
runChat = false
|
||||
alert = .error(title: "Error starting chat", error: responseError(error))
|
||||
await withCheckedContinuation { cont in
|
||||
let okAlertActionWaiting = UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default, handler: { _ in cont.resume() })
|
||||
// show these alerts globally so they are visible when all sheets will be hidden
|
||||
if case .archiveImported = dbAlert {
|
||||
let (title, message) = archiveImportedAlertText()
|
||||
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
|
||||
} else if case .archiveImportedWithErrors(let errs) = dbAlert {
|
||||
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
|
||||
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
|
||||
} else if case .chatDeleted = dbAlert {
|
||||
let (title, message) = chatDeletedAlertText()
|
||||
showAlert(title, message: message, actions: { [okAlertActionWaiting] })
|
||||
} else {
|
||||
alert.wrappedValue = dbAlert
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,8 +587,28 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func archiveErrorsText(_ errs: [ArchiveError]) -> Text {
|
||||
return Text("\n" + errs.map(showArchiveError).joined(separator: "\n"))
|
||||
private func archiveImportedAlertText() -> (String, String) {
|
||||
(
|
||||
NSLocalizedString("Chat database imported", comment: ""),
|
||||
NSLocalizedString("Restart the app to use imported chat database", comment: "")
|
||||
)
|
||||
}
|
||||
private func archiveImportedWithErrorsAlertText(errs: [ArchiveError]) -> (String, String) {
|
||||
(
|
||||
NSLocalizedString("Chat database imported", comment: ""),
|
||||
NSLocalizedString("Restart the app to use imported chat database", comment: "") + "\n" + NSLocalizedString("Some non-fatal errors occurred during import:", comment: "") + archiveErrorsText(errs)
|
||||
)
|
||||
}
|
||||
|
||||
private func chatDeletedAlertText() -> (String, String) {
|
||||
(
|
||||
NSLocalizedString("Chat database deleted", comment: ""),
|
||||
NSLocalizedString("Restart the app to create a new chat profile", comment: "")
|
||||
)
|
||||
}
|
||||
|
||||
func archiveErrorsText(_ errs: [ArchiveError]) -> String {
|
||||
return "\n" + errs.map(showArchiveError).joined(separator: "\n")
|
||||
|
||||
func showArchiveError(_ err: ArchiveError) -> String {
|
||||
switch err {
|
||||
|
||||
@@ -117,7 +117,7 @@ struct MigrateToAppGroupView: View {
|
||||
setV3DBMigration(.migration_error)
|
||||
migrationError = "Error starting chat: \(responseError(error))"
|
||||
}
|
||||
deleteOldArchive()
|
||||
deleteOldChatArchive()
|
||||
} label: {
|
||||
Text("Start chat")
|
||||
.font(.title)
|
||||
@@ -235,14 +235,16 @@ func exportChatArchive(_ storagePath: URL? = nil) async throws -> (URL, [Archive
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
let errs = try await apiExportArchive(config: config)
|
||||
if storagePath == nil {
|
||||
deleteOldArchive()
|
||||
deleteOldChatArchive()
|
||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||
chatArchiveTimeDefault.set(archiveTime)
|
||||
}
|
||||
return (archivePath, errs)
|
||||
}
|
||||
|
||||
func deleteOldArchive() {
|
||||
/// Deprecated. Remove in the end of 2025. All unused archives should be deleted for the most users til then.
|
||||
/// Remove DEFAULT_CHAT_ARCHIVE_NAME and DEFAULT_CHAT_ARCHIVE_TIME as well
|
||||
func deleteOldChatArchive() {
|
||||
let d = UserDefaults.standard
|
||||
if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) {
|
||||
do {
|
||||
|
||||
@@ -177,7 +177,7 @@ struct MigrateFromDevice: View {
|
||||
case let .archiveExportedWithErrors(archivePath, errs):
|
||||
return Alert(
|
||||
title: Text("Chat database exported"),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
Task { await uploadArchive(path: archivePath) }
|
||||
}
|
||||
@@ -222,7 +222,8 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
|
||||
private func passphraseNotSetView() -> some View {
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true, stopChatRunBlockStartChat: { _, _ in
|
||||
})
|
||||
.onChange(of: initialRandomDBPassphrase) { initial in
|
||||
if !initial {
|
||||
migrationState = .uploadConfirmation
|
||||
|
||||
@@ -103,6 +103,9 @@ struct MigrateToDevice: View {
|
||||
@State private var showQRCodeScanner: Bool = true
|
||||
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
||||
|
||||
@State private var importingArchiveFromFileProgressIndicator = false
|
||||
@State private var showFileImporter = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch migrationState {
|
||||
@@ -200,6 +203,12 @@ struct MigrateToDevice: View {
|
||||
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
||||
pasteLinkView()
|
||||
}
|
||||
Section(header: Text("Or import archive file").foregroundColor(theme.colors.secondary)) {
|
||||
archiveImportFromFileView()
|
||||
}
|
||||
}
|
||||
if importingArchiveFromFileProgressIndicator {
|
||||
progressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +229,34 @@ struct MigrateToDevice: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func archiveImportFromFileView() -> some View {
|
||||
Button {
|
||||
showFileImporter = true
|
||||
} label: {
|
||||
Label("Import database", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.disabled(importingArchiveFromFileProgressIndicator)
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.zip],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(files) = result, let fileURL = files.first {
|
||||
Task {
|
||||
let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, Binding.constant(nil))
|
||||
if success {
|
||||
DatabaseView.startChat(
|
||||
Binding.constant(false),
|
||||
$importingArchiveFromFileProgressIndicator
|
||||
)
|
||||
hideView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func linkDownloadingView(_ link: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
|
||||
@@ -18,7 +18,6 @@ struct NewChatMenuButton: View {
|
||||
// @EnvironmentObject var chatModel: ChatModel
|
||||
@State private var showNewChatSheet = false
|
||||
@State private var alert: SomeAlert? = nil
|
||||
@State private var pendingConnection: PendingContactConnection? = nil
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
@@ -30,12 +29,8 @@ struct NewChatMenuButton: View {
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.appSheet(isPresented: $showNewChatSheet) {
|
||||
NewChatSheet(pendingConnection: $pendingConnection)
|
||||
NewChatSheet()
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
.onDisappear {
|
||||
alert = cleanupPendingConnection(contactConnection: pendingConnection)
|
||||
pendingConnection = nil
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
return a.alert
|
||||
@@ -55,7 +50,6 @@ struct NewChatSheet: View {
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var alert: SomeAlert?
|
||||
@Binding var pendingConnection: PendingContactConnection?
|
||||
|
||||
// Sheet height management
|
||||
@State private var isAddContactActive = false
|
||||
@@ -110,17 +104,17 @@ struct NewChatSheet: View {
|
||||
if (searchText.isEmpty) {
|
||||
Section {
|
||||
NavigationLink(isActive: $isAddContactActive) {
|
||||
NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
NewChatView(selection: .invite)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
navigateOnTap(Label("Add contact", systemImage: "link.badge.plus")) {
|
||||
navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) {
|
||||
isAddContactActive = true
|
||||
}
|
||||
}
|
||||
NavigationLink(isActive: $isScanPasteLinkActive) {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
|
||||
@@ -45,32 +45,33 @@ enum NewChatOption: Identifiable {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
func cleanupPendingConnection(contactConnection: PendingContactConnection?) -> SomeAlert? {
|
||||
var alert: SomeAlert? = nil
|
||||
|
||||
if !(ChatModel.shared.showingInvitation?.connChatUsed ?? true),
|
||||
let conn = contactConnection {
|
||||
alert = SomeAlert(
|
||||
alert: Alert(
|
||||
title: Text("Keep unused invitation?"),
|
||||
message: Text("You can view invitation link again in connection details."),
|
||||
primaryButton: .default(Text("Keep")) {},
|
||||
secondaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: conn),
|
||||
chatItems: []
|
||||
))
|
||||
func showKeepInvitationAlert() {
|
||||
if let showingInvitation = ChatModel.shared.showingInvitation,
|
||||
!showingInvitation.connChatUsed {
|
||||
showAlert(
|
||||
NSLocalizedString("Keep unused invitation?", comment: "alert title"),
|
||||
message: NSLocalizedString("You can view invitation link again in connection details.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Keep", comment: "alert action"),
|
||||
style: .default
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Delete", comment: "alert action"),
|
||||
style: .destructive,
|
||||
handler: { _ in
|
||||
Task {
|
||||
await deleteChat(Chat(
|
||||
chatInfo: .contactConnection(contactConnection: showingInvitation.pcc),
|
||||
chatItems: []
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
]}
|
||||
)
|
||||
}
|
||||
|
||||
ChatModel.shared.showingInvitation = nil
|
||||
|
||||
return alert
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@@ -84,13 +85,12 @@ struct NewChatView: View {
|
||||
@State var choosingProfile = false
|
||||
@State private var pastedLink: String = ""
|
||||
@State private var alert: NewChatViewAlert?
|
||||
@Binding var parentAlert: SomeAlert?
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
@State private var contactConnection: PendingContactConnection? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("New chat", selection: $selection) {
|
||||
Label("Add contact", systemImage: "link")
|
||||
Label("1-time link", systemImage: "link")
|
||||
.tag(NewChatOption.invite)
|
||||
Label("Connect via link", systemImage: "qrcode")
|
||||
.tag(NewChatOption.connect)
|
||||
@@ -157,7 +157,7 @@ struct NewChatView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
if !choosingProfile {
|
||||
parentAlert = cleanupPendingConnection(contactConnection: contactConnection)
|
||||
showKeepInvitationAlert()
|
||||
contactConnection = nil
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ struct NewChatView: View {
|
||||
if let (connReq, pcc) = r {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(pcc)
|
||||
m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false)
|
||||
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
}
|
||||
@@ -916,11 +916,17 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (
|
||||
)
|
||||
case let .groupLinkConnecting(_, groupInfo):
|
||||
if let groupInfo = groupInfo {
|
||||
return Alert(
|
||||
return groupInfo.businessChat == nil
|
||||
? Alert(
|
||||
title: Text("Group already exists!"),
|
||||
message: Text("You are already joining the group \(groupInfo.displayName)."),
|
||||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
: Alert(
|
||||
title: Text("Chat already exists!"),
|
||||
message: Text("You are already connecting to \(groupInfo.displayName)."),
|
||||
dismissButton: .default(Text("OK")) { cleanup?() }
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text("Already joining the group!"),
|
||||
@@ -1237,10 +1243,15 @@ func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
||||
}
|
||||
|
||||
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
mkAlert(
|
||||
groupInfo.businessChat == nil
|
||||
? mkAlert(
|
||||
title: "Group already exists",
|
||||
message: "You are already in group \(groupInfo.displayName)."
|
||||
)
|
||||
: mkAlert(
|
||||
title: "Chat already exists",
|
||||
message: "You are already connected with \(groupInfo.displayName)."
|
||||
)
|
||||
}
|
||||
|
||||
enum ConnReqType: Equatable {
|
||||
@@ -1278,9 +1289,7 @@ struct NewChatView_Previews: PreviewProvider {
|
||||
@State var contactConnection: PendingContactConnection? = nil
|
||||
|
||||
NewChatView(
|
||||
selection: .invite,
|
||||
parentAlert: $parentAlert,
|
||||
contactConnection: $contactConnection
|
||||
selection: .invite
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// AddressCreationCard.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Diogo Cunha on 13/11/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
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 && chatContactType(chat: chat) != ContactType.card
|
||||
}
|
||||
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,451 @@
|
||||
//
|
||||
// ChooseServerOperators.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 31.10.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")!
|
||||
|
||||
struct OnboardingButtonStyle: ButtonStyle {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var isDisabled: Bool = false
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
isDisabled
|
||||
? (
|
||||
theme.colors.isLight
|
||||
? .gray.opacity(0.17)
|
||||
: .gray.opacity(0.27)
|
||||
)
|
||||
: theme.colors.primary
|
||||
)
|
||||
.foregroundColor(
|
||||
isDisabled
|
||||
? (
|
||||
theme.colors.isLight
|
||||
? .gray.opacity(0.4)
|
||||
: .white.opacity(0.2)
|
||||
)
|
||||
: .white
|
||||
)
|
||||
.cornerRadius(16)
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ChooseServerOperatorsSheet: Identifiable {
|
||||
case showInfo
|
||||
case showConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showInfo: return "showInfo"
|
||||
case .showConditions: return "showConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChooseServerOperators: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var onboarding: Bool
|
||||
@State private var serverOperators: [ServerOperator] = []
|
||||
@State private var selectedOperatorIds = Set<Int64>()
|
||||
@State private var sheetItem: ChooseServerOperatorsSheet? = nil
|
||||
@State private var notificationsModeNavLinkActive = false
|
||||
@State private var justOpened = true
|
||||
|
||||
var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
let title = Text("Server operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
if onboarding {
|
||||
title.padding(.top, 25)
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
infoText()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(serverOperators) { srvOperator in
|
||||
operatorCheckView(srvOperator)
|
||||
}
|
||||
VStack {
|
||||
Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
|
||||
Text("You can configure servers via settings.")
|
||||
}
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
|
||||
let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
|
||||
let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if !reviewForOperators.isEmpty {
|
||||
reviewConditionsButton()
|
||||
} else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
|
||||
setOperatorsButton()
|
||||
} else {
|
||||
continueButton()
|
||||
}
|
||||
if onboarding {
|
||||
Group {
|
||||
if reviewForOperators.isEmpty {
|
||||
Button("Conditions of use") {
|
||||
sheetItem = .showConditions
|
||||
}
|
||||
} else {
|
||||
Text("Conditions of use")
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
}
|
||||
|
||||
if !onboarding && !reviewForOperators.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
reviewLaterButton()
|
||||
(
|
||||
Text("Conditions will be accepted for enabled operators after 30 days.")
|
||||
+ textSpace
|
||||
+ Text("You can configure operators in Network & servers settings.")
|
||||
)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!canReviewLater)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.onAppear {
|
||||
if justOpened {
|
||||
serverOperators = ChatModel.shared.conditions.serverOperators
|
||||
selectedOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
|
||||
justOpened = false
|
||||
}
|
||||
}
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showInfo:
|
||||
ChooseServerOperatorsInfoView()
|
||||
case .showConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.padding(onboarding ? 25 : 16)
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
Button {
|
||||
sheetItem = .showInfo
|
||||
} label: {
|
||||
Label("How it helps privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
|
||||
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
|
||||
let icon = checked ? "checkmark.circle.fill" : "circle"
|
||||
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
|
||||
HStack(spacing: 10) {
|
||||
Image(serverOperator.largeLogo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
Spacer()
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 26, height: 26)
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
.background(theme.colors.background)
|
||||
.padding()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
|
||||
)
|
||||
.padding(.horizontal, 2)
|
||||
.onTapGesture {
|
||||
if checked {
|
||||
selectedOperatorIds.remove(serverOperator.operatorId)
|
||||
} else {
|
||||
selectedOperatorIds.insert(serverOperator.operatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewConditionsButton() -> some View {
|
||||
NavigationLink("Review conditions") {
|
||||
reviewConditionsView()
|
||||
.navigationTitle("Conditions of use")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
|
||||
private func setOperatorsButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
Task {
|
||||
if let enabledOperators = enabledOperators(serverOperators) {
|
||||
let r = try await setServerOperators(operators: enabledOperators)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r
|
||||
continueToNextStep()
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
continueToNextStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Update")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func continueButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
continueToNextStep()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
|
||||
.disabled(selectedOperatorIds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewLaterButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
continueToNextStep()
|
||||
} label: {
|
||||
Text("Review later")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
private func continueToNextStep() {
|
||||
if onboarding {
|
||||
onboardingStageDefault.set(.step4_SetNotificationsMode)
|
||||
notificationsModeNavLinkActive = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
|
||||
ZStack {
|
||||
button()
|
||||
|
||||
NavigationLink(isActive: $notificationsModeNavLinkActive) {
|
||||
notificationsModeDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func notificationsModeDestinationView() -> some View {
|
||||
SetNotificationsMode()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder private func reviewConditionsView() -> some View {
|
||||
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
|
||||
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if !operatorsWithConditionsAccepted.isEmpty {
|
||||
Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
} else {
|
||||
Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
}
|
||||
ConditionsTextView()
|
||||
.frame(maxHeight: .infinity)
|
||||
acceptConditionsButton()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
}
|
||||
|
||||
private func acceptConditionsButton() -> some View {
|
||||
notificationsModeNavLinkButton {
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
||||
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
|
||||
let operatorIds = acceptForOperators.map { $0.operatorId }
|
||||
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r
|
||||
}
|
||||
if let enabledOperators = enabledOperators(r.serverOperators) {
|
||||
let r2 = try await setServerOperators(operators: enabledOperators)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r2
|
||||
continueToNextStep()
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
continueToNextStep()
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error accepting conditions", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Accept conditions")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? {
|
||||
var ops = operators
|
||||
if !ops.isEmpty {
|
||||
for i in 0..<ops.count {
|
||||
var op = ops[i]
|
||||
op.enabled = selectedOperatorIds.contains(op.operatorId)
|
||||
ops[i] = op
|
||||
}
|
||||
let haveSMPStorage = ops.contains(where: { $0.enabled && $0.smpRoles.storage })
|
||||
let haveSMPProxy = ops.contains(where: { $0.enabled && $0.smpRoles.proxy })
|
||||
let haveXFTPStorage = ops.contains(where: { $0.enabled && $0.xftpRoles.storage })
|
||||
let haveXFTPProxy = ops.contains(where: { $0.enabled && $0.xftpRoles.proxy })
|
||||
if haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy {
|
||||
return ops
|
||||
} else if let firstEnabledIndex = ops.firstIndex(where: { $0.enabled }) {
|
||||
var op = ops[firstEnabledIndex]
|
||||
if !haveSMPStorage { op.smpRoles.storage = true }
|
||||
if !haveSMPProxy { op.smpRoles.proxy = true }
|
||||
if !haveXFTPStorage { op.xftpRoles.storage = true }
|
||||
if !haveXFTPProxy { op.xftpRoles.proxy = true }
|
||||
ops[firstEnabledIndex] = op
|
||||
return ops
|
||||
} else { // Shouldn't happen - view doesn't let to proceed if no operators are enabled
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")!
|
||||
|
||||
struct ChooseServerOperatorsInfoView: View {
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("The app protects your privacy by using different operators in each conversation.")
|
||||
Text("When more than one operator is enabled, none of them has metadata to learn who communicates with whom.")
|
||||
Text("For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server.")
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.padding(.top)
|
||||
|
||||
Section {
|
||||
ForEach(ChatModel.shared.conditions.serverOperators) { op in
|
||||
operatorInfoNavLinkView(op)
|
||||
}
|
||||
} header: {
|
||||
Text("About operators")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Server operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
private func operatorInfoNavLinkView(_ op: ServerOperator) -> some View {
|
||||
NavigationLink() {
|
||||
OperatorInfoView(serverOperator: op)
|
||||
.navigationBarTitle("Network operator")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(op.logo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
Text(op.tradeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
}
|
||||
@@ -38,7 +38,7 @@ struct CreateProfile: View {
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
Button {
|
||||
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
|
||||
createProfile()
|
||||
} label: {
|
||||
Label("Create profile", systemImage: "checkmark")
|
||||
}
|
||||
@@ -78,6 +78,35 @@ struct CreateProfile: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
// .isEmpty check is redundant here, but it makes it clearer what is going on
|
||||
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_ChooseServerOperators)
|
||||
m.onboardingStage = .step3_ChooseServerOperators
|
||||
}
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
} catch let error {
|
||||
showCreateProfileAlert(showAlert: { alert = $0 }, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CreateFirstProfile: View {
|
||||
@@ -86,131 +115,150 @@ struct CreateFirstProfile: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var displayName: String = ""
|
||||
@FocusState private var focusDisplayName
|
||||
@State private var nextStepNavLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.bottom)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.frame(maxWidth: .infinity) // Ensures it takes up the full width
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
ZStack(alignment: .trailing) {
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.top)
|
||||
|
||||
Spacer()
|
||||
onboardingButtons()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
createProfileButton()
|
||||
if !focusDisplayName {
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 25)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func onboardingButtons() -> some View {
|
||||
HStack {
|
||||
func createProfileButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
hideKeyboard()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
}
|
||||
createProfile()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
Text("Create profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
|
||||
NavigationLink(isActive: $nextStepNavLinkActive) {
|
||||
nextStepDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func showAlert(_ alert: UserProfileAlert) {
|
||||
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
|
||||
}
|
||||
|
||||
private func nextStepDestinationView() -> some View {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
private func createProfile() {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
try startChat(onboarding: true)
|
||||
onboardingStageDefault.set(.step3_ChooseServerOperators)
|
||||
nextStepNavLinkActive = true
|
||||
} catch let error {
|
||||
showCreateProfileAlert(showAlert: showAlert, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
private func showCreateProfileAlert(
|
||||
showAlert: (UserProfileAlert) -> Void,
|
||||
_ error: Error
|
||||
) {
|
||||
let m = ChatModel.shared
|
||||
do {
|
||||
AppChatState.shared.set(.active)
|
||||
m.currentUser = try apiCreateActiveUser(profile)
|
||||
// .isEmpty check is redundant here, but it makes it clearer what is going on
|
||||
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
|
||||
try startChat()
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
|
||||
m.onboardingStage = .step3_CreateSimpleXAddress
|
||||
}
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
} catch let error {
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
showAlert(.invalidDisplayNameError)
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
}
|
||||
case .chatCmdError(_, .error(.invalidDisplayName)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(invalidDisplayNameAlert)
|
||||
} else {
|
||||
showAlert(.invalidDisplayNameError)
|
||||
}
|
||||
default:
|
||||
let err: LocalizedStringKey = "Error: \(responseError(error))"
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(creatUserErrorAlert(err))
|
||||
} else {
|
||||
showAlert(.createUserError(error: err))
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
logger.error("Failed to create user or start chat: \(responseError(error))")
|
||||
}
|
||||
|
||||
private func canCreateProfile(_ displayName: String) -> Bool {
|
||||
|
||||
@@ -9,24 +9,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HowItWorks: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var m: ChatModel
|
||||
var onboarding: Bool
|
||||
@Binding var createProfileNavLinkActive: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("How SimpleX works")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*")
|
||||
Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.")
|
||||
Text("You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.")
|
||||
Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.")
|
||||
if onboarding {
|
||||
Text("Read more in our GitHub repository.")
|
||||
} else {
|
||||
Text("To protect your privacy, SimpleX uses separate IDs for each of your contacts.")
|
||||
Text("Only client devices store user profiles, contacts, groups, and messages.")
|
||||
Text("All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages.")
|
||||
if !onboarding {
|
||||
Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
|
||||
}
|
||||
}
|
||||
@@ -37,19 +37,34 @@ struct HowItWorks: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
.padding(.bottom, 8)
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
.lineLimit(10)
|
||||
.padding()
|
||||
.padding(onboarding ? 25 : 16)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
private func createFirstProfileButton() -> some View {
|
||||
Button {
|
||||
dismiss()
|
||||
createProfileNavLinkActive = true
|
||||
} label: {
|
||||
Text("Create your profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
}
|
||||
|
||||
struct HowItWorks_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
HowItWorks(onboarding: true)
|
||||
HowItWorks(
|
||||
onboarding: true,
|
||||
createProfileNavLinkActive: Binding.constant(false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,39 @@ struct OnboardingView: View {
|
||||
var onboarding: OnboardingStage
|
||||
|
||||
var body: some View {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
|
||||
case .step2_CreateProfile: CreateFirstProfile()
|
||||
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
|
||||
case .step4_SetNotificationsMode: SetNotificationsMode()
|
||||
case .onboardingComplete: EmptyView()
|
||||
NavigationView {
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo:
|
||||
SimpleXInfo(onboarding: true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step2_CreateProfile: // deprecated
|
||||
CreateFirstProfile()
|
||||
.modifier(ThemedBackground())
|
||||
case .step3_CreateSimpleXAddress: // deprecated
|
||||
CreateSimpleXAddress()
|
||||
case .step3_ChooseServerOperators:
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step4_SetNotificationsMode:
|
||||
SetNotificationsMode()
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .onboardingComplete: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onboardingButtonPlaceholder() -> some View {
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile
|
||||
case step3_CreateSimpleXAddress
|
||||
case step2_CreateProfile // deprecated
|
||||
case step3_CreateSimpleXAddress // deprecated
|
||||
case step3_ChooseServerOperators
|
||||
case step4_SetNotificationsMode
|
||||
case onboardingComplete
|
||||
|
||||
|
||||
@@ -13,42 +13,54 @@ struct SetNotificationsMode: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State private var notificationMode = NotificationsMode.instant
|
||||
@State private var showAlert: NotificationAlert?
|
||||
@State private var showInfo: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.top, 25)
|
||||
|
||||
infoText()
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Send notifications:")
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
}
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(25)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.sheet(isPresented: $showInfo) {
|
||||
NotificationsInfoView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +87,15 @@ struct SetNotificationsMode: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
Button {
|
||||
showInfo = true
|
||||
} label: {
|
||||
Label("How it affects privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NtfModeSelector: View {
|
||||
@@ -85,15 +106,25 @@ struct NtfModeSelector: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: mode.icon)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: mode.icon == "bolt" ? 14 : 18, height: 18)
|
||||
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
|
||||
Text(ntfModeDescription(mode))
|
||||
.lineLimit(10)
|
||||
.font(.subheadline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
|
||||
Text(ntfModeShortDescription(mode))
|
||||
.lineLimit(2)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tapped ? Color(uiColor: .secondarySystemFill) : theme.colors.background)
|
||||
@@ -109,6 +140,37 @@ struct NtfModeSelector: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Notifications privacy")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
(Text(Image(systemName: mode.icon)) + textSpace + Text(mode.label))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text(ntfModeDescription(mode))
|
||||
.lineLimit(10)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsModeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SetNotificationsMode()
|
||||
|
||||
@@ -13,58 +13,58 @@ struct SimpleXInfo: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@State private var showHowItWorks = false
|
||||
@State private var createProfileNavLinkActive = false
|
||||
var onboarding: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: g.size.width * 0.67)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
|
||||
VStack(alignment: .center, spacing: 10) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: g.size.width * 0.67)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.leading, 4)
|
||||
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
|
||||
|
||||
Button {
|
||||
showHowItWorks = true
|
||||
} label: {
|
||||
Label("The future of messaging", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("The next generation of private messaging")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 30)
|
||||
.padding(.horizontal, 40)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
infoRow("privacy", "Privacy redefined",
|
||||
"The 1st platform without any user identifiers – private by design.", width: 48)
|
||||
infoRow("shield", "Immune to spam and abuse",
|
||||
"People can connect to you only via the links you share.", width: 46)
|
||||
infoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
|
||||
"Open-source protocol and code – anybody can run the servers.", width: 44)
|
||||
onboardingInfoRow("privacy", "Privacy redefined",
|
||||
"No user identifiers.", width: 48)
|
||||
onboardingInfoRow("shield", "Immune to spam",
|
||||
"You decide who can connect.", width: 46)
|
||||
onboardingInfoRow(colorScheme == .light ? "decentralized" : "decentralized-light", "Decentralized",
|
||||
"Anybody can host servers.", width: 46)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Button {
|
||||
showHowItWorks = true
|
||||
} label: {
|
||||
Label("How it works", systemImage: "info.circle")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
@@ -83,76 +83,59 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHowItWorks) {
|
||||
HowItWorks(onboarding: onboarding)
|
||||
HowItWorks(
|
||||
onboarding: onboarding,
|
||||
createProfileNavLinkActive: $createProfileNavLinkActive
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
}
|
||||
|
||||
private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
|
||||
private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Image(image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: width, height: 54)
|
||||
.frame(width: 54)
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 10)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(text).frame(minHeight: 40, alignment: .top)
|
||||
.font(.callout)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
private func createFirstProfileButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createProfileNavLinkActive = true
|
||||
} label: {
|
||||
Text("Create your profile")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
|
||||
NavigationLink(isActive: $createProfileNavLinkActive) {
|
||||
CreateFirstProfile()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
.padding(.trailing, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingActionButton: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
if m.currentUser == nil {
|
||||
actionButton("Create your profile", onboarding: .step2_CreateProfile)
|
||||
} else {
|
||||
actionButton("Make a private connection", onboarding: .onboardingComplete)
|
||||
}
|
||||
}
|
||||
|
||||
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
onboardingStageDefault.set(onboarding)
|
||||
m.onboardingStage = onboarding
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label).font(.title2)
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
action()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label).font(.title2)
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
let textSpace = Text(verbatim: " ")
|
||||
|
||||
struct SimpleXInfo_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -7,190 +7,209 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private struct VersionDescription {
|
||||
var version: String
|
||||
var post: URL?
|
||||
var features: [FeatureDescription]
|
||||
var features: [Feature]
|
||||
}
|
||||
|
||||
private struct FeatureDescription {
|
||||
var icon: String?
|
||||
var title: LocalizedStringKey
|
||||
var description: LocalizedStringKey?
|
||||
private enum Feature: Identifiable {
|
||||
case feature(Description)
|
||||
case view(FeatureView)
|
||||
|
||||
var id: LocalizedStringKey {
|
||||
switch self {
|
||||
case let .feature(d): d.title
|
||||
case let .view(v): v.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Description {
|
||||
let icon: String?
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey?
|
||||
var subfeatures: [(icon: String, description: LocalizedStringKey)] = []
|
||||
}
|
||||
|
||||
private struct FeatureView {
|
||||
let icon: String?
|
||||
let title: LocalizedStringKey
|
||||
let view: () -> any View
|
||||
}
|
||||
|
||||
private let versionDescriptions: [VersionDescription] = [
|
||||
VersionDescription(
|
||||
version: "v4.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Security assessment",
|
||||
description: "SimpleX Chat security was audited by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.2",
|
||||
title: "Group links",
|
||||
description: "Admins can create the links to join groups."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "checkmark",
|
||||
title: "Auto-accept contact requests",
|
||||
description: "With optional welcome message."
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20221206-simplex-chat-v4.3-voice-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "mic",
|
||||
title: "Voice messages",
|
||||
description: "Max 30 seconds, received instantly."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "trash.slash",
|
||||
title: "Irreversible message deletion",
|
||||
description: "Your contacts can allow full message deletion."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Improved server configuration",
|
||||
description: "Add servers by scanning QR codes."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "eye.slash",
|
||||
title: "Improved privacy and security",
|
||||
description: "Hide app screen in the recent apps."
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20230103-simplex-chat-v4.4-disappearing-messages.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "stopwatch",
|
||||
title: "Disappearing messages",
|
||||
description: "Sent messages will be deleted after set time."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "ellipsis.circle",
|
||||
title: "Live messages",
|
||||
description: "Recipients see updates as you type them."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Verify connection security",
|
||||
description: "Compare security codes with your contacts."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "camera",
|
||||
title: "GIFs and stickers",
|
||||
description: "Send them from gallery or custom keyboards."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "French interface",
|
||||
description: "Thanks to the users – contribute via Weblate!"
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.5",
|
||||
post: URL(string: "https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "person.crop.rectangle.stack",
|
||||
title: "Multiple chat profiles",
|
||||
description: "Different names, avatars and transport isolation."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "rectangle.and.pencil.and.ellipsis",
|
||||
title: "Message draft",
|
||||
description: "Preserve the last message draft, with attachments."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network.badge.shield.half.filled",
|
||||
title: "Transport isolation",
|
||||
description: "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock.doc",
|
||||
title: "Private filenames",
|
||||
description: "To protect timezone, image/voice files use UTC."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.25",
|
||||
title: "Reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Italian interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v4.6",
|
||||
post: URL(string: "https://simplex.chat/blog/20230328-simplex-chat-v4-6-hidden-profiles.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Hidden chat profiles",
|
||||
description: "Protect your chat profiles with a password!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "phone.arrow.up.right",
|
||||
title: "Audio and video calls",
|
||||
description: "Fully re-implemented - work in background!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "flag",
|
||||
title: "Group moderation",
|
||||
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "plus.message",
|
||||
title: "Group welcome message",
|
||||
description: "Set the message shown to new members!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Further reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Chinese and Spanish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.0",
|
||||
post: URL(string: "https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "arrow.up.doc",
|
||||
title: "Videos and files up to 1gb",
|
||||
description: "Fast and no wait until the sender is online!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "App passcode",
|
||||
description: "Set it instead of system authentication."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Polish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
// Also
|
||||
@@ -200,240 +219,240 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
version: "v5.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "face.smiling",
|
||||
title: "Message reactions",
|
||||
description: "Finally, we have them! 🚀"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrow.up.message",
|
||||
title: "Better messages",
|
||||
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Self-destruct passcode",
|
||||
description: "All data is erased when it is entered."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Japanese interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark",
|
||||
title: "Message delivery receipts!",
|
||||
description: "The second tick we missed! ✅"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "star",
|
||||
title: "Find chats faster",
|
||||
description: "Filter unread and favorite chats."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
title: "Keep your connections",
|
||||
description: "Fix encryption after restoring backups."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "stopwatch",
|
||||
title: "Make one message disappear",
|
||||
description: "Even when disabled in the conversation."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- more stable message delivery.\n- a bit better groups.\n- and more!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.3",
|
||||
post: URL(string: "https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "desktopcomputer",
|
||||
title: "New desktop app!",
|
||||
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "lock",
|
||||
title: "Encrypt stored files & media",
|
||||
description: "App encrypts new local files (except videos)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "magnifyingglass",
|
||||
title: "Discover and join groups",
|
||||
description: "- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "theatermasks",
|
||||
title: "Simplified incognito mode",
|
||||
description: "Toggle incognito when connecting."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "\(4) new interface languages",
|
||||
description: "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.4",
|
||||
post: URL(string: "https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "desktopcomputer",
|
||||
title: "Link mobile and desktop apps! 🔗",
|
||||
description: "Via secure quantum resistant protocol."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.2",
|
||||
title: "Better groups",
|
||||
description: "Faster joining and more reliable messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "theatermasks",
|
||||
title: "Incognito groups",
|
||||
description: "Create a group using a random profile."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "hand.raised",
|
||||
title: "Block group members",
|
||||
description: "To hide unwanted messages."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "gift",
|
||||
title: "A few more things",
|
||||
description: "- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.5",
|
||||
post: URL(string: "https://simplex.chat/blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "folder",
|
||||
title: "Private notes",
|
||||
description: "With encrypted files and media."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "link",
|
||||
title: "Paste link to connect!",
|
||||
description: "Search bar accepts invitation links."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
title: "Join group conversations",
|
||||
description: "Recent history and improved [directory bot](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion)."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Improved message delivery",
|
||||
description: "With reduced battery usage."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Turkish interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.6",
|
||||
post: URL(string: "https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Enable in direct chats (BETA)!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "tray.and.arrow.up",
|
||||
title: "App data migration",
|
||||
description: "Migrate to another device via QR code."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "phone",
|
||||
title: "Picture-in-picture calls",
|
||||
description: "Use the app while in the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "hand.raised",
|
||||
title: "Safer groups",
|
||||
description: "Admins can block a member for all."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "character",
|
||||
title: "Hungarian interface",
|
||||
description: "Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.7",
|
||||
post: URL(string: "https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Will be enabled in direct chats!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrowshape.turn.up.forward",
|
||||
title: "Forward and save messages",
|
||||
description: "Message source remains private."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "music.note",
|
||||
title: "In-call sounds",
|
||||
description: "When connecting audio and video calls."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "person.crop.square",
|
||||
title: "Shape profile images",
|
||||
description: "Square, circle, or anything in between."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Network management",
|
||||
description: "More reliable network connection."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v5.8",
|
||||
post: URL(string: "https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "arrow.forward",
|
||||
title: "Private message routing 🚀",
|
||||
description: "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network.badge.shield.half.filled",
|
||||
title: "Safely receive files",
|
||||
description: "Confirm files from unknown servers."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "battery.50",
|
||||
title: "Improved message delivery",
|
||||
description: "With reduced battery usage."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.0",
|
||||
post: URL(string: "https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "New chat experience 🎉",
|
||||
description: nil,
|
||||
@@ -444,8 +463,8 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
("platter.filled.bottom.and.arrow.down.iphone", "Use the app with one hand."),
|
||||
("paintpalette", "Color chats with the new themes."),
|
||||
]
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "New media options",
|
||||
description: nil,
|
||||
@@ -454,39 +473,39 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
("play.circle", "Play from the chat list."),
|
||||
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
|
||||
]
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "arrow.forward",
|
||||
title: "Private message routing 🚀",
|
||||
description: "It protects your IP address and connections."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "network",
|
||||
title: "Better networking",
|
||||
description: "Connection and servers status."
|
||||
)
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.1",
|
||||
post: URL(string: "https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html"),
|
||||
features: [
|
||||
FeatureDescription(
|
||||
.feature(Description(
|
||||
icon: "checkmark.shield",
|
||||
title: "Better security ✅",
|
||||
description: "SimpleX protocols reviewed by Trail of Bits."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "video",
|
||||
title: "Better calls",
|
||||
description: "Switch audio and video during the call."
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bolt",
|
||||
title: "Better notifications",
|
||||
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
|
||||
),
|
||||
FeatureDescription(
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: nil,
|
||||
title: "Better user experience",
|
||||
description: nil,
|
||||
@@ -497,9 +516,30 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
("arrowshape.turn.up.right", "Forward up to 20 messages at once."),
|
||||
("flag", "Delete or moderate up to 200 messages.")
|
||||
]
|
||||
),
|
||||
)),
|
||||
]
|
||||
),
|
||||
VersionDescription(
|
||||
version: "v6.2",
|
||||
post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"),
|
||||
features: [
|
||||
.view(FeatureView(
|
||||
icon: nil,
|
||||
title: "Network decentralization",
|
||||
view: { NewOperatorsView() }
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "briefcase",
|
||||
title: "Business chats",
|
||||
description: "Privacy for your customers."
|
||||
)),
|
||||
.feature(Description(
|
||||
icon: "bolt",
|
||||
title: "More reliable notifications",
|
||||
description: "Delivered even when Apple drops them."
|
||||
)),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
private let lastVersion = versionDescriptions.last!.version
|
||||
@@ -514,14 +554,68 @@ func shouldShowWhatsNew() -> Bool {
|
||||
return v != lastVersion
|
||||
}
|
||||
|
||||
fileprivate struct NewOperatorsView: View {
|
||||
@State private var showOperatorsSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
Text("The second preset operator in the app!")
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(10)
|
||||
HStack {
|
||||
Button("Enable Flux") {
|
||||
showOperatorsSheet = true
|
||||
}
|
||||
Text("for better metadata privacy.")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showOperatorsSheet) {
|
||||
NavigationView {
|
||||
ChooseServerOperators(onboarding: false)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WhatsNewViewSheet: Identifiable {
|
||||
case showConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showConditions: return "showConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WhatsNewView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var currentVersion = versionDescriptions.count - 1
|
||||
@State var currentVersionNav = versionDescriptions.count - 1
|
||||
var viaSettings = false
|
||||
var updatedConditions: Bool
|
||||
@State private var sheetItem: WhatsNewViewSheet? = nil
|
||||
|
||||
var body: some View {
|
||||
whatsNewView()
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: Binding.constant([]),
|
||||
userServers: Binding.constant([])
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func whatsNewView() -> some View {
|
||||
VStack {
|
||||
TabView(selection: $currentVersion) {
|
||||
ForEach(Array(versionDescriptions.enumerated()), id: \.0) { (i, v) in
|
||||
@@ -532,9 +626,11 @@ struct WhatsNewView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
ForEach(v.features, id: \.title) { f in
|
||||
featureDescription(f)
|
||||
.padding(.bottom, 8)
|
||||
ForEach(v.features) { f in
|
||||
switch f {
|
||||
case let .feature(d): featureDescription(d).padding(.bottom, 8)
|
||||
case let .view(v): AnyView(v.view()).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
@@ -544,13 +640,20 @@ struct WhatsNewView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if updatedConditions {
|
||||
Button("View updated conditions") {
|
||||
sheetItem = .showConditions
|
||||
}
|
||||
}
|
||||
if !viaSettings {
|
||||
Spacer()
|
||||
|
||||
Button("Ok") {
|
||||
dismiss()
|
||||
}
|
||||
.font(.title3)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
@@ -568,20 +671,24 @@ struct WhatsNewView: View {
|
||||
currentVersionNav = currentVersion
|
||||
}
|
||||
}
|
||||
|
||||
private func featureDescription(_ f: FeatureDescription) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let icon = f.icon {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(f.title).font(.title3).bold()
|
||||
}
|
||||
} else {
|
||||
Text(f.title).font(.title3).bold()
|
||||
|
||||
@ViewBuilder private func featureHeader(_ icon: String?, _ title: LocalizedStringKey) -> some View {
|
||||
if let icon {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(minWidth: 30, alignment: .center)
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
} else {
|
||||
Text(title).font(.title3).bold()
|
||||
}
|
||||
}
|
||||
|
||||
private func featureDescription(_ f: Description) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
featureHeader(f.icon, f.title)
|
||||
if let d = f.description {
|
||||
Text(d)
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -636,6 +743,6 @@ struct WhatsNewView: View {
|
||||
|
||||
struct NewFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WhatsNewView()
|
||||
WhatsNewView(updatedConditions: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ struct ConnectDesktopView: View {
|
||||
private func ctrlDeviceNameText(_ session: RemoteCtrlSession, _ rc: RemoteCtrlInfo?) -> Text {
|
||||
var t = Text(rc?.deviceViewName ?? session.ctrlAppInfo?.deviceName ?? "")
|
||||
if (rc == nil) {
|
||||
t = t + Text(" ") + Text("(new)").italic()
|
||||
t = t + textSpace + Text("(new)").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -277,7 +277,7 @@ struct ConnectDesktopView: View {
|
||||
let v = session.ctrlAppInfo?.appVersionRange.maxVersion
|
||||
var t = Text("v\(v ?? "")")
|
||||
if v != session.appVersion {
|
||||
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
|
||||
t = t + textSpace + Text("(this device v\(session.appVersion))").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ extension AppSettings {
|
||||
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
|
||||
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
|
||||
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
|
||||
if let val = chatBottomBar { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) }
|
||||
}
|
||||
|
||||
public static var current: AppSettings {
|
||||
@@ -100,6 +101,7 @@ extension AppSettings {
|
||||
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
|
||||
c.uiThemes = themeOverridesDefault.get()
|
||||
c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI)
|
||||
c.chatBottomBar = groupDefaults.bool(forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR)
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ struct DeveloperView: View {
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
|
||||
((developerTools ? Text("Show:") : Text("Hide:")) + textSpace + Text("Database IDs and Transport isolation option."))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// NetworkServersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 02/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private enum NetworkAlert: Identifiable {
|
||||
case error(err: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .error(err): return "error \(err)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkAndServers: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
.navigationTitle("Your SMP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Message servers")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ProtocolServersView(serverProtocol: .xftp)
|
||||
.navigationTitle("Your XFTP servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Media & file servers")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AdvancedNetworkSettings()
|
||||
.navigationTitle("Advanced settings")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Advanced network settings")
|
||||
}
|
||||
} header: {
|
||||
Text("Messages & files")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
|
||||
NavigationLink {
|
||||
RTCServers()
|
||||
.navigationTitle("Your ICE servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("WebRTC ICE servers")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text(m.networkInfo.networkType.text)
|
||||
Spacer()
|
||||
Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NetworkAndServers()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// ConditionsWebView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Stanislav Dmitrenko on 26.11.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct ConditionsWebView: UIViewRepresentable {
|
||||
@State var html: String
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var pageLoaded = false
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let view = WKWebView()
|
||||
view.backgroundColor = .clear
|
||||
view.isOpaque = false
|
||||
view.navigationDelegate = context.coordinator
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
// just to make sure that even if updateUIView will not be called for any reason, the page
|
||||
// will be rendered anyway
|
||||
if !pageLoaded {
|
||||
loadPage(view)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: WKWebView, context: Context) {
|
||||
loadPage(view)
|
||||
}
|
||||
|
||||
private func loadPage(_ webView: WKWebView) {
|
||||
let styles = """
|
||||
<style>
|
||||
body {
|
||||
color: \(theme.colors.onBackground.toHTMLHex());
|
||||
font-family: Helvetica;
|
||||
}
|
||||
a {
|
||||
color: \(theme.colors.primary.toHTMLHex());
|
||||
}
|
||||
code, pre {
|
||||
font-family: Menlo;
|
||||
background: \(theme.colors.secondary.opacity(theme.colors.isLight ? 0.2 : 0.3).toHTMLHex());
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
let head = "<head><meta name='viewport' content='width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=no'>\(styles)</head>"
|
||||
webView.loadHTMLString(head + html, baseURL: nil)
|
||||
DispatchQueue.main.async {
|
||||
pageLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Cordinator {
|
||||
Cordinator()
|
||||
}
|
||||
|
||||
class Cordinator: NSObject, WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
|
||||
guard let url = navigationAction.request.url else { return decisionHandler(.allow) }
|
||||
|
||||
switch navigationAction.navigationType {
|
||||
case .linkActivated:
|
||||
decisionHandler(.cancel)
|
||||
if url.absoluteString.starts(with: "https://simplex.chat/contact#") {
|
||||
ChatModel.shared.appOpenUrl = url
|
||||
} else {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
default:
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//
|
||||
// NetworkServersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 02/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private enum NetworkAlert: Identifiable {
|
||||
case error(err: String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .error(err): return "error \(err)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum NetworkAndServersSheet: Identifiable {
|
||||
case showConditions
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .showConditions: return "showConditions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkAndServers: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var ss: SaveableSettings
|
||||
@State private var sheetItem: NetworkAndServersSheet? = nil
|
||||
@State private var justOpened = true
|
||||
@State private var showSaveDialog = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
let conditionsAction = m.conditions.conditionsAction
|
||||
let anyOperatorEnabled = ss.servers.userServers.contains(where: { $0.operator?.enabled ?? false })
|
||||
Section {
|
||||
ForEach(ss.servers.userServers.enumerated().map { $0 }, id: \.element.id) { idx, userOperatorServers in
|
||||
if let serverOperator = userOperatorServers.operator {
|
||||
serverOperatorView(idx, serverOperator)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
if let conditionsAction = conditionsAction, anyOperatorEnabled {
|
||||
conditionsButton(conditionsAction)
|
||||
}
|
||||
} header: {
|
||||
Text("Preset servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
switch conditionsAction {
|
||||
case let .review(_, deadline, _):
|
||||
if let deadline = deadline, anyOperatorEnabled {
|
||||
Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let idx = ss.servers.userServers.firstIndex(where: { $0.operator == nil }) {
|
||||
NavigationLink {
|
||||
YourServersView(
|
||||
userServers: $ss.servers.userServers,
|
||||
serverErrors: $ss.servers.serverErrors,
|
||||
operatorIndex: idx
|
||||
)
|
||||
.navigationTitle("Your servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Your servers")
|
||||
|
||||
if ss.servers.userServers[idx] != ss.servers.currUserServers[idx] {
|
||||
Spacer()
|
||||
unsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AdvancedNetworkSettings()
|
||||
.navigationTitle("Advanced settings")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Advanced network settings")
|
||||
}
|
||||
} header: {
|
||||
Text("Messages & files")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
|
||||
.disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
|
||||
} footer: {
|
||||
if let errStr = globalServersError(ss.servers.serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else if !ss.servers.serverErrors.isEmpty {
|
||||
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
|
||||
NavigationLink {
|
||||
RTCServers()
|
||||
.navigationTitle("Your ICE servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("WebRTC ICE servers")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Network connection").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text(m.networkInfo.networkType.text)
|
||||
Spacer()
|
||||
Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// this condition is needed to prevent re-setting the servers when exiting single server view
|
||||
if justOpened {
|
||||
do {
|
||||
ss.servers.currUserServers = try await getUserServers()
|
||||
ss.servers.userServers = ss.servers.currUserServers
|
||||
ss.servers.serverErrors = []
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error loading servers", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
justOpened = false
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors) {
|
||||
showSaveDialog = true
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
||||
Button("Save") {
|
||||
saveServers($ss.servers.currUserServers, $ss.servers.userServers)
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
.sheet(item: $sheetItem) { item in
|
||||
switch item {
|
||||
case .showConditions:
|
||||
UsageConditionsView(
|
||||
currUserServers: $ss.servers.currUserServers,
|
||||
userServers: $ss.servers.userServers
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func serverOperatorView(_ operatorIndex: Int, _ serverOperator: ServerOperator) -> some View {
|
||||
NavigationLink() {
|
||||
OperatorView(
|
||||
currUserServers: $ss.servers.currUserServers,
|
||||
userServers: $ss.servers.userServers,
|
||||
serverErrors: $ss.servers.serverErrors,
|
||||
operatorIndex: operatorIndex,
|
||||
useOperator: serverOperator.enabled
|
||||
)
|
||||
.navigationBarTitle("\(serverOperator.tradeName) servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(serverOperator.logo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.grayscale(serverOperator.enabled ? 0.0 : 1.0)
|
||||
.frame(width: 24, height: 24)
|
||||
Text(serverOperator.tradeName)
|
||||
.foregroundColor(serverOperator.enabled ? theme.colors.onBackground : theme.colors.secondary)
|
||||
|
||||
if ss.servers.userServers[operatorIndex] != ss.servers.currUserServers[operatorIndex] {
|
||||
Spacer()
|
||||
unsavedChangesIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unsavedChangesIndicator() -> some View {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
}
|
||||
|
||||
private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View {
|
||||
Button {
|
||||
sheetItem = .showConditions
|
||||
} label: {
|
||||
switch conditionsAction {
|
||||
case .review:
|
||||
Text("Review conditions")
|
||||
case .accepted:
|
||||
Text("Accepted conditions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UsageConditionsView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var currUserServers: [UserOperatorServers]
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
Text("Conditions of use").font(.largeTitle).bold()
|
||||
Spacer()
|
||||
conditionsLinkButton()
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
|
||||
switch ChatModel.shared.conditions.conditionsAction {
|
||||
|
||||
case .none:
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
|
||||
case let .review(operators, deadline, _):
|
||||
Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
ConditionsTextView()
|
||||
VStack(spacing: 8) {
|
||||
acceptConditionsButton(operators.map { $0.operatorId })
|
||||
if let deadline = deadline {
|
||||
Text("Conditions will be automatically accepted for enabled operators on: \(conditionsTimestamp(deadline)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
|
||||
|
||||
case let .accepted(operators):
|
||||
Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
ConditionsTextView()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func acceptConditionsButton(_ operatorIds: [Int64]) -> some View {
|
||||
Button {
|
||||
acceptForOperators(operatorIds)
|
||||
} label: {
|
||||
Text("Accept conditions")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
}
|
||||
|
||||
func acceptForOperators(_ operatorIds: [Int64]) {
|
||||
Task {
|
||||
do {
|
||||
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
||||
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r
|
||||
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error accepting conditions", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
|
||||
let userServersToValidate = userServers.wrappedValue
|
||||
Task {
|
||||
do {
|
||||
let errs = try await validateServers(userServers: userServersToValidate)
|
||||
await MainActor.run {
|
||||
serverErrors.wrappedValue = errs
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("validateServers error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serversCanBeSaved(
|
||||
_ currUserServers: [UserOperatorServers],
|
||||
_ userServers: [UserOperatorServers],
|
||||
_ serverErrors: [UserServersError]
|
||||
) -> Bool {
|
||||
return userServers != currUserServers && serverErrors.isEmpty
|
||||
}
|
||||
|
||||
struct ServersErrorView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var errStr: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
Text(errStr)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
for err in serverErrors {
|
||||
if let errStr = err.globalError {
|
||||
return errStr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
for err in serverErrors {
|
||||
if let errStr = err.globalSMPError {
|
||||
return errStr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func globalXFTPServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
for err in serverErrors {
|
||||
if let errStr = err.globalXFTPError {
|
||||
return errStr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set<String> {
|
||||
let duplicateHostsList = serverErrors.compactMap { err in
|
||||
if case let .duplicateServer(_, _, duplicateHost) = err {
|
||||
return duplicateHost
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return Set(duplicateHostsList)
|
||||
}
|
||||
|
||||
func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) {
|
||||
let userServersToSave = userServers.wrappedValue
|
||||
Task {
|
||||
do {
|
||||
try await setUserServers(userServers: userServersToSave)
|
||||
// Get updated servers to learn new server ids (otherwise it messes up delete of newly added and saved servers)
|
||||
do {
|
||||
let updatedServers = try await getUserServers()
|
||||
let updatedOperators = try await getServerOperators()
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = updatedOperators
|
||||
currUserServers.wrappedValue = updatedServers
|
||||
userServers.wrappedValue = updatedServers
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("saveServers getUserServers error: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
currUserServers.wrappedValue = userServersToSave
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("saveServers setUserServers error: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error saving servers", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, _ updatedOperators: [ServerOperator]) {
|
||||
for i in 0..<usvs.wrappedValue.count {
|
||||
if let updatedOperator = updatedOperators.first(where: { $0.operatorId == usvs.wrappedValue[i].operator?.operatorId }) {
|
||||
usvs.wrappedValue[i].operator?.conditionsAcceptance = updatedOperator.conditionsAcceptance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NetworkAndServers()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// NewServerView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 13.11.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct NewServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@State private var serverToEdit: UserServer = .empty
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
customServer()
|
||||
if testing {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
title: Text("Server test failed!"),
|
||||
message: Text(testFailure?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Possibly refactor - similar functions in ProtocolServerView
|
||||
private func customServer() -> some View {
|
||||
VStack {
|
||||
let serverAddress = parseServerAddress(serverToEdit.server)
|
||||
let valid = serverAddress?.valid == true
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $serverToEdit.server)
|
||||
.multilineTextAlignment(.leading)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.allowsTightening(true)
|
||||
.lineLimit(10)
|
||||
.frame(height: 144)
|
||||
.padding(-6)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your server address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if !valid {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
useServerSection(valid)
|
||||
if valid {
|
||||
Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) {
|
||||
MutableQRCode(uri: $serverToEdit.server)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useServerSection(_ valid: Bool) -> some View {
|
||||
Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Button("Test server") {
|
||||
testing = true
|
||||
serverToEdit.tested = nil
|
||||
Task {
|
||||
if let f = await testServerConnection(server: $serverToEdit) {
|
||||
showTestFailure = true
|
||||
testFailure = f
|
||||
}
|
||||
await MainActor.run { testing = false }
|
||||
}
|
||||
}
|
||||
.disabled(!valid || testing)
|
||||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serverProtocolAndOperator(_ server: UserServer, _ userServers: [UserOperatorServers]) -> (ServerProtocol, ServerOperator?)? {
|
||||
if let serverAddress = parseServerAddress(server.server) {
|
||||
let serverProtocol = serverAddress.serverProtocol
|
||||
let hostnames = serverAddress.hostnames
|
||||
let matchingOperator = userServers.compactMap { $0.operator }.first { op in
|
||||
op.serverDomains.contains { domain in
|
||||
hostnames.contains { hostname in
|
||||
hostname.hasSuffix(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (serverProtocol, matchingOperator)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func addServer(
|
||||
_ server: UserServer,
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ serverErrors: Binding<[UserServersError]>,
|
||||
_ dismiss: DismissAction
|
||||
) {
|
||||
if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
|
||||
if let i = userServers.wrappedValue.firstIndex(where: { $0.operator?.operatorId == matchingOperator?.operatorId }) {
|
||||
switch serverProtocol {
|
||||
case .smp: userServers[i].wrappedValue.smpServers.append(server)
|
||||
case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
|
||||
}
|
||||
validateServers_(userServers, serverErrors)
|
||||
dismiss()
|
||||
if let op = matchingOperator {
|
||||
showAlert(
|
||||
NSLocalizedString("Operator server", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Server added to operator %@.", comment: "alert message"), op.tradeName)
|
||||
)
|
||||
}
|
||||
} else { // Shouldn't happen
|
||||
dismiss()
|
||||
showAlert(NSLocalizedString("Error adding server", comment: "alert title"))
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
if server.server.trimmingCharacters(in: .whitespaces) != "" {
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid server address!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check server address and try again.", comment: "alert title")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NewServerView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
//
|
||||
// OperatorView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 28.10.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import Ink
|
||||
|
||||
struct OperatorView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.editMode) private var editMode
|
||||
@Binding var currUserServers: [UserOperatorServers]
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
var operatorIndex: Int
|
||||
@State var useOperator: Bool
|
||||
@State private var useOperatorToggleReset: Bool = false
|
||||
@State private var showConditionsSheet: Bool = false
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var testing = false
|
||||
|
||||
var body: some View {
|
||||
operatorView()
|
||||
.opacity(testing ? 0.4 : 1)
|
||||
.overlay {
|
||||
if testing {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!testing)
|
||||
}
|
||||
|
||||
@ViewBuilder private func operatorView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
infoViewLink()
|
||||
useOperatorToggle()
|
||||
} header: {
|
||||
Text("Operator")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
|
||||
case let .accepted(acceptedAt):
|
||||
if let acceptedAt = acceptedAt {
|
||||
Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
case let .required(deadline):
|
||||
if userServers[operatorIndex].operator_.enabled, let deadline = deadline {
|
||||
Text("Conditions will be accepted on: \(conditionsTimestamp(deadline)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if userServers[operatorIndex].operator_.enabled {
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
|
||||
.onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
|
||||
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Use for messages")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalSMPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preset servers can't be deleted
|
||||
if !userServers[operatorIndex].smpServers.filter({ $0.preset }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].smpServers) { srv in
|
||||
if srv.wrappedValue.preset {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Message servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalSMPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
Text("The servers for new connections of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].smpServers) { srv in
|
||||
if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteSMPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Added message servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
|
||||
.onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Use for files")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalXFTPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preset servers can't be deleted
|
||||
if !userServers[operatorIndex].xftpServers.filter({ $0.preset }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].xftpServers) { srv in
|
||||
if srv.wrappedValue.preset {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Media & file servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalXFTPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
Text("The servers for new files of your current chat profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].xftpServers) { srv in
|
||||
if !srv.wrappedValue.preset && !srv.wrappedValue.deleted {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteXFTPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Added media & file servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TestServersButton(
|
||||
smpServers: $userServers[operatorIndex].smpServers,
|
||||
xftpServers: $userServers[operatorIndex].xftpServers,
|
||||
testing: $testing
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if (
|
||||
!userServers[operatorIndex].smpServers.filter({ !$0.preset && !$0.deleted }).isEmpty ||
|
||||
!userServers[operatorIndex].xftpServers.filter({ !$0.preset && !$0.deleted }).isEmpty
|
||||
) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showConditionsSheet, onDismiss: onUseToggleSheetDismissed) {
|
||||
SingleOperatorUsageConditionsView(
|
||||
currUserServers: $currUserServers,
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
operatorIndex: operatorIndex
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
private func infoViewLink() -> some View {
|
||||
NavigationLink() {
|
||||
OperatorInfoView(serverOperator: userServers[operatorIndex].operator_)
|
||||
.navigationBarTitle("Network operator")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Image(userServers[operatorIndex].operator_.largeLogo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.grayscale(userServers[operatorIndex].operator_.enabled ? 0.0 : 1.0)
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func useOperatorToggle() -> some View {
|
||||
Toggle("Use servers", isOn: $useOperator)
|
||||
.onChange(of: useOperator) { useOperatorToggle in
|
||||
if useOperatorToggleReset {
|
||||
useOperatorToggleReset = false
|
||||
} else if useOperatorToggle {
|
||||
switch userServers[operatorIndex].operator_.conditionsAcceptance {
|
||||
case .accepted:
|
||||
userServers[operatorIndex].operator_.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
case let .required(deadline):
|
||||
if deadline == nil {
|
||||
showConditionsSheet = true
|
||||
} else {
|
||||
userServers[operatorIndex].operator_.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userServers[operatorIndex].operator_.enabled = false
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func onUseToggleSheetDismissed() {
|
||||
if useOperator && !userServers[operatorIndex].operator_.conditionsAcceptance.usageAllowed {
|
||||
useOperatorToggleReset = true
|
||||
useOperator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func conditionsTimestamp(_ date: Date) -> String {
|
||||
let localDateFormatter = DateFormatter()
|
||||
localDateFormatter.dateStyle = .medium
|
||||
localDateFormatter.timeStyle = .none
|
||||
return localDateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
struct OperatorInfoView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||
var serverOperator: ServerOperator
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading) {
|
||||
Image(serverOperator.largeLogo(colorScheme))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 48)
|
||||
if let legalName = serverOperator.legalName {
|
||||
Text(legalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(serverOperator.info.description, id: \.self) { d in
|
||||
Text(d)
|
||||
}
|
||||
}
|
||||
Link(serverOperator.info.website.absoluteString, destination: serverOperator.info.website)
|
||||
}
|
||||
if let selfhost = serverOperator.info.selfhost {
|
||||
Section {
|
||||
Link(selfhost.text, destination: selfhost.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConditionsTextView: View {
|
||||
@State private var conditionsData: (UsageConditions, String?, UsageConditions?)?
|
||||
@State private var failedToLoad: Bool = false
|
||||
@State private var conditionsHTML: String? = nil
|
||||
|
||||
let defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md"
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task {
|
||||
do {
|
||||
let conditions = try await getUsageConditions()
|
||||
let conditionsText = conditions.1
|
||||
let parentLink = "https://github.com/simplex-chat/simplex-chat/blob/\(conditions.0.conditionsCommit)"
|
||||
let preparedText: String?
|
||||
if let conditionsText {
|
||||
let prepared = prepareMarkdown(conditionsText.trimmingCharacters(in: .whitespacesAndNewlines), parentLink)
|
||||
conditionsHTML = MarkdownParser().html(from: prepared)
|
||||
preparedText = prepared
|
||||
} else {
|
||||
preparedText = nil
|
||||
}
|
||||
conditionsData = (conditions.0, preparedText, conditions.2)
|
||||
} catch let error {
|
||||
logger.error("ConditionsTextView getUsageConditions error: \(responseError(error))")
|
||||
failedToLoad = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Diff rendering
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
if let (usageConditions, _, _) = conditionsData {
|
||||
if let conditionsHTML {
|
||||
ConditionsWebView(html: conditionsHTML)
|
||||
.padding(6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
)
|
||||
} else {
|
||||
let conditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/\(usageConditions.conditionsCommit)/PRIVACY.md"
|
||||
conditionsLinkView(conditionsLink)
|
||||
}
|
||||
} else if failedToLoad {
|
||||
conditionsLinkView(defaultConditionsLink)
|
||||
} else {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
}
|
||||
}
|
||||
|
||||
private func conditionsLinkView(_ conditionsLink: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Current conditions text couldn't be loaded, you can review conditions via this link:")
|
||||
Link(destination: URL(string: conditionsLink)!) {
|
||||
Text(conditionsLink)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareMarkdown(_ text: String, _ parentLink: String) -> String {
|
||||
let localLinkRegex = try! NSRegularExpression(pattern: "\\[([^\\(]*)\\]\\(#.*\\)")
|
||||
let h1Regex = try! NSRegularExpression(pattern: "^# ")
|
||||
var text = localLinkRegex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "$1")
|
||||
text = h1Regex.stringByReplacingMatches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count), withTemplate: "")
|
||||
return text
|
||||
.replacingOccurrences(of: "](/", with: "](\(parentLink)/")
|
||||
.replacingOccurrences(of: "](./", with: "](\(parentLink)/")
|
||||
}
|
||||
}
|
||||
|
||||
struct SingleOperatorUsageConditionsView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var currUserServers: [UserOperatorServers]
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
var operatorIndex: Int
|
||||
|
||||
var body: some View {
|
||||
viewBody()
|
||||
}
|
||||
|
||||
@ViewBuilder private func viewBody() -> some View {
|
||||
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
|
||||
if case .accepted = userServers[operatorIndex].operator_.conditionsAcceptance {
|
||||
|
||||
// In current UI implementation this branch doesn't get shown - as conditions can't be opened from inside operator once accepted
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
viewHeader()
|
||||
ConditionsTextView()
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
} else if !operatorsWithConditionsAccepted.isEmpty {
|
||||
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
viewHeader()
|
||||
Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
Text("The same conditions will apply to operator **\(userServers[operatorIndex].operator_.legalName_)**.")
|
||||
conditionsAppliedToOtherOperatorsText()
|
||||
Spacer()
|
||||
|
||||
acceptConditionsButton()
|
||||
usageConditionsNavLinkButton()
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
.padding(.horizontal)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
viewHeader()
|
||||
Text("To use the servers of **\(userServers[operatorIndex].operator_.legalName_)**, accept conditions of use.")
|
||||
conditionsAppliedToOtherOperatorsText()
|
||||
ConditionsTextView()
|
||||
acceptConditionsButton()
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private func viewHeader() -> some View {
|
||||
HStack {
|
||||
Text("Use \(userServers[operatorIndex].operator_.tradeName)").font(.largeTitle).bold()
|
||||
Spacer()
|
||||
conditionsLinkButton()
|
||||
}
|
||||
.padding(.top)
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
@ViewBuilder private func conditionsAppliedToOtherOperatorsText() -> some View {
|
||||
let otherOperatorsToApply = ChatModel.shared.conditions.serverOperators.filter {
|
||||
$0.enabled &&
|
||||
!$0.conditionsAcceptance.conditionsAccepted &&
|
||||
$0.operatorId != userServers[operatorIndex].operator_.operatorId
|
||||
}
|
||||
if !otherOperatorsToApply.isEmpty {
|
||||
Text("These conditions will also apply for: **\(otherOperatorsToApply.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func acceptConditionsButton() -> some View {
|
||||
let operatorIds = ChatModel.shared.conditions.serverOperators
|
||||
.filter {
|
||||
$0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator
|
||||
($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted
|
||||
}
|
||||
.map { $0.operatorId }
|
||||
Button {
|
||||
acceptForOperators(operatorIds, operatorIndex)
|
||||
} label: {
|
||||
Text("Accept conditions")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
}
|
||||
|
||||
func acceptForOperators(_ operatorIds: [Int64], _ operatorIndexToEnable: Int) {
|
||||
Task {
|
||||
do {
|
||||
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
||||
let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.conditions = r
|
||||
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
|
||||
userServers[operatorIndexToEnable].operator?.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error accepting conditions", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func usageConditionsNavLinkButton() -> some View {
|
||||
NavigationLink("View conditions") {
|
||||
ConditionsTextView()
|
||||
.padding()
|
||||
.navigationTitle("Conditions of use")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
func conditionsLinkButton() -> some View {
|
||||
let commit = ChatModel.shared.conditions.currentConditions.conditionsCommit
|
||||
let mdUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/\(commit)/PRIVACY.md") ?? conditionsURL
|
||||
return Menu {
|
||||
Link(destination: mdUrl) {
|
||||
Label("Open conditions", systemImage: "doc")
|
||||
}
|
||||
if let commitUrl = URL(string: "https://github.com/simplex-chat/simplex-chat/commit/\(commit)") {
|
||||
Link(destination: commitUrl) {
|
||||
Label("Open changes", systemImage: "ellipsis")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20)
|
||||
.padding(2)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OperatorView(
|
||||
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
|
||||
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
operatorIndex: 1,
|
||||
useOperator: ServerOperator.sampleData1.enabled
|
||||
)
|
||||
}
|
||||
+40
-17
@@ -12,15 +12,15 @@ import SimpleXChat
|
||||
struct ProtocolServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var server: UserServer
|
||||
@State var serverToEdit: UserServer
|
||||
var backLabel: LocalizedStringKey
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: ProtocolTestFailure?
|
||||
|
||||
var proto: String { serverProtocol.rawValue.uppercased() }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if server.preset {
|
||||
@@ -32,9 +32,33 @@ struct ProtocolServerView: View {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
|
||||
server = serverToEdit
|
||||
dismiss()
|
||||
.modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
|
||||
if let (serverToEditProtocol, serverToEditOperator) = serverProtocolAndOperator(serverToEdit, userServers),
|
||||
let (serverProtocol, serverOperator) = serverProtocolAndOperator(server, userServers) {
|
||||
if serverToEditProtocol != serverProtocol {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Error updating server", comment: "alert title"),
|
||||
message: NSLocalizedString("Server protocol changed.", comment: "alert title")
|
||||
)
|
||||
} else if serverToEditOperator != serverOperator {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Error updating server", comment: "alert title"),
|
||||
message: NSLocalizedString("Server operator changed.", comment: "alert title")
|
||||
)
|
||||
} else {
|
||||
server = serverToEdit
|
||||
validateServers_($userServers, $serverErrors)
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid server address!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check server address and try again.", comment: "alert title")
|
||||
)
|
||||
}
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
@@ -62,7 +86,7 @@ struct ProtocolServerView: View {
|
||||
private func customServer() -> some View {
|
||||
VStack {
|
||||
let serverAddress = parseServerAddress(serverToEdit.server)
|
||||
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
|
||||
let valid = serverAddress?.valid == true
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $serverToEdit.server)
|
||||
@@ -112,10 +136,7 @@ struct ProtocolServerView: View {
|
||||
Spacer()
|
||||
showTestStatus(server: serverToEdit)
|
||||
}
|
||||
let useForNewDisabled = serverToEdit.tested != true && !serverToEdit.preset
|
||||
Toggle("Use for new connections", isOn: $serverToEdit.enabled)
|
||||
.disabled(useForNewDisabled)
|
||||
.foregroundColor(useForNewDisabled ? theme.colors.secondary : theme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +163,7 @@ struct BackButton: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
|
||||
@ViewBuilder func showTestStatus(server: UserServer) -> some View {
|
||||
switch server.tested {
|
||||
case .some(true):
|
||||
Image(systemName: "checkmark")
|
||||
@@ -155,7 +176,7 @@ struct BackButton: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
|
||||
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
|
||||
do {
|
||||
let r = try await testProtoServer(server: server.wrappedValue.server)
|
||||
switch r {
|
||||
@@ -178,9 +199,11 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
|
||||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServerView(
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
server: Binding.constant(UserServer.sampleData.custom),
|
||||
serverToEdit: UserServer.sampleData.custom,
|
||||
backLabel: "Your SMP servers"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// ProtocolServersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
|
||||
|
||||
struct YourServersView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.editMode) private var editMode
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
var operatorIndex: Int
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var newServerNavLinkActive = false
|
||||
@State private var showScanProtoServer = false
|
||||
@State private var testing = false
|
||||
|
||||
var body: some View {
|
||||
yourServersView()
|
||||
.opacity(testing ? 0.4 : 1)
|
||||
.overlay {
|
||||
if testing {
|
||||
ProgressView()
|
||||
.scaleEffect(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(!testing)
|
||||
}
|
||||
|
||||
@ViewBuilder private func yourServersView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
List {
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].smpServers) { srv in
|
||||
if !srv.wrappedValue.deleted {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
backLabel: "Your servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteSMPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Message servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalSMPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].xftpServers) { srv in
|
||||
if !srv.wrappedValue.deleted {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
backLabel: "Your servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteXFTPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
}
|
||||
} header: {
|
||||
Text("Media & file servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if let errStr = globalXFTPServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
Text("The servers for new files of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ZStack {
|
||||
Button("Add server") {
|
||||
showAddServer = true
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $newServerNavLinkActive) {
|
||||
newServerDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
} footer: {
|
||||
if let errStr = globalServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TestServersButton(
|
||||
smpServers: $userServers[operatorIndex].smpServers,
|
||||
xftpServers: $userServers[operatorIndex].xftpServers,
|
||||
testing: $testing
|
||||
)
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if (
|
||||
!userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
|
||||
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
|
||||
) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") { newServerNavLinkActive = true }
|
||||
Button("Scan server QR code") { showScanProtoServer = true }
|
||||
}
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
|
||||
private func newServerDestinationView() -> some View {
|
||||
NewServerView(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors
|
||||
)
|
||||
.navigationTitle("New server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
func howToButton() -> some View {
|
||||
Button {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(howToUrl)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("How to use your servers")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolServerViewLink: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
var duplicateHosts: Set<String>
|
||||
@Binding var server: UserServer
|
||||
var serverProtocol: ServerProtocol
|
||||
var backLabel: LocalizedStringKey
|
||||
@Binding var selectedServer: String?
|
||||
|
||||
var body: some View {
|
||||
let proto = serverProtocol.rawValue.uppercased()
|
||||
|
||||
NavigationLink(tag: server.id, selection: $selectedServer) {
|
||||
ProtocolServerView(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
server: $server,
|
||||
serverToEdit: server,
|
||||
backLabel: backLabel
|
||||
)
|
||||
.navigationBarTitle("\(proto) server")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
let address = parseServerAddress(server.server)
|
||||
HStack {
|
||||
Group {
|
||||
if let address = address {
|
||||
if !address.valid || address.serverProtocol != serverProtocol {
|
||||
invalidServer()
|
||||
} else if address.hostnames.contains(where: duplicateHosts.contains) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if !server.enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
showTestStatus(server: server)
|
||||
}
|
||||
} else {
|
||||
invalidServer()
|
||||
}
|
||||
}
|
||||
.frame(width: 16, alignment: .center)
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let v = Text(address?.hostnames.first ?? server.server).lineLimit(1)
|
||||
if server.enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidServer() -> some View {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteSMPServer(
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ operatorServersIndex: Int,
|
||||
_ serverIndexSet: IndexSet
|
||||
) {
|
||||
if let idx = serverIndexSet.first {
|
||||
let server = userServers[operatorServersIndex].wrappedValue.smpServers[idx]
|
||||
if server.serverId == nil {
|
||||
userServers[operatorServersIndex].wrappedValue.smpServers.remove(at: idx)
|
||||
} else {
|
||||
var updatedServer = server
|
||||
updatedServer.deleted = true
|
||||
userServers[operatorServersIndex].wrappedValue.smpServers[idx] = updatedServer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteXFTPServer(
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ operatorServersIndex: Int,
|
||||
_ serverIndexSet: IndexSet
|
||||
) {
|
||||
if let idx = serverIndexSet.first {
|
||||
let server = userServers[operatorServersIndex].wrappedValue.xftpServers[idx]
|
||||
if server.serverId == nil {
|
||||
userServers[operatorServersIndex].wrappedValue.xftpServers.remove(at: idx)
|
||||
} else {
|
||||
var updatedServer = server
|
||||
updatedServer.deleted = true
|
||||
userServers[operatorServersIndex].wrappedValue.xftpServers[idx] = updatedServer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TestServersButton: View {
|
||||
@Binding var smpServers: [UserServer]
|
||||
@Binding var xftpServers: [UserServer]
|
||||
@Binding var testing: Bool
|
||||
|
||||
var body: some View {
|
||||
Button("Test servers", action: testServers)
|
||||
.disabled(testing || allServersDisabled)
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled }
|
||||
}
|
||||
|
||||
private func testServers() {
|
||||
resetTestStatus()
|
||||
testing = true
|
||||
Task {
|
||||
let fs = await runServersTest()
|
||||
await MainActor.run {
|
||||
testing = false
|
||||
if !fs.isEmpty {
|
||||
let msg = fs.map { (srv, f) in
|
||||
"\(srv): \(f.localizedDescription)"
|
||||
}.joined(separator: "\n")
|
||||
showAlert(
|
||||
NSLocalizedString("Tests failed!", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<smpServers.count {
|
||||
if smpServers[i].enabled {
|
||||
smpServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..<xftpServers.count {
|
||||
if xftpServers[i].enabled {
|
||||
xftpServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<smpServers.count {
|
||||
if smpServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $smpServers[i]) {
|
||||
fs[serverHostname(smpServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..<xftpServers.count {
|
||||
if xftpServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $xftpServers[i]) {
|
||||
fs[serverHostname(xftpServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
}
|
||||
|
||||
struct YourServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
YourServersView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
operatorIndex: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-15
@@ -12,8 +12,8 @@ import CodeScanner
|
||||
|
||||
struct ScanProtocolServer: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var servers: [ServerCfg]
|
||||
@State private var showAddressError = false
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -28,23 +28,14 @@ struct ScanProtocolServer: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(isPresented: $showAddressError) {
|
||||
Alert(
|
||||
title: Text("Invalid server address!"),
|
||||
message: Text("Check server address and try again.")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
}
|
||||
var server: UserServer = .empty
|
||||
server.server = r.string
|
||||
addServer(server, $userServers, $serverErrors, dismiss)
|
||||
case let .failure(e):
|
||||
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
@@ -54,6 +45,9 @@ struct ScanProtocolServer: View {
|
||||
|
||||
struct ScanProtocolServer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanProtocolServer(servers: Binding.constant([]))
|
||||
ScanProtocolServer(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -237,9 +237,17 @@ struct NotificationsView: View {
|
||||
|
||||
func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)."
|
||||
case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have."
|
||||
case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from."
|
||||
case .off: return "**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app."
|
||||
case .periodic: return "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata."
|
||||
case .instant: return "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from."
|
||||
}
|
||||
}
|
||||
|
||||
func ntfModeShortDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .off: return "Check messages when allowed."
|
||||
case .periodic: return "Check messages every 20 min."
|
||||
case .instant: return "E2E encrypted notifications."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
//
|
||||
// ProtocolServersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private let howToUrl = URL(string: "https://simplex.chat/docs/server.html")!
|
||||
|
||||
struct ProtocolServersView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.editMode) private var editMode
|
||||
let serverProtocol: ServerProtocol
|
||||
@State private var currServers: [ServerCfg] = []
|
||||
@State private var presetServers: [ServerCfg] = []
|
||||
@State private var configuredServers: [ServerCfg] = []
|
||||
@State private var otherServers: [ServerCfg] = []
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var showScanProtoServer = false
|
||||
@State private var justOpened = true
|
||||
@State private var testing = false
|
||||
@State private var alert: ServerAlert? = nil
|
||||
@State private var showSaveDialog = false
|
||||
|
||||
var proto: String { serverProtocol.rawValue.uppercased() }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
protocolServersView()
|
||||
if testing {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ServerAlert: Identifiable {
|
||||
case testsFailed(failures: [String: ProtocolTestFailure])
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .testsFailed: return "testsFailed"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func protocolServersView() -> some View {
|
||||
List {
|
||||
if !configuredServers.isEmpty {
|
||||
Section {
|
||||
ForEach($configuredServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
configuredServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
configuredServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Configured \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
}
|
||||
|
||||
if !otherServers.isEmpty {
|
||||
Section {
|
||||
ForEach($otherServers) { srv in
|
||||
protocolServerView(srv)
|
||||
}
|
||||
.onMove { indexSet, offset in
|
||||
otherServers.move(fromOffsets: indexSet, toOffset: offset)
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
otherServers.remove(atOffsets: indexSet)
|
||||
}
|
||||
} header: {
|
||||
Text("Other \(proto) servers")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Add server") {
|
||||
showAddServer = true
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset") { partitionServers(currServers) }
|
||||
.disabled(Set(allServers) == Set(currServers) || testing)
|
||||
Button("Test servers", action: testServers)
|
||||
.disabled(testing || allServersDisabled)
|
||||
Button("Save servers", action: saveServers)
|
||||
.disabled(saveDisabled)
|
||||
howToButton()
|
||||
}
|
||||
}
|
||||
.toolbar { EditButton() }
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") {
|
||||
otherServers.append(ServerCfg.empty)
|
||||
selectedServer = allServers.last?.id
|
||||
}
|
||||
Button("Scan server QR code") { showScanProtoServer = true }
|
||||
Button("Add preset servers", action: addAllPresets)
|
||||
.disabled(hasAllPresets())
|
||||
}
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(servers: $otherServers)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if saveDisabled {
|
||||
dismiss()
|
||||
justOpened = false
|
||||
} else {
|
||||
showSaveDialog = true
|
||||
}
|
||||
})
|
||||
.confirmationDialog("Save servers?", isPresented: $showSaveDialog, titleVisibility: .visible) {
|
||||
Button("Save") {
|
||||
saveServers()
|
||||
dismiss()
|
||||
justOpened = false
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
switch a {
|
||||
case let .testsFailed(fs):
|
||||
let msg = fs.map { (srv, f) in
|
||||
"\(srv): \(f.localizedDescription)"
|
||||
}.joined(separator: "\n")
|
||||
return Alert(
|
||||
title: Text("Tests failed!"),
|
||||
message: Text("Some servers failed the test:\n" + msg)
|
||||
)
|
||||
case .error:
|
||||
return Alert(
|
||||
title: Text("Error")
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// this condition is needed to prevent re-setting the servers when exiting single server view
|
||||
if justOpened {
|
||||
do {
|
||||
let r = try getUserProtoServers(serverProtocol)
|
||||
currServers = r.protoServers
|
||||
presetServers = r.presetServers
|
||||
partitionServers(currServers)
|
||||
} catch let error {
|
||||
alert = .error(
|
||||
title: "Error loading \(proto) servers",
|
||||
error: "Error: \(responseError(error))"
|
||||
)
|
||||
}
|
||||
justOpened = false
|
||||
} else {
|
||||
partitionServers(allServers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func partitionServers(_ servers: [ServerCfg]) {
|
||||
configuredServers = servers.filter { $0.preset || $0.enabled }
|
||||
otherServers = servers.filter { !($0.preset || $0.enabled) }
|
||||
}
|
||||
|
||||
private var allServers: [ServerCfg] {
|
||||
configuredServers + otherServers
|
||||
}
|
||||
|
||||
private var saveDisabled: Bool {
|
||||
allServers.isEmpty ||
|
||||
Set(allServers) == Set(currServers) ||
|
||||
testing ||
|
||||
!allServers.allSatisfy { srv in
|
||||
if let address = parseServerAddress(srv.server) {
|
||||
return uniqueAddress(srv, address)
|
||||
}
|
||||
return false
|
||||
} ||
|
||||
allServersDisabled
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
allServers.allSatisfy { !$0.enabled }
|
||||
}
|
||||
|
||||
private func protocolServerView(_ server: Binding<ServerCfg>) -> some View {
|
||||
let srv = server.wrappedValue
|
||||
return NavigationLink(tag: srv.id, selection: $selectedServer) {
|
||||
ProtocolServerView(
|
||||
serverProtocol: serverProtocol,
|
||||
server: server,
|
||||
serverToEdit: srv
|
||||
)
|
||||
.navigationBarTitle(srv.preset ? "Preset server" : "Your server")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
let address = parseServerAddress(srv.server)
|
||||
HStack {
|
||||
Group {
|
||||
if let address = address {
|
||||
if !address.valid || address.serverProtocol != serverProtocol {
|
||||
invalidServer()
|
||||
} else if !uniqueAddress(srv, address) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if !srv.enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
showTestStatus(server: srv)
|
||||
}
|
||||
} else {
|
||||
invalidServer()
|
||||
}
|
||||
}
|
||||
.frame(width: 16, alignment: .center)
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let v = Text(address?.hostnames.first ?? srv.server).lineLimit(1)
|
||||
if srv.enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func howToButton() -> some View {
|
||||
Button {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(howToUrl)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text("How to use your servers")
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidServer() -> some View {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
|
||||
private func uniqueAddress(_ s: ServerCfg, _ address: ServerAddress) -> Bool {
|
||||
allServers.allSatisfy { srv in
|
||||
address.hostnames.allSatisfy { host in
|
||||
srv.id == s.id || !srv.server.contains(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasAllPresets() -> Bool {
|
||||
presetServers.allSatisfy { hasPreset($0) }
|
||||
}
|
||||
|
||||
private func addAllPresets() {
|
||||
for srv in presetServers {
|
||||
if !hasPreset(srv) {
|
||||
configuredServers.append(srv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hasPreset(_ srv: ServerCfg) -> Bool {
|
||||
allServers.contains(where: { $0.server == srv.server })
|
||||
}
|
||||
|
||||
private func testServers() {
|
||||
resetTestStatus()
|
||||
testing = true
|
||||
Task {
|
||||
let fs = await runServersTest()
|
||||
await MainActor.run {
|
||||
testing = false
|
||||
if !fs.isEmpty {
|
||||
alert = .testsFailed(failures: fs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
configuredServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
otherServers[i].tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runServersTest() async -> [String: ProtocolTestFailure] {
|
||||
var fs: [String: ProtocolTestFailure] = [:]
|
||||
for i in 0..<configuredServers.count {
|
||||
if configuredServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $configuredServers[i]) {
|
||||
fs[serverHostname(configuredServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..<otherServers.count {
|
||||
if otherServers[i].enabled {
|
||||
if let f = await testServerConnection(server: $otherServers[i]) {
|
||||
fs[serverHostname(otherServers[i].server)] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func saveServers() {
|
||||
Task {
|
||||
do {
|
||||
try await setUserProtoServers(serverProtocol, servers: allServers)
|
||||
await MainActor.run {
|
||||
currServers = allServers
|
||||
editMode?.wrappedValue = .inactive
|
||||
}
|
||||
} catch let error {
|
||||
let err = responseError(error)
|
||||
logger.error("saveServers setUserProtocolServers error: \(err)")
|
||||
await MainActor.run {
|
||||
alert = .error(
|
||||
title: "Error saving \(proto) servers",
|
||||
error: "Make sure \(proto) server addresses are in correct format, line separated and are not duplicated (\(responseError(error)))."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProtocolServersView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServersView(serverProtocol: .smp)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
|
||||
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
|
||||
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
|
||||
let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration"
|
||||
let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings"
|
||||
let DEFAULT_DEVELOPER_TOOLS = "developerTools"
|
||||
let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted"
|
||||
let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt"
|
||||
@@ -50,6 +51,7 @@ let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
|
||||
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
|
||||
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
|
||||
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
|
||||
let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"
|
||||
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
|
||||
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
|
||||
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
|
||||
@@ -107,6 +109,7 @@ let appDefaults: [String: Any] = [
|
||||
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
|
||||
DEFAULT_CHAT_ITEM_TAIL: true,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false,
|
||||
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
|
||||
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
|
||||
@@ -135,6 +138,7 @@ let appDefaults: [String: Any] = [
|
||||
let hintDefaults = [
|
||||
DEFAULT_LA_NOTICE_SHOWN,
|
||||
DEFAULT_ONE_HAND_UI_CARD_SHOWN,
|
||||
DEFAULT_ADDRESS_CREATION_CARD_SHOWN,
|
||||
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN,
|
||||
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE,
|
||||
DEFAULT_SHOW_MUTE_PROFILE_ALERT,
|
||||
@@ -189,6 +193,8 @@ let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.sta
|
||||
let showDeleteConversationNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE)
|
||||
let showDeleteContactNoticeDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOW_DELETE_CONTACT_NOTICE)
|
||||
|
||||
/// after importing new database, this flag will be set and unset only after importing app settings in `initializeChat` */
|
||||
let shouldImportAppSettingsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_IMPORT_APP_SETTINGS)
|
||||
let currentThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME, withDefault: DefaultTheme.SYSTEM_THEME_NAME)
|
||||
let systemDarkThemeDefault = StringDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SYSTEM_DARK_THEME, withDefault: DefaultTheme.DARK.themeName)
|
||||
let currentThemeIdsDefault = CodableDefault<[String: String]>(defaults: UserDefaults.standard, forKey: DEFAULT_CURRENT_THEME_IDS, withDefault: [:] )
|
||||
@@ -356,7 +362,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
WhatsNewView(viaSettings: true)
|
||||
WhatsNewView(viaSettings: true, updatedConditions: false)
|
||||
.modifier(ThemedBackground())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
|
||||
@@ -9,15 +9,94 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserAddressLearnMore: View {
|
||||
@State var showCreateAddressButton = false
|
||||
@State private var createAddressLinkActive = false
|
||||
@State private var createOneTimeLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text("You can share your address as a link or QR code - anybody can connect to you.")
|
||||
Text("You won't lose your contacts if you later delete your address.")
|
||||
Text("When people request to connect, you can accept or reject it.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/app-settings.html#your-simplex-contact-address).")
|
||||
VStack {
|
||||
List {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
(Text(Image(systemName: "envelope")).foregroundColor(.secondary) + textSpace + Text("Share address publicly").bold().font(.title2))
|
||||
Text("Share SimpleX address on social media.")
|
||||
Text("You won't lose your contacts if you later delete your address.")
|
||||
|
||||
(Text(Image(systemName: "link.badge.plus")).foregroundColor(.secondary) + textSpace + Text("Share 1-time link with a friend").font(.title2).bold())
|
||||
.padding(.top)
|
||||
Text("1-time link can be used *with one contact only* - share in person or via any messenger.")
|
||||
Text("You can set connection name, to remember who the link was shared with.")
|
||||
|
||||
if !showCreateAddressButton {
|
||||
(Text(Image(systemName: "shield")).foregroundColor(.secondary) + textSpace + Text("Connection security").font(.title2).bold())
|
||||
.padding(.top)
|
||||
Text("SimpleX address and 1-time links are safe to share via any messenger.")
|
||||
Text("To protect against your link being replaced, you can compare contact security codes.")
|
||||
Text("Read more in [User Guide](https://simplex.chat/docs/guide/making-connections.html#comparison-of-1-time-invitation-links-and-simplex-contact-addresses).")
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
|
||||
Spacer()
|
||||
|
||||
if showCreateAddressButton {
|
||||
VStack {
|
||||
addressCreationButton()
|
||||
.padding(.bottom)
|
||||
|
||||
createOneTimeLinkButton()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
private func addressCreationButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createAddressLinkActive = true
|
||||
} label: {
|
||||
Text("Create SimpleX address")
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
|
||||
NavigationLink(isActive: $createAddressLinkActive) {
|
||||
UserAddressView(autoCreate: true)
|
||||
.navigationTitle("SimpleX address")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func createOneTimeLinkButton() -> some View {
|
||||
ZStack {
|
||||
Button {
|
||||
createOneTimeLinkActive = true
|
||||
} label: {
|
||||
Text("Create 1-time link")
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $createOneTimeLinkActive) {
|
||||
NewChatView(selection: .invite)
|
||||
.navigationTitle("New chat")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,45 +15,31 @@ struct UserAddressView: View {
|
||||
@EnvironmentObject private var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State var shareViaProfile = false
|
||||
@State var autoCreate = false
|
||||
@State private var aas = AutoAcceptState()
|
||||
@State private var savedAAS = AutoAcceptState()
|
||||
@State private var ignoreShareViaProfileChange = false
|
||||
@State private var showMailView = false
|
||||
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
|
||||
@State private var alert: UserAddressAlert?
|
||||
@State private var progressIndicator = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
private enum UserAddressAlert: Identifiable {
|
||||
case deleteAddress
|
||||
case profileAddress(on: Bool)
|
||||
case shareOnCreate
|
||||
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .deleteAddress: return "deleteAddress"
|
||||
case let .profileAddress(on): return "profileAddress \(on)"
|
||||
case .shareOnCreate: return "shareOnCreate"
|
||||
case let .error(title, _): return "error \(title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
userAddressScrollView()
|
||||
.onDisappear {
|
||||
if savedAAS != aas {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
|
||||
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: saveAAS,
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
userAddressView()
|
||||
|
||||
if progressIndicator {
|
||||
ZStack {
|
||||
@@ -67,22 +53,10 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Namespace private var bottomID
|
||||
|
||||
private func userAddressScrollView() -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
userAddressView()
|
||||
.onChange(of: keyboardVisible) { _ in
|
||||
if keyboardVisible {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation {
|
||||
proxy.scrollTo(bottomID, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatModel.userAddress == nil, autoCreate {
|
||||
createAddress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +68,18 @@ struct UserAddressView: View {
|
||||
aas = AutoAcceptState(userAddress: userAddress)
|
||||
savedAAS = aas
|
||||
}
|
||||
.onChange(of: aas.enable) { _ in
|
||||
if !aas.enable { aas = AutoAcceptState() }
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
createAddressButton()
|
||||
} footer: {
|
||||
Text("Create an address to let people connect with you.")
|
||||
} header: {
|
||||
Text("For social media")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
createOneTimeLinkButton()
|
||||
} header: {
|
||||
Text("Or to share privately")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
@@ -117,8 +95,8 @@ struct UserAddressView: View {
|
||||
title: Text("Delete address?"),
|
||||
message:
|
||||
shareViaProfile
|
||||
? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
|
||||
: Text("All your contacts will remain connected."),
|
||||
? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
|
||||
: Text("All your contacts will remain connected."),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
@@ -128,7 +106,6 @@ struct UserAddressView: View {
|
||||
chatModel.userAddress = nil
|
||||
chatModel.updateUser(u)
|
||||
if shareViaProfile {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = false
|
||||
}
|
||||
}
|
||||
@@ -141,37 +118,12 @@ struct UserAddressView: View {
|
||||
}
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
case let .profileAddress(on):
|
||||
if on {
|
||||
return Alert(
|
||||
title: Text("Share address with contacts?"),
|
||||
message: Text("Profile update will be sent to your contacts."),
|
||||
primaryButton: .default(Text("Share")) {
|
||||
setProfileAddress(on)
|
||||
}, secondaryButton: .cancel() {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
)
|
||||
} else {
|
||||
return Alert(
|
||||
title: Text("Stop sharing address?"),
|
||||
message: Text("Profile update will be sent to your contacts."),
|
||||
primaryButton: .default(Text("Stop sharing")) {
|
||||
setProfileAddress(on)
|
||||
}, secondaryButton: .cancel() {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
)
|
||||
}
|
||||
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."),
|
||||
primaryButton: .default(Text("Share")) {
|
||||
setProfileAddress(true)
|
||||
ignoreShareViaProfileChange = true
|
||||
setProfileAddress($progressIndicator, true)
|
||||
shareViaProfile = true
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
@@ -186,19 +138,39 @@ struct UserAddressView: View {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
|
||||
shareQRCodeButton(userAddress)
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
// if MFMailComposeViewController.canSendMail() {
|
||||
// shareViaEmailButton(userAddress)
|
||||
// }
|
||||
settingsRow("briefcase", color: theme.colors.secondary) {
|
||||
Toggle("Business address", isOn: $aas.business)
|
||||
.onChange(of: aas.business) { ba in
|
||||
if ba {
|
||||
aas.enable = true
|
||||
aas.incognito = false
|
||||
}
|
||||
saveAAS($aas, $savedAAS)
|
||||
}
|
||||
}
|
||||
shareWithContactsButton()
|
||||
autoAcceptToggle()
|
||||
learnMoreButton()
|
||||
addressSettingsButton(userAddress)
|
||||
} header: {
|
||||
Text("Address")
|
||||
Text("For social media")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if aas.business {
|
||||
Text("Add your team members to the conversations.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
createOneTimeLinkButton()
|
||||
} header: {
|
||||
Text("Or to share privately")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
if aas.enable {
|
||||
autoAcceptSection()
|
||||
Section {
|
||||
learnMoreButton()
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -207,32 +179,47 @@ struct UserAddressView: View {
|
||||
Text("Your contacts will remain connected.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.id(bottomID)
|
||||
}
|
||||
|
||||
private func createAddressButton() -> some View {
|
||||
Button {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let connReqContact = try await apiCreateUserAddress()
|
||||
DispatchQueue.main.async {
|
||||
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating address")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
await MainActor.run { progressIndicator = false }
|
||||
}
|
||||
}
|
||||
createAddress()
|
||||
} label: {
|
||||
Label("Create SimpleX address", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
private func createAddress() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
let connReqContact = try await apiCreateUserAddress()
|
||||
DispatchQueue.main.async {
|
||||
chatModel.userAddress = UserContactLink(connReqContact: connReqContact)
|
||||
alert = .shareOnCreate
|
||||
progressIndicator = false
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
|
||||
let a = getErrorAlert(error, "Error creating address")
|
||||
alert = .error(title: a.title, error: a.message)
|
||||
await MainActor.run { progressIndicator = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createOneTimeLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
NewChatView(selection: .invite)
|
||||
.navigationTitle("New chat")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Label("Create 1-time link", systemImage: "link.badge.plus")
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAddressButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteAddress
|
||||
@@ -282,24 +269,140 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func autoAcceptToggle() -> some View {
|
||||
settingsRow("checkmark", color: theme.colors.secondary) {
|
||||
Toggle("Auto-accept", isOn: $aas.enable)
|
||||
.onChange(of: aas.enable) { _ in
|
||||
saveAAS()
|
||||
}
|
||||
private func addressSettingsButton(_ userAddress: UserContactLink) -> some View {
|
||||
NavigationLink {
|
||||
UserAddressSettingsView(shareViaProfile: $shareViaProfile)
|
||||
.navigationTitle("Address settings")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
Text("Address settings")
|
||||
}
|
||||
}
|
||||
|
||||
private func learnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
UserAddressLearnMore()
|
||||
.navigationTitle("SimpleX address")
|
||||
.navigationTitle("Address or 1-time link?")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
settingsRow("info.circle", color: theme.colors.secondary) {
|
||||
Text("About SimpleX address")
|
||||
Text("SimpleX address or 1-time link?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoAcceptState: Equatable {
|
||||
var enable = false
|
||||
var incognito = false
|
||||
var business = false
|
||||
var welcomeText = ""
|
||||
|
||||
init(enable: Bool = false, incognito: Bool = false, business: Bool = false, welcomeText: String = "") {
|
||||
self.enable = enable
|
||||
self.incognito = incognito
|
||||
self.business = business
|
||||
self.welcomeText = welcomeText
|
||||
}
|
||||
|
||||
init(userAddress: UserContactLink) {
|
||||
if let aa = userAddress.autoAccept {
|
||||
enable = true
|
||||
incognito = aa.acceptIncognito
|
||||
business = aa.businessAddress
|
||||
if let msg = aa.autoReply {
|
||||
welcomeText = msg.text
|
||||
} else {
|
||||
welcomeText = ""
|
||||
}
|
||||
} else {
|
||||
enable = false
|
||||
incognito = false
|
||||
business = false
|
||||
welcomeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
var autoAccept: AutoAccept? {
|
||||
if enable {
|
||||
var autoReply: MsgContent? = nil
|
||||
let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s != "" { autoReply = .text(s) }
|
||||
return AutoAccept(businessAddress: business, acceptIncognito: incognito, autoReply: autoReply)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool) {
|
||||
progressIndicator.wrappedValue = true
|
||||
Task {
|
||||
do {
|
||||
if let u = try await apiSetProfileAddress(on: on) {
|
||||
DispatchQueue.main.async {
|
||||
ChatModel.shared.updateUser(u)
|
||||
}
|
||||
}
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
} catch let error {
|
||||
logger.error("apiSetProfileAddress: \(responseError(error))")
|
||||
await MainActor.run { progressIndicator.wrappedValue = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserAddressSettingsView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var shareViaProfile: Bool
|
||||
@State private var aas = AutoAcceptState()
|
||||
@State private var savedAAS = AutoAcceptState()
|
||||
@State private var ignoreShareViaProfileChange = false
|
||||
@State private var progressIndicator = false
|
||||
@FocusState private var keyboardVisible: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let userAddress = ChatModel.shared.userAddress {
|
||||
userAddressSettingsView()
|
||||
.onAppear {
|
||||
aas = AutoAcceptState(userAddress: userAddress)
|
||||
savedAAS = aas
|
||||
}
|
||||
.onChange(of: aas.enable) { aasEnabled in
|
||||
if !aasEnabled { aas = AutoAcceptState() }
|
||||
}
|
||||
.onDisappear {
|
||||
if savedAAS != aas {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
|
||||
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
|
||||
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
|
||||
buttonAction: { saveAAS($aas, $savedAAS) },
|
||||
cancelButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(String("Error opening address settings"))
|
||||
}
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func userAddressSettingsView() -> some View {
|
||||
List {
|
||||
Section {
|
||||
shareWithContactsButton()
|
||||
autoAcceptToggle().disabled(aas.business)
|
||||
}
|
||||
|
||||
if aas.enable {
|
||||
autoAcceptSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,70 +414,70 @@ struct UserAddressView: View {
|
||||
if ignoreShareViaProfileChange {
|
||||
ignoreShareViaProfileChange = false
|
||||
} else {
|
||||
alert = .profileAddress(on: on)
|
||||
if on {
|
||||
showAlert(
|
||||
NSLocalizedString("Share address with contacts?", comment: "alert title"),
|
||||
message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Cancel", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Share", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Stop sharing address?", comment: "alert title"),
|
||||
message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Cancel", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = !on
|
||||
}
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Stop sharing", comment: "alert action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
setProfileAddress($progressIndicator, on)
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setProfileAddress(_ on: Bool) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
if let u = try await apiSetProfileAddress(on: on) {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.updateUser(u)
|
||||
}
|
||||
private func autoAcceptToggle() -> some View {
|
||||
settingsRow("checkmark", color: theme.colors.secondary) {
|
||||
Toggle("Auto-accept", isOn: $aas.enable)
|
||||
.onChange(of: aas.enable) { _ in
|
||||
saveAAS($aas, $savedAAS)
|
||||
}
|
||||
await MainActor.run { progressIndicator = false }
|
||||
} catch let error {
|
||||
logger.error("UserAddressView apiSetProfileAddress: \(responseError(error))")
|
||||
await MainActor.run { progressIndicator = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoAcceptState: Equatable {
|
||||
var enable = false
|
||||
var incognito = false
|
||||
var welcomeText = ""
|
||||
|
||||
init(enable: Bool = false, incognito: Bool = false, welcomeText: String = "") {
|
||||
self.enable = enable
|
||||
self.incognito = incognito
|
||||
self.welcomeText = welcomeText
|
||||
}
|
||||
|
||||
init(userAddress: UserContactLink) {
|
||||
if let aa = userAddress.autoAccept {
|
||||
enable = true
|
||||
incognito = aa.acceptIncognito
|
||||
if let msg = aa.autoReply {
|
||||
welcomeText = msg.text
|
||||
} else {
|
||||
welcomeText = ""
|
||||
}
|
||||
} else {
|
||||
enable = false
|
||||
incognito = false
|
||||
welcomeText = ""
|
||||
}
|
||||
}
|
||||
|
||||
var autoAccept: AutoAccept? {
|
||||
if enable {
|
||||
var autoReply: MsgContent? = nil
|
||||
let s = welcomeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s != "" { autoReply = .text(s) }
|
||||
return AutoAccept(acceptIncognito: incognito, autoReply: autoReply)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func autoAcceptSection() -> some View {
|
||||
private func autoAcceptSection() -> some View {
|
||||
Section {
|
||||
acceptIncognitoToggle()
|
||||
if !aas.business {
|
||||
acceptIncognitoToggle()
|
||||
}
|
||||
welcomeMessageEditor()
|
||||
saveAASButton()
|
||||
.disabled(aas == savedAAS)
|
||||
@@ -414,22 +517,24 @@ struct UserAddressView: View {
|
||||
private func saveAASButton() -> some View {
|
||||
Button {
|
||||
keyboardVisible = false
|
||||
saveAAS()
|
||||
saveAAS($aas, $savedAAS)
|
||||
} label: {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAAS() {
|
||||
Task {
|
||||
do {
|
||||
if let address = try await userAddressAutoAccept(aas.autoAccept) {
|
||||
chatModel.userAddress = address
|
||||
savedAAS = aas
|
||||
private func saveAAS(_ aas: Binding<AutoAcceptState>, _ savedAAS: Binding<AutoAcceptState>) {
|
||||
Task {
|
||||
do {
|
||||
if let address = try await userAddressAutoAccept(aas.wrappedValue.autoAccept) {
|
||||
await MainActor.run {
|
||||
ChatModel.shared.userAddress = address
|
||||
savedAAS.wrappedValue = aas.wrappedValue
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("userAddressAutoAccept error: \(responseError(error))")
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("userAddressAutoAccept error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ struct UserProfilesView: View {
|
||||
action()
|
||||
} else {
|
||||
authenticate(
|
||||
reason: NSLocalizedString("Change user profiles", comment: "authentication reason")
|
||||
reason: NSLocalizedString("Change chat profiles", comment: "authentication reason")
|
||||
) { laResult in
|
||||
switch laResult {
|
||||
case .success, .unavailable:
|
||||
|
||||
@@ -187,23 +187,18 @@
|
||||
<target state="needs-translation">)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve" approved="no">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<target state="translated">** إضافة جهة اتصال جديدة **: لإنشاء رمز QR لمرة واحدة أو رابط جهة الاتصال الخاصة بكم.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<target state="translated">** أنشئ رابطًا / رمز QR ** لتستخدمه جهة الاتصال الخاصة بك.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<target state="translated">** المزيد من الخصوصية **: تحققوا من الرسائل الجديدة كل 20 دقيقة. تتم مشاركة رمز الجهاز مع خادم SimpleX Chat ، ولكن ليس عدد جهات الاتصال أو الرسائل لديكم.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<target state="translated">** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامكم للتطبيق).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -217,8 +212,8 @@
|
||||
<target state="translated">** يرجى ملاحظة **: لن تتمكنوا من استعادة أو تغيير عبارة المرور إذا فقدتموها.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<target state="translated">** موصى به **: يتم إرسال رمز الجهاز والإشعارات إلى خادم إشعارات SimpleX Chat ، ولكن ليس محتوى الرسالة أو حجمها أو مصدرها.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1048,8 +1043,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1064,8 +1059,8 @@
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1428,16 +1423,16 @@
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1528,8 +1523,8 @@
|
||||
<source>Image will be received when your contact is online, please wait or check later!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1625,8 +1620,8 @@
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
@@ -1926,8 +1921,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Onion hosts will not be used.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -1978,8 +1973,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." xml:space="preserve">
|
||||
@@ -2010,8 +2005,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2590,8 +2585,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The app can notify you when you receive messages or contact requests - please open settings to enable." xml:space="preserve">
|
||||
@@ -2622,8 +2617,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>The microphone does not work when the app is in the background.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -2686,8 +2681,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>To prevent the call interruption, enable Do Not Disturb mode.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -2860,8 +2855,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -2972,10 +2967,6 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>You can use markdown to format messages:</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -3752,8 +3743,8 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>%u messages skipped.</source>
|
||||
<target state="translated">%u تم تخطي الرسائل.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add contact**: to create a new invitation link, or connect via a link you received." xml:space="preserve" approved="no">
|
||||
<source>**Add contact**: to create a new invitation link, or connect via a link you received.</source>
|
||||
<trans-unit id="**Create 1-time link**: to create and share a new invitation link." xml:space="preserve" approved="no">
|
||||
<source>**Create 1-time link**: to create and share a new invitation link.</source>
|
||||
<target state="translated">**إضافة جهة اتصال**: لإنشاء رابط دعوة جديد، أو الاتصال عبر الرابط الذي تلقيتوهم.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create group**: to create a new group." xml:space="preserve" approved="no">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -193,20 +193,16 @@
|
||||
<source>)</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Paste received link** or open it in the browser and tap **Open in mobile app**." xml:space="preserve">
|
||||
@@ -217,8 +213,8 @@
|
||||
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Scan QR code**: to connect to your contact in person or via video call." xml:space="preserve">
|
||||
@@ -1251,8 +1247,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1271,8 +1267,8 @@
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappears at" xml:space="preserve">
|
||||
@@ -1751,24 +1747,24 @@
|
||||
<source>Group links</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can add message reactions." xml:space="preserve">
|
||||
<source>Group members can add message reactions.</source>
|
||||
<trans-unit id="Members can add message reactions." xml:space="preserve">
|
||||
<source>Members can add message reactions.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can irreversibly delete sent messages." xml:space="preserve">
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1899,8 +1895,8 @@
|
||||
<source>Immediately</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -2020,8 +2016,8 @@
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
@@ -2207,8 +2203,8 @@
|
||||
<source>Message reactions are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message reactions are prohibited in this group." xml:space="preserve">
|
||||
<source>Message reactions are prohibited in this group.</source>
|
||||
<trans-unit id="Message reactions are prohibited." xml:space="preserve">
|
||||
<source>Message reactions are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message text" xml:space="preserve">
|
||||
@@ -2239,8 +2235,8 @@
|
||||
<source>Migration is completed</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Migrations: %@" xml:space="preserve">
|
||||
<source>Migrations: %@</source>
|
||||
<trans-unit id="Migrations:" xml:space="preserve">
|
||||
<source>Migrations:</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Moderate" xml:space="preserve">
|
||||
@@ -2409,8 +2405,8 @@
|
||||
<source>Onion hosts will not be used.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2477,8 +2473,8 @@
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening database…" xml:space="preserve">
|
||||
@@ -2537,8 +2533,8 @@
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -3373,8 +3369,8 @@
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised." xml:space="preserve">
|
||||
@@ -3418,8 +3414,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>The message will be marked as moderated for all members.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -3490,8 +3486,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>To make a new connection</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3724,8 +3720,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -3876,10 +3872,6 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>You can't send messages!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -186,20 +186,16 @@ Available in v5.1</source>
|
||||
<source>)</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Paste received link** or open it in the browser and tap **Open in mobile app**." xml:space="preserve">
|
||||
@@ -210,8 +206,8 @@ Available in v5.1</source>
|
||||
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Scan QR code**: to connect to your contact in person or via video call." xml:space="preserve">
|
||||
@@ -1128,8 +1124,8 @@ Available in v5.1</source>
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1144,8 +1140,8 @@ Available in v5.1</source>
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1580,16 +1576,16 @@ Available in v5.1</source>
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1708,8 +1704,8 @@ Available in v5.1</source>
|
||||
<source>Immediately</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1821,8 +1817,8 @@ Available in v5.1</source>
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
@@ -2016,8 +2012,8 @@ Available in v5.1</source>
|
||||
<source>Migration is completed</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Migrations: %@" xml:space="preserve">
|
||||
<source>Migrations: %@</source>
|
||||
<trans-unit id="Migrations:" xml:space="preserve">
|
||||
<source>Migrations:</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Moderate" xml:space="preserve">
|
||||
@@ -2174,8 +2170,8 @@ Available in v5.1</source>
|
||||
<source>Onion hosts will not be used.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2234,8 +2230,8 @@ Available in v5.1</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." xml:space="preserve">
|
||||
@@ -2290,8 +2286,8 @@ Available in v5.1</source>
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2994,8 +2990,8 @@ Available in v5.1</source>
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised." xml:space="preserve">
|
||||
@@ -3039,8 +3035,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>The message will be marked as moderated for all members.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -3111,8 +3107,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>To make a new connection</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3337,8 +3333,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -3478,10 +3474,6 @@ SimpleX Lock must be enabled.</source>
|
||||
<source>You can't send messages!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -217,23 +217,18 @@ Available in v5.1</source>
|
||||
<target state="translated">)</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve" approved="no">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<target state="translated">**הוסיפו איש קשר חדש**: ליצירת קוד QR או קישור חד־פעמיים עבור איש הקשר שלכם.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<target state="translated">**צור קישור / קוד QR** לשימוש איש הקשר שלך.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<target state="translated">**יותר פרטי**: בדוק הודעות חדשות כל 20 דקות. אסימון המכשיר משותף עם שרת SimpleX Chat, אך לא כמה אנשי קשר או הודעות יש לך.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<target state="translated">**הכי פרטי**: אל תשתמש בשרת ההתראות של SimpleX Chat, בדוק הודעות מעת לעת ברקע (תלוי בתדירות השימוש באפליקציה).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -247,8 +242,8 @@ Available in v5.1</source>
|
||||
<target state="translated">**שימו לב**: לא ניתן יהיה לשחזר או לשנות את הסיסמה אם תאבדו אותה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<target state="translated">**מומלץ**: אסימון מכשיר והתראות נשלחים לשרת ההתראות של SimpleX Chat, אך לא תוכן ההודעה, גודלה או ממי היא.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1391,8 +1386,8 @@ Available in v5.1</source>
|
||||
<target state="translated">הודעות ישירות</target>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve" approved="no">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<target state="translated">הודעות ישירות בין חברי קבוצה אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1411,8 +1406,8 @@ Available in v5.1</source>
|
||||
<target state="translated">הודעות נעלמות אסורות בצ׳אט זה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve" approved="no">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<target state="translated">הודעות נעלמות אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1956,18 +1951,18 @@ Available in v5.1</source>
|
||||
<target state="translated">חברי הקבוצה יכולים למחוק באופן בלתי הפיך הודעות שנשלחו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send direct messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות ישירות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות נעלמות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send voice messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות קוליות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2115,8 +2110,8 @@ Available in v5.1</source>
|
||||
<target state="translated">מיד</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve" approved="no">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve" approved="no">
|
||||
<source>Immune to spam</source>
|
||||
<target state="translated">חסין מפני ספאם ושימוש לרעה</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2257,8 +2252,8 @@ Available in v5.1</source>
|
||||
<target state="translated">מחיקה בלתי הפיכה של הודעות אסורה בצ׳אט זה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve" approved="no">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<target state="translated">מחיקה בלתי הפיכה של הודעות אסורה בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2502,9 +2497,9 @@ Available in v5.1</source>
|
||||
<target state="translated">ההעברה הושלמה</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Migrations: %@" xml:space="preserve" approved="no">
|
||||
<source>Migrations: %@</source>
|
||||
<target state="translated">העברות: %@</target>
|
||||
<trans-unit id="Migrations:" xml:space="preserve" approved="no">
|
||||
<source>Migrations:</source>
|
||||
<target state="translated">העברות:</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Moderate" xml:space="preserve" approved="no">
|
||||
@@ -2701,8 +2696,8 @@ Available in v5.1</source>
|
||||
<target state="translated">לא ייעשה שימוש במארחי Onion.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2761,8 +2756,8 @@ Available in v5.1</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." xml:space="preserve">
|
||||
@@ -2817,8 +2812,8 @@ Available in v5.1</source>
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -3521,8 +3516,8 @@ Available in v5.1</source>
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The ID of the next message is incorrect (less or equal to the previous). It can happen because of some bug or when the connection is compromised." xml:space="preserve">
|
||||
@@ -3566,8 +3561,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>The message will be marked as moderated for all members.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -3638,8 +3633,8 @@ It can happen because of some bug or when the connection is compromised.</source
|
||||
<source>To make a new connection</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3864,8 +3859,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -4005,10 +4000,6 @@ SimpleX Lock must be enabled.</source>
|
||||
<source>You can't send messages!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
@@ -4967,8 +4958,8 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">נמחק</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Files and media are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Files and media are prohibited in this group.</source>
|
||||
<trans-unit id="Files and media are prohibited." xml:space="preserve" approved="no">
|
||||
<source>Files and media are prohibited.</source>
|
||||
<target state="translated">קבצים ומדיה אסורים בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -5027,13 +5018,13 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">הזמן חברים</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can add message reactions." xml:space="preserve" approved="no">
|
||||
<source>Group members can add message reactions.</source>
|
||||
<trans-unit id="Members can add message reactions." xml:space="preserve" approved="no">
|
||||
<source>Members can add message reactions.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים להוסיף תגובות אמוג׳י להודעות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send files and media." xml:space="preserve" approved="no">
|
||||
<source>Group members can send files and media.</source>
|
||||
<trans-unit id="Members can send files and media." xml:space="preserve" approved="no">
|
||||
<source>Members can send files and media.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח קבצים ומדיה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -5231,8 +5222,8 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">תגובות אמוג׳י להודעות אסורות בצ׳אט זה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message reactions are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Message reactions are prohibited in this group.</source>
|
||||
<trans-unit id="Message reactions are prohibited." xml:space="preserve" approved="no">
|
||||
<source>Message reactions are prohibited.</source>
|
||||
<target state="translated">תגובות אמוג׳י להודעות אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
|
||||
@@ -181,23 +181,18 @@
|
||||
<source>)</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve" approved="no">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<target state="needs-translation">**Dodajte novi kontakt**: da biste stvorili svoj jednokratni QR kôd ili vezu za svoj kontakt.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve" approved="no">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<target state="needs-translation">**Stvorite vezu / QR kôd** za vaš kontakt.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve" approved="no">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<target state="needs-translation">**Privatnije**: provjeravajte nove poruke svakih 20 minuta. Token uređaja dijeli se s SimpleX Chat poslužiteljem, ali ne i s brojem kontakata ili poruka koje imate.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve" approved="no">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<target state="needs-translation">**Najprivatniji**: nemojte koristiti SimpleX Chat poslužitelj obavijesti, povremeno provjeravajte poruke u pozadini (ovisi o tome koliko često koristite aplikaciju).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -211,8 +206,8 @@
|
||||
<target state="needs-translation">**Imajte na umu**: NEĆETE moći oporaviti ili promijeniti pristupni izraz ako ga izgubite.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve" approved="no">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<target state="needs-translation">**Preporučeno**: token uređaja i obavijesti šalju se na poslužitelj obavijesti SimpleX Chata, ali ne i sadržaj poruke, veličinu ili od koga je.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1039,8 +1034,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1055,8 +1050,8 @@
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1419,16 +1414,16 @@
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1519,8 +1514,8 @@
|
||||
<source>Image will be received when your contact is online, please wait or check later!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1616,8 +1611,8 @@
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
@@ -1917,8 +1912,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Onion hosts will not be used.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -1965,8 +1960,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Open chat console</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." xml:space="preserve">
|
||||
@@ -1997,8 +1992,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2577,8 +2572,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The app can notify you when you receive messages or contact requests - please open settings to enable." xml:space="preserve">
|
||||
@@ -2609,8 +2604,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>The microphone does not work when the app is in the background.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -2673,8 +2668,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>To prevent the call interruption, enable Do Not Disturb mode.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -2847,8 +2842,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -2959,10 +2954,6 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>You can use markdown to format messages:</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
||||
/* No comment provided by engineer. */
|
||||
"_italic_" = "\\_italic_";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"**Add new contact**: to create your one-time QR Code for your contact." = "**Add new contact**: to create your one-time QR Code or link for your contact.";
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"*bold*" = "\\*bold*";
|
||||
|
||||
@@ -27,4 +24,3 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -152,20 +152,16 @@
|
||||
<source>)</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Add new contact**: to create your one-time QR Code for your contact." xml:space="preserve">
|
||||
<source>**Add new contact**: to create your one-time QR Code or link for your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Create link / QR code** for your contact to use." xml:space="preserve">
|
||||
<source>**Create link / QR code** for your contact to use.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have.</source>
|
||||
<trans-unit id="**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." xml:space="preserve">
|
||||
<source>**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app).</source>
|
||||
<trans-unit id="**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app." xml:space="preserve">
|
||||
<source>**Most private**: do not use SimpleX Chat push server. The app will check messages in background, when the system allows it, depending on how often you use the app.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Paste received link** or open it in the browser and tap **Open in mobile app**." xml:space="preserve">
|
||||
@@ -176,8 +172,8 @@
|
||||
<source>**Please note**: you will NOT be able to recover or change passphrase if you lose it.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who it is from.</source>
|
||||
<trans-unit id="**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." xml:space="preserve">
|
||||
<source>**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="**Scan QR code**: to connect to your contact in person or via video call." xml:space="preserve">
|
||||
@@ -1013,8 +1009,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited in this group." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited in this group.</source>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1029,8 +1025,8 @@
|
||||
<source>Disappearing messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1421,16 +1417,16 @@
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1537,8 +1533,8 @@
|
||||
<source>Image will be received when your contact is online, please wait or check later!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1642,8 +1638,8 @@
|
||||
<source>Irreversible message deletion is prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="It allows having many anonymous connections without any shared data between them in a single chat profile." xml:space="preserve">
|
||||
@@ -1961,8 +1957,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Onion hosts will not be used.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.</source>
|
||||
<trans-unit id="Only client devices store user profiles, contacts, groups, and messages." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2013,8 +2009,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Open-source protocol and code – anybody can run the servers." xml:space="preserve">
|
||||
<source>Open-source protocol and code – anybody can run the servers.</source>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red." xml:space="preserve">
|
||||
@@ -2049,8 +2045,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Paste the link you received into the box below to connect with your contact.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="People can connect to you only via the links you share." xml:space="preserve">
|
||||
<source>People can connect to you only via the links you share.</source>
|
||||
<trans-unit id="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2670,8 +2666,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The 1st platform without any user identifiers – private by design." xml:space="preserve">
|
||||
<source>The 1st platform without any user identifiers – private by design.</source>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The app can notify you when you receive messages or contact requests - please open settings to enable." xml:space="preserve">
|
||||
@@ -2706,8 +2702,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>The message will be marked as moderated for all members.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private messaging</source>
|
||||
<trans-unit id="The future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="The old database was not removed during the migration, it can be deleted." xml:space="preserve">
|
||||
@@ -2774,8 +2770,8 @@ We will be adding server redundancy to prevent lost messages.</source>
|
||||
<source>To make a new connection</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts." xml:space="preserve">
|
||||
<source>To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.</source>
|
||||
<trans-unit id="To protect your privacy, SimpleX uses separate IDs for each of your contacts." xml:space="preserve">
|
||||
<source>To protect your privacy, SimpleX uses separate IDs for each of your contacts.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -2968,8 +2964,8 @@ To connect, please ask your contact to create another connection link and check
|
||||
<source>Voice messages are prohibited in this chat.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<trans-unit id="Voice messages are prohibited." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -3093,10 +3089,6 @@ SimpleX Lock must be enabled.</source>
|
||||
<source>You can't send messages!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them." xml:space="preserve">
|
||||
<source>You control through which server(s) **to receive** the messages, your contacts – the servers you use to message them.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="You could not be verified; please try again." xml:space="preserve">
|
||||
<source>You could not be verified; please try again.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user