mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08db2852f3 | |||
| 7d23ed3086 | |||
| 9e1c543c51 | |||
| c52065b5ef | |||
| c0e673d63f |
+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@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> 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 "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> 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 " 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 " 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@3.0/include" >> cabal.project.local
|
||||
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> 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 "" >> cabal.project.local
|
||||
echo "package direct-sqlcipher" >> 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 " 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 " 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 openssl for Mac
|
||||
- name: Install pkg-config for Mac
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
|
||||
run: brew install openssl@3.0
|
||||
run: brew install pkg-config
|
||||
|
||||
- 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-3.0.15 | sed 's#/\([a-zA-Z]\)#\1:#' | sed 's#/#\\#g')
|
||||
openssl_windows_style_path=$(echo `pwd`/dist-newstyle/openssl-1.1.1w | 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,8 +233,6 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
Recent and important updates:
|
||||
|
||||
[Nov 25, 2025. Servers operated by Flux - true privacy and decentralization for all users](./20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.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)
|
||||
|
||||
@@ -17,7 +17,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
application.registerForRemoteNotifications()
|
||||
removePasscodesIfReinstalled()
|
||||
prepareForLaunch()
|
||||
deleteOldChatArchive()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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.
|
Before Width: | Height: | Size: 17 KiB |
@@ -9,18 +9,6 @@ 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
|
||||
@@ -42,8 +30,7 @@ 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 noticesShown = false
|
||||
@State private var noticesSheetItem: NoticesSheet? = nil
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@@ -274,15 +261,8 @@ struct ContentView: View {
|
||||
alertManager.showAlert(laNoticeAlert())
|
||||
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
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
|
||||
}
|
||||
if !showWhatsNew {
|
||||
showWhatsNew = shouldShowWhatsNew()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,22 +270,8 @@ struct ContentView: View {
|
||||
connectViaUrl()
|
||||
}
|
||||
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
||||
.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_() }
|
||||
}
|
||||
.sheet(isPresented: $showWhatsNew) {
|
||||
WhatsNewView()
|
||||
}
|
||||
if chatModel.setDeliveryReceipts {
|
||||
SetDeliveryReceiptsView()
|
||||
@@ -317,15 +283,6 @@ 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,8 +193,6 @@ 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> = [:]
|
||||
|
||||
@@ -845,7 +843,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
func dismissConnReqView(_ id: String) {
|
||||
if id == showingInvitation?.pcc.id {
|
||||
if id == showingInvitation?.connId {
|
||||
markShowingInvitationUsed()
|
||||
dismissAllSheets()
|
||||
}
|
||||
@@ -898,7 +896,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
var pcc: PendingContactConnection
|
||||
var connId: String
|
||||
var connChatUsed: Bool
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ 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> = [:]
|
||||
|
||||
@@ -75,10 +74,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
} else {
|
||||
if let chatId = content.targetContentIdentifier {
|
||||
self.navigatingToChat = true
|
||||
ItemsModel.shared.loadOpenChat(chatId) {
|
||||
self.navigatingToChat = false
|
||||
}
|
||||
ItemsModel.shared.loadOpenChat(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,13 +446,6 @@ 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 }
|
||||
@@ -507,6 +500,18 @@ 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))
|
||||
@@ -519,72 +524,6 @@ 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)))
|
||||
@@ -1075,8 +1014,8 @@ func apiRejectContactRequest(contactReqId: Int64) async throws {
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiChatRead(type: ChatType, id: Int64) async throws {
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id))
|
||||
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
|
||||
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
|
||||
}
|
||||
|
||||
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
|
||||
@@ -1382,13 +1321,15 @@ func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
|
||||
throw r
|
||||
}
|
||||
|
||||
func markChatRead(_ chat: Chat) async {
|
||||
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) 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)
|
||||
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
|
||||
await MainActor.run {
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo) }
|
||||
withAnimation { ChatModel.shared.markChatItemsRead(cInfo, aboveItem: aboveItem) }
|
||||
}
|
||||
}
|
||||
if chat.chatStats.unreadChat {
|
||||
@@ -1411,6 +1352,17 @@ 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)
|
||||
@@ -1606,16 +1558,6 @@ 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)
|
||||
@@ -1660,7 +1602,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
|
||||
}
|
||||
}
|
||||
|
||||
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
|
||||
func startChat(refreshInvitations: Bool = true) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
try setNetworkConfig(getNetCfg())
|
||||
@@ -1679,15 +1621,13 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws
|
||||
if let token = m.deviceToken {
|
||||
registerToken(token: token)
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2021,18 +1961,6 @@ 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,8 +143,7 @@ struct SimpleXApp: App {
|
||||
let chats = try await apiGetChatsAsync()
|
||||
await MainActor.run { chatModel.updateChats(chats) }
|
||||
if let id = chatModel.chatId,
|
||||
let chat = chatModel.getChat(id),
|
||||
!NtfManager.shared.navigatingToChat {
|
||||
let chat = chatModel.getChat(id) {
|
||||
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")) + textSpace)
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.baselineOffset(1)
|
||||
|
||||
@@ -96,8 +96,6 @@ 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
|
||||
@@ -329,16 +327,6 @@ 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 {
|
||||
@@ -351,7 +339,7 @@ struct ChatInfoView: View {
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.title2)
|
||||
+ textSpace
|
||||
+ Text(" ")
|
||||
+ Text(contact.profile.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
@@ -459,9 +447,8 @@ struct ChatInfoView: View {
|
||||
NavigationLink {
|
||||
ContactPreferencesView(
|
||||
contact: $contact,
|
||||
featuresAllowed: $featuresAllowed,
|
||||
currentFeaturesAllowed: $currentFeaturesAllowed,
|
||||
savePreferences: savePreferences
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences)
|
||||
)
|
||||
.navigationBarTitle("Contact preferences")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
@@ -630,23 +617,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 AudioCallButton: View {
|
||||
@@ -1203,8 +1173,6 @@ 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(verbatim: " ")
|
||||
+ Text(" ")
|
||||
}
|
||||
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(verbatim: " ")
|
||||
+ Text(" ")
|
||||
+ 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(verbatim: " ")
|
||||
+ Text(" ")
|
||||
+ 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 {
|
||||
ItemsModel.shared.loadOpenChat("@\(contactId)")
|
||||
m.chatId = "@\(contactId)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -45,7 +45,7 @@ struct CIMemberCreatedContactView: View {
|
||||
+ Text(openText)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
+ Text(verbatim: " ")
|
||||
+ Text(" ")
|
||||
}
|
||||
r = r + chatItem.timestampText
|
||||
.fontWeight(.light)
|
||||
|
||||
@@ -83,7 +83,7 @@ enum MetaColorMode {
|
||||
? Image("checkmark.wide")
|
||||
: Image(systemName: "circlebadge.fill")
|
||||
).foregroundColor(.clear)
|
||||
case .invertedMaterial: textSpace.kerning(13)
|
||||
case .invertedMaterial: Text(" ").kerning(13)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func ciMetaText(
|
||||
if ttl != chatTTL {
|
||||
r = r + colored(Text(shortTimeText(ttl)), resolved)
|
||||
}
|
||||
space = textSpace
|
||||
space = Text(" ")
|
||||
}
|
||||
if showViaProxy, meta.sentViaProxy == true {
|
||||
appendSpace()
|
||||
@@ -138,12 +138,12 @@ func ciMetaText(
|
||||
} else if !meta.disappearing {
|
||||
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
|
||||
}
|
||||
space = textSpace
|
||||
space = Text(" ")
|
||||
}
|
||||
if let enc = encrypted {
|
||||
appendSpace()
|
||||
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
|
||||
space = textSpace
|
||||
space = Text(" ")
|
||||
}
|
||||
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)
|
||||
+ textSpace
|
||||
+ Text(" ")
|
||||
+ Text("Fix connection")
|
||||
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
|
||||
.font(.callout)
|
||||
+ Text(verbatim: " ")
|
||||
+ Text(" ")
|
||||
+ 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(verbatim: " ")
|
||||
+ Text(" ")
|
||||
+ 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() + textSpace + chatItem.timestampText)
|
||||
(Text(mergedMarkedDeletedText).italic() + Text(" ") + 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(verbatim: " ")
|
||||
private let noTyping = Text(" ")
|
||||
|
||||
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(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
|
||||
(rightToLeft ? Text("\n") : Text(" ")) + 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) + textSpace + res
|
||||
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + Text(" ") + res
|
||||
}
|
||||
|
||||
if let s = sender {
|
||||
|
||||
@@ -126,7 +126,7 @@ struct ChatItemForwardingView: View {
|
||||
ChatItemForwardingView(
|
||||
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")],
|
||||
fromChatInfo: .direct(contact: Contact.sampleData),
|
||||
composeState: Binding.constant(ComposeState(message: "hello"))
|
||||
composeState: Binding.constant(ComposeState())
|
||||
).environmentObject(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
|
||||
private func eventItemViewText(_ secondaryColor: Color) -> Text {
|
||||
if !revealed, let t = mergedGroupEventText {
|
||||
return chatEventText(t + textSpace + chatItem.timestampText, secondaryColor)
|
||||
return chatEventText(t + Text(" ") + 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) + textSpace + Text("and \(count - ns.count) other events")
|
||||
Text(members) + Text(" ") + 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) + textSpace + ts, secondaryColor)
|
||||
chatEventText(Text(eventText) + Text(" ") + ts, secondaryColor)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||
|
||||
@@ -133,12 +133,7 @@ struct ChatView: View {
|
||||
.appSheet(item: $selectedMember) { member in
|
||||
Group {
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
groupMember: member,
|
||||
navigation: true
|
||||
)
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,8 +226,6 @@ struct ChatView: View {
|
||||
chat: chat,
|
||||
contact: contact,
|
||||
localAlias: chat.chatInfo.localAlias,
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(contact.mergedPreferences),
|
||||
onSearch: { focusSearch() }
|
||||
)
|
||||
}
|
||||
@@ -908,7 +901,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]
|
||||
|
||||
@@ -994,7 +987,7 @@ struct ChatView: View {
|
||||
}
|
||||
} else if chatItem.isRcvNew {
|
||||
waitToMarkRead {
|
||||
await apiMarkChatItemsRead(chat.chatInfo, [chatItem.id])
|
||||
await apiMarkChatItemRead(chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1124,13 +1117,14 @@ struct ChatView: View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if let mem = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = mem
|
||||
if let member = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = member
|
||||
} else {
|
||||
let mem = GMember.init(member)
|
||||
m.groupMembers.append(mem)
|
||||
m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
|
||||
selectedMember = mem
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
@@ -1250,20 +1244,11 @@ struct ChatView: View {
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
.if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
v.onTapGesture {
|
||||
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
}
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
v.contextMenu {
|
||||
ReactionContextMenu(
|
||||
groupInfo: groupInfo,
|
||||
itemId: ci.id,
|
||||
reactionCount: r,
|
||||
selectedMember: $selectedMember
|
||||
)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -1853,109 +1838,6 @@ private func buildTheme() -> AppTheme {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactionContextMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let groupInfo: GroupInfo
|
||||
var itemId: Int64
|
||||
var reactionCount: CIReactionCount
|
||||
@Binding var selectedMember: GMember?
|
||||
@State private var memberReactions: [MemberReaction] = []
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||
|
||||
var body: some View {
|
||||
groupMemberReactionList()
|
||||
.task {
|
||||
logger.debug("ReactionContextMenu task \(radius)")
|
||||
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: {
|
||||
HStack {
|
||||
Text(mem.displayName)
|
||||
if let img = cropImage(mem.image) {
|
||||
Image(uiImage: img)
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(userMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cropImage(_ img: String?) -> UIImage? {
|
||||
return if let originalImage = imageFromBase64(img) {
|
||||
maskToCustomShape(originalImage, size: 30, radius: radius)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
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 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)) + textSpace
|
||||
Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,24 +29,28 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let field = CustomUITextField(height: _height)
|
||||
field.backgroundColor = .clear
|
||||
field.text = text
|
||||
field.attributedText = NSAttributedString(
|
||||
string: "",
|
||||
attributes: CustomUITextField.defaultAttributes
|
||||
)
|
||||
field.textAlignment = alignment(text)
|
||||
field.autocapitalizationType = .sentences
|
||||
field.setOnTextChangedListener { newText, images in
|
||||
if !disableEditing {
|
||||
text = newText
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
// Speed up the process of updating layout, reduce jumping content on screen
|
||||
updateHeight(field)
|
||||
self.height = field.frame.size.height
|
||||
} else {
|
||||
field.text = text
|
||||
}
|
||||
if !images.isEmpty {
|
||||
onImagesAdded(images)
|
||||
}
|
||||
}
|
||||
// TODO: Reintegrate with attributed string
|
||||
// field.setOnTextChangedListener { newText, images in
|
||||
// if !disableEditing {
|
||||
// text = newText
|
||||
// field.textAlignment = alignment(text)
|
||||
// updateFont(field)
|
||||
// // Speed up the process of updating layout, reduce jumping content on screen
|
||||
// updateHeight(field)
|
||||
// self.height = field.frame.size.height
|
||||
// } else {
|
||||
// field.text = text
|
||||
// }
|
||||
// if !images.isEmpty {
|
||||
// onImagesAdded(images)
|
||||
// }
|
||||
// }
|
||||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
@@ -57,7 +61,8 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||
|
||||
func updateUIView(_ field: UITextView, context: Context) {
|
||||
if field.markedTextRange == nil && field.text != text {
|
||||
field.text = text
|
||||
// TODO: Reintegrate with attributed string
|
||||
// field.text = text
|
||||
field.textAlignment = alignment(text)
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
@@ -91,7 +96,7 @@ private func alignment(_ text: String) -> NSTextAlignment {
|
||||
isRightToLeft(text) ? .right : .left
|
||||
}
|
||||
|
||||
private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
var height: Binding<CGFloat>
|
||||
var newHeight: CGFloat = 0
|
||||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
@@ -130,6 +135,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
let suggestedActions = [formatMenu] + suggestedActions
|
||||
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
|
||||
return UIMenu(children: suggestedActions.map { elem in
|
||||
if let elem = elem as? UIMenu {
|
||||
@@ -172,33 +178,34 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
textView.attributedText.enumerateAttribute(
|
||||
NSAttributedString.Key.attachment,
|
||||
in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
options: [],
|
||||
using: { value, range, _ in
|
||||
if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
do {
|
||||
images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
} catch {
|
||||
if let img = (value as? NSTextAttachment)?.image {
|
||||
images.append(.simpleImage(image: img))
|
||||
}
|
||||
}
|
||||
newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
rangeDiff += range.length
|
||||
}
|
||||
}
|
||||
)
|
||||
if textView.attributedText != newAttributedText {
|
||||
textView.attributedText = newAttributedText
|
||||
}
|
||||
onTextChanged(textView.text, images)
|
||||
}
|
||||
// TODO: Reintegrate with attributed string
|
||||
// if textView.markedTextRange == nil {
|
||||
// var images: [UploadContent] = []
|
||||
// var rangeDiff = 0
|
||||
// let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||
// textView.attributedText.enumerateAttribute(
|
||||
// NSAttributedString.Key.attachment,
|
||||
// in: NSRange(location: 0, length: textView.attributedText.length),
|
||||
// options: [],
|
||||
// using: { value, range, _ in
|
||||
// if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
||||
// do {
|
||||
// images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
||||
// } catch {
|
||||
// if let img = (value as? NSTextAttachment)?.image {
|
||||
// images.append(.simpleImage(image: img))
|
||||
// }
|
||||
// }
|
||||
// newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
||||
// rangeDiff += range.length
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// if textView.attributedText != newAttributedText {
|
||||
// textView.attributedText = newAttributedText
|
||||
// }
|
||||
// onTextChanged(textView.text, images)
|
||||
// }
|
||||
}
|
||||
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
@@ -208,6 +215,106 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
||||
func textViewDidEndEditing(_ textView: UITextView) {
|
||||
onFocusChanged(false)
|
||||
}
|
||||
|
||||
// MARK: Formatting
|
||||
private var formatMenu: UIMenu {
|
||||
UIMenu(
|
||||
title: "Format",
|
||||
children: [
|
||||
UIAction(image: UIImage(systemName: "bold")) { _ in self.toggleBold() },
|
||||
UIAction(image: UIImage(systemName: "italic")) { _ in self.toggleItalic() },
|
||||
UIAction(image: UIImage(systemName: "strikethrough")) { _ in self.toggleStriketrough() },
|
||||
UIAction(title: "Mono") { _ in self.toggleMono() },
|
||||
UIMenu(
|
||||
title: "Color",
|
||||
children: [UIColor]([.red, .green, .blue, .yellow, .cyan, .magenta])
|
||||
.map { color in
|
||||
UIAction(
|
||||
image: UIImage(systemName: "circle.fill")?
|
||||
.withTintColor(color, renderingMode: .alwaysOriginal),
|
||||
handler: { _ in self.toggle(color: color) }
|
||||
)
|
||||
}
|
||||
),
|
||||
UIAction(title: "Secret") { _ in self.toggleSecret() }
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func toggleBold() {
|
||||
toggle(UIFont.boldSystemFont(ofSize: Self.bodySize), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitBold)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleItalic() {
|
||||
toggle(UIFont.italicSystemFont(ofSize: Self.bodySize), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitItalic)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleStriketrough() {
|
||||
toggle(NSUnderlineStyle.single.rawValue, for: .strikethroughStyle) {
|
||||
($0 as? Int).map { $0 == NSUnderlineStyle.single.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMono() {
|
||||
toggle(UIFont.monospacedSystemFont(ofSize: Self.bodySize, weight: .regular), for: .font) {
|
||||
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(color: UIColor) {
|
||||
toggle(color, for: .foregroundColor) {
|
||||
($0 as? UIColor).map { $0 == color }
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSecret() {
|
||||
toggle(UIColor.clear, for: .foregroundColor) { value in
|
||||
(value as? UIColor).map { $0 == .clear }
|
||||
}
|
||||
toggle(UIColor.secondarySystemFill, for: .backgroundColor) { value in
|
||||
(value as? UIColor).map { $0 == .secondarySystemFill }
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles an attribute in the currently selected text range
|
||||
/// - Parameters:
|
||||
/// - attribute: Value of the attribute to be enabled or disabled
|
||||
/// - key: Key for which to apply the attribute
|
||||
/// - detect: Block which detects, if the attribute is already present within the selection
|
||||
private func toggle(
|
||||
_ attribute: Any,
|
||||
for key: NSAttributedString.Key,
|
||||
detect: (Any) -> Bool?
|
||||
) {
|
||||
var detected = false
|
||||
textStorage.enumerateAttribute(key, in: selectedRange) { value, _, stop in
|
||||
if let value, detect(value) == true {
|
||||
detected = true
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
if key == .backgroundColor {
|
||||
textStorage.removeAttribute(.backgroundColor, range: selectedRange)
|
||||
} else {
|
||||
textStorage.setAttributes(Self.defaultAttributes, range: selectedRange)
|
||||
}
|
||||
if !detected {
|
||||
textStorage.addAttribute(key, value: attribute, range: selectedRange)
|
||||
}
|
||||
}
|
||||
|
||||
static var bodySize: CGFloat {
|
||||
UIFont.preferredFont(forTextStyle: .body).pointSize
|
||||
}
|
||||
|
||||
static var defaultAttributes: [NSAttributedString.Key : Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.label
|
||||
]
|
||||
}
|
||||
|
||||
struct NativeTextEditor_Previews: PreviewProvider{
|
||||
|
||||
@@ -14,10 +14,9 @@ struct ContactPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var contact: Contact
|
||||
@Binding var featuresAllowed: ContactFeaturesAllowed
|
||||
@Binding var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
@State var featuresAllowed: ContactFeaturesAllowed
|
||||
@State var currentFeaturesAllowed: ContactFeaturesAllowed
|
||||
@State private var showSaveDialogue = false
|
||||
let savePreferences: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let user: User = chatModel.currentUser!
|
||||
@@ -49,10 +48,7 @@ struct ContactPreferencesView: View {
|
||||
savePreferences()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") {
|
||||
featuresAllowed = currentFeaturesAllowed
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,15 +118,31 @@ 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: Binding.constant(ContactFeaturesAllowed.sampleData),
|
||||
currentFeaturesAllowed: Binding.constant(ContactFeaturesAllowed.sampleData),
|
||||
savePreferences: {}
|
||||
featuresAllowed: ContactFeaturesAllowed.sampleData,
|
||||
currentFeaturesAllowed: ContactFeaturesAllowed.sampleData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,12 +78,7 @@ struct AddGroupMembersViewCommon: View {
|
||||
let count = selectedContacts.count
|
||||
Section {
|
||||
if creatingGroup {
|
||||
GroupPreferencesButton(
|
||||
groupInfo: $groupInfo,
|
||||
preferences: groupInfo.fullGroupPreferences,
|
||||
currentPreferences: groupInfo.fullGroupPreferences,
|
||||
creatingGroup: true
|
||||
)
|
||||
groupPreferencesButton($groupInfo, true)
|
||||
}
|
||||
rolePicker()
|
||||
inviteMembersButton()
|
||||
@@ -145,13 +140,12 @@ struct AddGroupMembersViewCommon: View {
|
||||
return dummy
|
||||
}()
|
||||
|
||||
@ViewBuilder private func inviteMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
|
||||
private func inviteMembersButton() -> some View {
|
||||
Button {
|
||||
inviteMembers()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label)
|
||||
Text("Invite to group")
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
@@ -237,7 +231,6 @@ 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.isOwner && groupInfo.businessChat == nil {
|
||||
if groupInfo.canEdit {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
if groupInfo.groupProfile.description != nil || groupInfo.canEdit {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
groupPreferencesButton($groupInfo)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
@@ -101,20 +101,13 @@ struct GroupChatInfoView: View {
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
Text("Only group owners can change group preferences.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
}
|
||||
groupLinkButton()
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
@@ -283,15 +276,10 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
|
||||
case .customer: "Add team members"
|
||||
case .business: "Add friends"
|
||||
case .none: "Invite members"
|
||||
}
|
||||
return NavigationLink {
|
||||
NavigationLink {
|
||||
addMembersDestinationView()
|
||||
} label: {
|
||||
Label(label, systemImage: "plus")
|
||||
Label("Invite members", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +418,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private var memberVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.font(.caption)
|
||||
.baselineOffset(2)
|
||||
.kerning(-2)
|
||||
@@ -439,7 +427,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func memberInfoView(_ groupMember: GMember) -> some View {
|
||||
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
|
||||
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
|
||||
.navigationBarHidden(false)
|
||||
}
|
||||
|
||||
@@ -499,12 +487,11 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func deleteGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
|
||||
private func deleteGroupButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteGroupAlert
|
||||
} label: {
|
||||
Label(label, systemImage: "trash")
|
||||
Label("Delete group", systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
}
|
||||
}
|
||||
@@ -518,22 +505,20 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func leaveGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
|
||||
private func leaveGroupButton() -> some View {
|
||||
Button(role: .destructive) {
|
||||
alert = .leaveGroupAlert
|
||||
} label: {
|
||||
Label(label, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
Label("Leave group", 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(label),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
title: Text("Delete group?"),
|
||||
message: deleteGroupAlertMessage(),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -552,6 +537,10 @@ 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?"),
|
||||
@@ -567,15 +556,9 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert() -> Alert {
|
||||
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),
|
||||
Alert(
|
||||
title: Text("Leave group?"),
|
||||
message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
|
||||
primaryButton: .destructive(Text("Leave")) {
|
||||
Task {
|
||||
await leaveGroup(chat.chatInfo.apiId)
|
||||
@@ -619,14 +602,9 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> 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(
|
||||
Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text(messageLabel),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -646,80 +624,26 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +14,6 @@ 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
|
||||
@@ -136,8 +135,7 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
|
||||
infoRow(label, groupInfo.displayName)
|
||||
infoRow("Group", groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
@@ -262,11 +260,6 @@ 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))
|
||||
}
|
||||
|
||||
@@ -312,15 +305,10 @@ 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: messageLabel
|
||||
message: "Direct messages between members are prohibited in this group."
|
||||
),
|
||||
id: "can't message member, direct messages prohibited"
|
||||
))
|
||||
@@ -400,7 +388,7 @@ struct GroupMemberInfoView: View {
|
||||
Text(Image(systemName: "checkmark.shield"))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.font(.title2)
|
||||
+ textSpace
|
||||
+ Text(" ")
|
||||
+ Text(mem.displayName)
|
||||
.font(.largeTitle)
|
||||
)
|
||||
@@ -549,14 +537,9 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
|
||||
private func removeMemberAlert(_ mem: GroupMember) -> 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(
|
||||
Alert(
|
||||
title: Text("Remove member?"),
|
||||
message: Text(label),
|
||||
message: Text("Member will be removed from group - this cannot be undone!"),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -579,15 +562,7 @@ struct GroupMemberInfoView: View {
|
||||
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Change member role?"),
|
||||
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.")
|
||||
),
|
||||
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."),
|
||||
primaryButton: .default(Text("Change")) {
|
||||
Task {
|
||||
do {
|
||||
@@ -595,7 +570,7 @@ struct GroupMemberInfoView: View {
|
||||
await MainActor.run {
|
||||
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
|
||||
}
|
||||
|
||||
|
||||
} catch let error {
|
||||
newRole = mem.memberRole
|
||||
logger.error("apiMemberRole error: \(responseError(error))")
|
||||
@@ -764,7 +739,6 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: GroupInfo.sampleData,
|
||||
chat: Chat.sampleData,
|
||||
groupMember: GMember.sampleData
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,9 @@ struct GroupPreferencesView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var groupInfo: GroupInfo
|
||||
@Binding var preferences: FullGroupPreferences
|
||||
var currentPreferences: FullGroupPreferences
|
||||
@State var preferences: FullGroupPreferences
|
||||
@State var currentPreferences: FullGroupPreferences
|
||||
let creatingGroup: Bool
|
||||
let savePreferences: () -> Void
|
||||
@State private var showSaveDialogue = false
|
||||
|
||||
var body: some View {
|
||||
@@ -39,7 +38,7 @@ struct GroupPreferencesView: View {
|
||||
featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role)
|
||||
featureSection(.history, $preferences.history.enable)
|
||||
|
||||
if groupInfo.isOwner {
|
||||
if groupInfo.canEdit {
|
||||
Section {
|
||||
Button("Reset") { preferences = currentPreferences }
|
||||
Button(saveText) { savePreferences() }
|
||||
@@ -69,10 +68,7 @@ struct GroupPreferencesView: View {
|
||||
savePreferences()
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") {
|
||||
preferences = currentPreferences
|
||||
dismiss()
|
||||
}
|
||||
Button("Exit without saving") { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +77,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.isOwner {
|
||||
if groupInfo.canEdit {
|
||||
let enable = Binding(
|
||||
get: { enableFeature.wrappedValue == .on },
|
||||
set: { on, _ in enableFeature.wrappedValue = on ? .on : .off }
|
||||
@@ -127,7 +123,7 @@ struct GroupPreferencesView: View {
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.isOwner))
|
||||
Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.onChange(of: enableFeature.wrappedValue) { enabled in
|
||||
@@ -136,16 +132,32 @@ 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: Binding.constant(FullGroupPreferences.sampleData),
|
||||
preferences: FullGroupPreferences.sampleData,
|
||||
currentPreferences: FullGroupPreferences.sampleData,
|
||||
creatingGroup: false,
|
||||
savePreferences: {}
|
||||
creatingGroup: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct GroupWelcomeView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
if groupInfo.canEdit {
|
||||
editorView()
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
if welcomeTextUnchanged() {
|
||||
|
||||
@@ -42,8 +42,7 @@ struct ChatHelp: View {
|
||||
Text("above, then choose:")
|
||||
}
|
||||
|
||||
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("**Add contact**: to create a new invitation link, or connect via a link you received.")
|
||||
Text("**Create group**: to create a new group.")
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -404,9 +404,8 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
return Alert(
|
||||
title: Text(label),
|
||||
Alert(
|
||||
title: Text("Delete group?"),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
primaryButton: .destructive(Text("Delete")) {
|
||||
Task { await deleteChat(chat) }
|
||||
@@ -415,6 +414,10 @@ 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?"),
|
||||
@@ -438,15 +441,9 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
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),
|
||||
Alert(
|
||||
title: Text("Leave group?"),
|
||||
message: Text("You will stop receiving messages from this group. Chat history will be preserved."),
|
||||
primaryButton: .destructive(Text("Leave")) {
|
||||
Task { await leaveGroup(groupInfo.groupId) }
|
||||
},
|
||||
|
||||
@@ -31,21 +31,9 @@ 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 {
|
||||
@@ -88,21 +76,6 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +94,6 @@ 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 {
|
||||
@@ -304,6 +276,12 @@ 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)
|
||||
@@ -329,20 +307,6 @@ 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")) + textSpace)
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.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) + textSpace
|
||||
Text(Image(systemName: s)).foregroundColor(color) + Text(" ")
|
||||
}
|
||||
|
||||
func attachment() -> Text {
|
||||
switch draft.preview {
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + textSpace
|
||||
case let .filePreview(fileName, _): return image("doc.fill") + Text(fileName) + Text(" ")
|
||||
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) + textSpace
|
||||
.foregroundColor(.red) + Text(" ")
|
||||
case .sndWarning:
|
||||
return Text(Image(systemName: "exclamationmark.triangle.fill"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange) + textSpace
|
||||
.foregroundColor(.orange) + Text(" ")
|
||||
default: return Text("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ 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,6 +491,15 @@ 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 {
|
||||
@@ -670,6 +679,15 @@ 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 {
|
||||
|
||||
@@ -151,7 +151,7 @@ struct ContactListNavLink: View {
|
||||
}
|
||||
|
||||
private var verifiedIcon: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
|
||||
.foregroundColor(.secondary)
|
||||
.baselineOffset(1)
|
||||
.kerning(-2)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// 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,8 +48,6 @@ 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 {
|
||||
@@ -136,61 +134,46 @@ struct DatabaseEncryptionView: View {
|
||||
.onAppear {
|
||||
if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" }
|
||||
}
|
||||
.disabled(progressIndicator)
|
||||
.disabled(m.chatRunning != false)
|
||||
.alert(item: $alert) { item in databaseEncryptionAlert(item) }
|
||||
}
|
||||
|
||||
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)
|
||||
} else {
|
||||
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()
|
||||
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()
|
||||
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))"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -388,6 +371,6 @@ func validKey(_ s: String) -> Bool {
|
||||
|
||||
struct DatabaseEncryptionView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false, stopChatRunBlockStartChat: { _, _ in true })
|
||||
DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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()
|
||||
@@ -29,39 +28,23 @@ struct DatabaseErrorView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func databaseErrorView() -> some View {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
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)
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
saveAndOpenButton()
|
||||
fileNameText(dbFile)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -69,105 +52,73 @@ 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()
|
||||
.padding(.horizontal, 25)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Warning: you may lose some data!").bold()
|
||||
Button("Downgrade and open chat") { runChat(confirmMigrations: .yesUpDown) }
|
||||
fileNameText(dbFile)
|
||||
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, font: .callout)
|
||||
errorView(Text(mtrErrorDescription(mtrError)))
|
||||
fileNameText(dbFile)
|
||||
Text("Error: ") + Text(mtrErrorDescription(mtrError))
|
||||
}
|
||||
case let .errorSQL(dbFile, migrationSQLError):
|
||||
titleText("Database error")
|
||||
fileNameText(dbFile, font: .callout)
|
||||
errorView(Text("Error: \(migrationSQLError)"))
|
||||
fileNameText(dbFile)
|
||||
Text("Error: \(migrationSQLError)")
|
||||
case .errorKeychain:
|
||||
titleText("Keychain error")
|
||||
errorView(Text("Cannot access keychain to save database password"))
|
||||
Text("Cannot access keychain to save database password")
|
||||
case .invalidConfirmation:
|
||||
// this can only happen if incorrect parameter is passed
|
||||
titleText("Invalid migration confirmation")
|
||||
errorView()
|
||||
|
||||
Text(String("Invalid migration confirmation")).font(.title)
|
||||
case let .unknown(json):
|
||||
titleText("Database error")
|
||||
errorView(Text("Unknown database error: \(json)"))
|
||||
Text("Unknown database error: \(json)")
|
||||
case .ok:
|
||||
EmptyView()
|
||||
}
|
||||
if showRestoreDbButton {
|
||||
Spacer()
|
||||
Spacer().frame(height: 10)
|
||||
Text("The attempt to change database passphrase was not completed.")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
.font(.footnote)
|
||||
|
||||
restoreDbButton()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() }
|
||||
}
|
||||
|
||||
private func titleText(_ s: LocalizedStringKey) -> some View {
|
||||
Text(s).font(.largeTitle).bold().multilineTextAlignment(.center)
|
||||
private func titleText(_ s: LocalizedStringKey) -> Text {
|
||||
Text(s).font(.title)
|
||||
}
|
||||
|
||||
private func fileNameText(_ f: String, font: Font = .caption) -> Text {
|
||||
Text("File: \((f as NSString).lastPathComponent)").font(font)
|
||||
private func fileNameText(_ f: String) -> Text {
|
||||
Text("File: \((f as NSString).lastPathComponent)")
|
||||
}
|
||||
|
||||
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 migrationsText(_ ms: [String]) -> Text {
|
||||
Text("Migrations: \(ms.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -241,9 +192,8 @@ struct DatabaseErrorView: View {
|
||||
secondaryButton: .cancel()
|
||||
))
|
||||
} label: {
|
||||
Text("Restore database backup")
|
||||
Text("Restore database backup").foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
}
|
||||
|
||||
private func restoreDb() {
|
||||
@@ -258,23 +208,6 @@ 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,7 +46,6 @@ 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?
|
||||
@@ -58,8 +57,6 @@ 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
|
||||
|
||||
@@ -72,20 +69,7 @@ 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 {
|
||||
@@ -117,10 +101,9 @@ struct DatabaseView: View {
|
||||
isOn: $runChat
|
||||
)
|
||||
.onChange(of: runChat) { _ in
|
||||
if runChat {
|
||||
DatabaseView.startChat($runChat, $progressIndicator)
|
||||
} else if !stoppingChat {
|
||||
stoppingChat = false
|
||||
if (runChat) {
|
||||
startChat()
|
||||
} else {
|
||||
alert = .stopChat
|
||||
}
|
||||
}
|
||||
@@ -140,9 +123,7 @@ 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, stopChatRunBlockStartChat: { progressIndicator, block in
|
||||
stopChatRunBlockStartChat(false, progressIndicator, block)
|
||||
})
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: false)
|
||||
.navigationTitle("Database passphrase")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
@@ -152,14 +133,9 @@ struct DatabaseView: View {
|
||||
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
|
||||
Button("Export database") {
|
||||
if initialRandomDBPassphraseGroupDefault.get() && !unencrypted {
|
||||
showDatabaseEncryptionView = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
alert = .exportProhibited
|
||||
}
|
||||
alert = .exportProhibited
|
||||
} else {
|
||||
stopChatRunBlockStartChat(stopped, $progressIndicator) {
|
||||
await exportArchive()
|
||||
}
|
||||
exportArchive()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +144,20 @@ 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
|
||||
@@ -177,10 +167,14 @@ struct DatabaseView: View {
|
||||
Text("Chat database")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
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.")
|
||||
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"
|
||||
)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.disabled(progressIndicator)
|
||||
.disabled(!stopped)
|
||||
|
||||
if case .group = dbContainer, legacyDatabase {
|
||||
Section(header: Text("Old database").foregroundColor(theme.colors.secondary)) {
|
||||
@@ -196,7 +190,7 @@ struct DatabaseView: View {
|
||||
Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) {
|
||||
alert = .deleteFilesAndMedia
|
||||
}
|
||||
.disabled(progressIndicator || appFilesCountAndSize?.0 == 0)
|
||||
.disabled(!stopped || appFilesCountAndSize?.0 == 0)
|
||||
} header: {
|
||||
Text("Files & media")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -261,10 +255,7 @@ 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")) {
|
||||
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
|
||||
_ = await DatabaseView.importArchive(fileURL, $progressIndicator, $alert)
|
||||
return true
|
||||
}
|
||||
importArchive(fileURL)
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -272,15 +263,19 @@ struct DatabaseView: View {
|
||||
return Alert(title: Text("Error: no database file"))
|
||||
}
|
||||
case .archiveImported:
|
||||
let (title, message) = archiveImportedAlertText()
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
return Alert(
|
||||
title: Text("Chat database imported"),
|
||||
message: Text("Restart the app to use imported chat database")
|
||||
)
|
||||
case let .archiveImportedWithErrors(errs):
|
||||
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
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)
|
||||
)
|
||||
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:") + Text(archiveErrorsText(errs)),
|
||||
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
showShareSheet(items: [archivePath])
|
||||
}
|
||||
@@ -290,17 +285,15 @@ 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")) {
|
||||
let wasStopped = m.chatRunning == false
|
||||
stopChatRunBlockStartChat(wasStopped, $progressIndicator) {
|
||||
_ = await deleteChat()
|
||||
return true
|
||||
}
|
||||
deleteChat()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .chatDeleted:
|
||||
let (title, message) = chatDeletedAlertText()
|
||||
return Alert(title: Text(title), message: Text(message))
|
||||
return Alert(
|
||||
title: Text("Chat database deleted"),
|
||||
message: Text("Restart the app to create a new chat profile")
|
||||
)
|
||||
case .deleteLegacyDatabase:
|
||||
return Alert(
|
||||
title: Text("Delete old database?"),
|
||||
@@ -315,10 +308,7 @@ 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")) {
|
||||
stopChatRunBlockStartChat(m.chatRunning == false, $progressIndicator) {
|
||||
deleteFiles()
|
||||
return true
|
||||
}
|
||||
deleteFiles()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
@@ -338,180 +328,95 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func authStopChat(_ onStop: (() -> Void)? = nil) {
|
||||
private func authStopChat() {
|
||||
if UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) {
|
||||
authenticate(reason: NSLocalizedString("Stop SimpleX", comment: "authentication reason")) { laResult in
|
||||
switch laResult {
|
||||
case .success: stopChat(onStop)
|
||||
case .unavailable: stopChat(onStop)
|
||||
case .success: stopChat()
|
||||
case .unavailable: stopChat()
|
||||
case .failed: withAnimation { runChat = true }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopChat(onStop)
|
||||
stopChat()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopChat(_ onStop: (() -> Void)? = nil) {
|
||||
private func stopChat() {
|
||||
Task {
|
||||
do {
|
||||
try await stopChatAsync()
|
||||
onStop?()
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
runChat = true
|
||||
showAlert("Error stopping chat", message: responseError(error))
|
||||
alert = .error(title: "Error stopping chat", error: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
private func exportArchive() {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
_ = try apiStartChat()
|
||||
runChat.wrappedValue = true
|
||||
m.chatRunning = true
|
||||
ChatReceiver.shared.start()
|
||||
chatLastStartGroupDefault.set(Date.now)
|
||||
AppChatState.shared.set(.active)
|
||||
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
|
||||
}
|
||||
}
|
||||
} 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 = .archiveExportedWithErrors(archivePath: archivePath, archiveErrors: archiveErrors)
|
||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
alert = .error(title: "Error exporting chat database", error: responseError(error))
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func importArchive(
|
||||
_ archivePath: URL,
|
||||
_ progressIndicator: Binding<Bool>,
|
||||
_ alert: Binding<DatabaseAlert?>
|
||||
) async -> Bool {
|
||||
private func importArchive(_ archivePath: URL) {
|
||||
if archivePath.startAccessingSecurityScopedResource() {
|
||||
await MainActor.run {
|
||||
progressIndicator.wrappedValue = true
|
||||
}
|
||||
do {
|
||||
try await apiDeleteStorage()
|
||||
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
||||
progressIndicator = true
|
||||
Task {
|
||||
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)
|
||||
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)))
|
||||
}
|
||||
return true
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error importing chat database", error: responseError(error)), progressIndicator, alert)
|
||||
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)))
|
||||
}
|
||||
} catch let error {
|
||||
await operationEnded(.error(title: "Error deleting chat database", error: responseError(error)), progressIndicator, alert)
|
||||
archivePath.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
archivePath.stopAccessingSecurityScopedResource()
|
||||
} else {
|
||||
showAlert("Error accessing database file")
|
||||
alert = .error(title: "Error accessing database file")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,28 +428,39 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static func operationEnded(_ dbAlert: DatabaseAlert, _ progressIndicator: Binding<Bool>, _ alert: Binding<DatabaseAlert?>) async {
|
||||
private func operationEnded(_ dbAlert: DatabaseAlert) async {
|
||||
await MainActor.run {
|
||||
let m = ChatModel.shared
|
||||
m.chatDbChanged = true
|
||||
m.chatInitialized = false
|
||||
progressIndicator.wrappedValue = false
|
||||
progressIndicator = false
|
||||
alert = dbAlert
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,28 +503,8 @@ struct DatabaseView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 archiveErrorsText(_ errs: [ArchiveError]) -> Text {
|
||||
return Text("\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))"
|
||||
}
|
||||
deleteOldChatArchive()
|
||||
deleteOldArchive()
|
||||
} label: {
|
||||
Text("Start chat")
|
||||
.font(.title)
|
||||
@@ -235,16 +235,14 @@ 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 {
|
||||
deleteOldChatArchive()
|
||||
deleteOldArchive()
|
||||
UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME)
|
||||
chatArchiveTimeDefault.set(archiveTime)
|
||||
}
|
||||
return (archivePath, errs)
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
func deleteOldArchive() {
|
||||
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:") + Text(archiveErrorsText(errs)),
|
||||
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + archiveErrorsText(errs),
|
||||
dismissButton: .default(Text("Continue")) {
|
||||
Task { await uploadArchive(path: archivePath) }
|
||||
}
|
||||
@@ -222,8 +222,7 @@ struct MigrateFromDevice: View {
|
||||
}
|
||||
|
||||
private func passphraseNotSetView() -> some View {
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true, stopChatRunBlockStartChat: { _, _ in
|
||||
})
|
||||
DatabaseEncryptionView(useKeychain: $useKeychain, migration: true)
|
||||
.onChange(of: initialRandomDBPassphrase) { initial in
|
||||
if !initial {
|
||||
migrationState = .uploadConfirmation
|
||||
|
||||
@@ -103,9 +103,6 @@ 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 {
|
||||
@@ -203,12 +200,6 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,34 +220,6 @@ 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,6 +18,7 @@ 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 {
|
||||
@@ -29,8 +30,12 @@ struct NewChatMenuButton: View {
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.appSheet(isPresented: $showNewChatSheet) {
|
||||
NewChatSheet()
|
||||
NewChatSheet(pendingConnection: $pendingConnection)
|
||||
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
|
||||
.onDisappear {
|
||||
alert = cleanupPendingConnection(contactConnection: pendingConnection)
|
||||
pendingConnection = nil
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { a in
|
||||
return a.alert
|
||||
@@ -50,6 +55,7 @@ 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
|
||||
@@ -104,17 +110,17 @@ struct NewChatSheet: View {
|
||||
if (searchText.isEmpty) {
|
||||
Section {
|
||||
NavigationLink(isActive: $isAddContactActive) {
|
||||
NewChatView(selection: .invite)
|
||||
NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) {
|
||||
navigateOnTap(Label("Add contact", systemImage: "link.badge.plus")) {
|
||||
isAddContactActive = true
|
||||
}
|
||||
}
|
||||
NavigationLink(isActive: $isScanPasteLinkActive) {
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true)
|
||||
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection)
|
||||
.navigationTitle("New chat")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
|
||||
@@ -45,33 +45,32 @@ enum NewChatOption: Identifiable {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
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: []
|
||||
))
|
||||
}
|
||||
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: []
|
||||
))
|
||||
}
|
||||
)
|
||||
]}
|
||||
}
|
||||
),
|
||||
id: "keepUnusedInvitation"
|
||||
)
|
||||
}
|
||||
|
||||
ChatModel.shared.showingInvitation = nil
|
||||
|
||||
return alert
|
||||
}
|
||||
|
||||
struct NewChatView: View {
|
||||
@@ -85,12 +84,13 @@ struct NewChatView: View {
|
||||
@State var choosingProfile = false
|
||||
@State private var pastedLink: String = ""
|
||||
@State private var alert: NewChatViewAlert?
|
||||
@State private var contactConnection: PendingContactConnection? = nil
|
||||
@Binding var parentAlert: SomeAlert?
|
||||
@Binding var contactConnection: PendingContactConnection?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Picker("New chat", selection: $selection) {
|
||||
Label("1-time link", systemImage: "link")
|
||||
Label("Add contact", systemImage: "link")
|
||||
.tag(NewChatOption.invite)
|
||||
Label("Connect via link", systemImage: "qrcode")
|
||||
.tag(NewChatOption.connect)
|
||||
@@ -157,7 +157,7 @@ struct NewChatView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
if !choosingProfile {
|
||||
showKeepInvitationAlert()
|
||||
parentAlert = cleanupPendingConnection(contactConnection: contactConnection)
|
||||
contactConnection = nil
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ struct NewChatView: View {
|
||||
if let (connReq, pcc) = r {
|
||||
await MainActor.run {
|
||||
m.updateContactConnection(pcc)
|
||||
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
|
||||
m.showingInvitation = ShowingInvitation(connId: pcc.id, connChatUsed: false)
|
||||
connReqInvitation = connReq
|
||||
contactConnection = pcc
|
||||
}
|
||||
@@ -916,17 +916,11 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (
|
||||
)
|
||||
case let .groupLinkConnecting(_, groupInfo):
|
||||
if let groupInfo = groupInfo {
|
||||
return groupInfo.businessChat == nil
|
||||
? Alert(
|
||||
return 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!"),
|
||||
@@ -1243,15 +1237,10 @@ func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
|
||||
}
|
||||
|
||||
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
groupInfo.businessChat == nil
|
||||
? mkAlert(
|
||||
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 {
|
||||
@@ -1289,7 +1278,9 @@ struct NewChatView_Previews: PreviewProvider {
|
||||
@State var contactConnection: PendingContactConnection? = nil
|
||||
|
||||
NewChatView(
|
||||
selection: .invite
|
||||
selection: .invite,
|
||||
parentAlert: $parentAlert,
|
||||
contactConnection: $contactConnection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
//
|
||||
// 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()
|
||||
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
|
||||
} label: {
|
||||
Label("Create profile", systemImage: "checkmark")
|
||||
}
|
||||
@@ -78,35 +78,6 @@ 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 {
|
||||
@@ -115,150 +86,131 @@ 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, spacing: 20) {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
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)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(maxWidth: .infinity) // Ensures it takes up the full width
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom)
|
||||
|
||||
HStack {
|
||||
ZStack(alignment: .topLeading) {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
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)
|
||||
}
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
.padding(.bottom)
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
createProfileButton()
|
||||
if !focusDisplayName {
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
onboardingButtons()
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 25)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func createProfileButton() -> some View {
|
||||
ZStack {
|
||||
func onboardingButtons() -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
createProfile()
|
||||
hideKeyboard()
|
||||
withAnimation {
|
||||
m.onboardingStage = .step1_SimpleXInfo
|
||||
}
|
||||
} label: {
|
||||
Text("Create profile")
|
||||
HStack {
|
||||
Image(systemName: "lessthan")
|
||||
Text("About SimpleX")
|
||||
}
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
|
||||
NavigationLink(isActive: $nextStepNavLinkActive) {
|
||||
nextStepDestinationView()
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
|
||||
} label: {
|
||||
EmptyView()
|
||||
HStack {
|
||||
Text("Create")
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
.disabled(!canCreateProfile(displayName))
|
||||
}
|
||||
}
|
||||
|
||||
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 showCreateProfileAlert(
|
||||
showAlert: (UserProfileAlert) -> Void,
|
||||
_ error: Error
|
||||
) {
|
||||
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
|
||||
hideKeyboard()
|
||||
let profile = Profile(
|
||||
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
||||
fullName: ""
|
||||
)
|
||||
let m = ChatModel.shared
|
||||
switch error as? ChatResponse {
|
||||
case .chatCmdError(_, .errorStore(.duplicateName)),
|
||||
.chatCmdError(_, .error(.userExists)):
|
||||
if m.currentUser == nil {
|
||||
AlertManager.shared.showAlert(duplicateUserAlert)
|
||||
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
|
||||
}
|
||||
} else {
|
||||
showAlert(.duplicateUserError)
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
m.users = try listUsers()
|
||||
try getUserChatData()
|
||||
}
|
||||
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))
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
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("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("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("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
|
||||
}
|
||||
}
|
||||
@@ -37,34 +37,19 @@ struct HowItWorks: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
OnboardingActionButton()
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.lineLimit(10)
|
||||
.padding(onboarding ? 25 : 16)
|
||||
.padding()
|
||||
.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,
|
||||
createProfileNavLinkActive: Binding.constant(false)
|
||||
)
|
||||
HowItWorks(onboarding: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,39 +12,20 @@ struct OnboardingView: View {
|
||||
var onboarding: OnboardingStage
|
||||
|
||||
var body: some View {
|
||||
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()
|
||||
}
|
||||
switch onboarding {
|
||||
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
|
||||
case .step2_CreateProfile: CreateFirstProfile()
|
||||
case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
|
||||
case .step4_SetNotificationsMode: SetNotificationsMode()
|
||||
case .onboardingComplete: EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onboardingButtonPlaceholder() -> some View {
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile // deprecated
|
||||
case step3_CreateSimpleXAddress // deprecated
|
||||
case step3_ChooseServerOperators
|
||||
case step2_CreateProfile
|
||||
case step3_CreateSimpleXAddress
|
||||
case step4_SetNotificationsMode
|
||||
case onboardingComplete
|
||||
|
||||
|
||||
@@ -13,54 +13,42 @@ 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 {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.top, 25)
|
||||
|
||||
infoText()
|
||||
|
||||
Spacer()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Push notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
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!")
|
||||
}
|
||||
|
||||
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()
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
}
|
||||
}
|
||||
.padding(25)
|
||||
.frame(minHeight: g.size.height)
|
||||
.font(.title)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.sheet(isPresented: $showInfo) {
|
||||
NotificationsInfoView()
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,15 +75,6 @@ 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 {
|
||||
@@ -106,25 +85,15 @@ struct NtfModeSelector: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: mode.icon)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: mode.icon == "bolt" ? 14 : 18, height: 18)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
|
||||
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)
|
||||
}
|
||||
Text(ntfModeDescription(mode))
|
||||
.lineLimit(10)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.leading, 16)
|
||||
.padding(12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tapped ? Color(uiColor: .secondarySystemFill) : theme.colors.background)
|
||||
@@ -140,37 +109,6 @@ 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) {
|
||||
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()
|
||||
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: .leading) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
OnboardingActionButton()
|
||||
Spacer()
|
||||
|
||||
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)
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.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,59 +83,76 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showHowItWorks) {
|
||||
HowItWorks(
|
||||
onboarding: onboarding,
|
||||
createProfileNavLinkActive: $createProfileNavLinkActive
|
||||
)
|
||||
HowItWorks(onboarding: onboarding)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
|
||||
private func infoRow(_ 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)
|
||||
}
|
||||
}
|
||||
|
||||
let textSpace = Text(verbatim: " ")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXInfo_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -7,209 +7,190 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private struct VersionDescription {
|
||||
var version: String
|
||||
var post: URL?
|
||||
var features: [Feature]
|
||||
var features: [FeatureDescription]
|
||||
}
|
||||
|
||||
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?
|
||||
private struct FeatureDescription {
|
||||
var icon: String?
|
||||
var title: LocalizedStringKey
|
||||
var 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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Security assessment",
|
||||
description: "SimpleX Chat security was audited by Trail of Bits."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.2",
|
||||
title: "Group links",
|
||||
description: "Admins can create the links to join groups."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "mic",
|
||||
title: "Voice messages",
|
||||
description: "Max 30 seconds, received instantly."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "trash.slash",
|
||||
title: "Irreversible message deletion",
|
||||
description: "Your contacts can allow full message deletion."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Improved server configuration",
|
||||
description: "Add servers by scanning QR codes."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
title: "Disappearing messages",
|
||||
description: "Sent messages will be deleted after set time."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "ellipsis.circle",
|
||||
title: "Live messages",
|
||||
description: "Recipients see updates as you type them."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Verify connection security",
|
||||
description: "Compare security codes with your contacts."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "camera",
|
||||
title: "GIFs and stickers",
|
||||
description: "Send them from gallery or custom keyboards."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "person.crop.rectangle.stack",
|
||||
title: "Multiple chat profiles",
|
||||
description: "Different names, avatars and transport isolation."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "rectangle.and.pencil.and.ellipsis",
|
||||
title: "Message draft",
|
||||
description: "Preserve the last message draft, with attachments."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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)."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock.doc",
|
||||
title: "Private filenames",
|
||||
description: "To protect timezone, image/voice files use UTC."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "battery.25",
|
||||
title: "Reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Hidden chat profiles",
|
||||
description: "Protect your chat profiles with a password!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "phone.arrow.up.right",
|
||||
title: "Audio and video calls",
|
||||
description: "Fully re-implemented - work in background!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "flag",
|
||||
title: "Group moderation",
|
||||
description: "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "plus.message",
|
||||
title: "Group welcome message",
|
||||
description: "Set the message shown to new members!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "battery.50",
|
||||
title: "Further reduced battery usage",
|
||||
description: "More improvements are coming soon!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "arrow.up.doc",
|
||||
title: "Videos and files up to 1gb",
|
||||
description: "Fast and no wait until the sender is online!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "App passcode",
|
||||
description: "Set it instead of system authentication."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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
|
||||
@@ -219,240 +200,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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "face.smiling",
|
||||
title: "Message reactions",
|
||||
description: "Finally, we have them! 🚀"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "arrow.up.message",
|
||||
title: "Better messages",
|
||||
description: "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Self-destruct passcode",
|
||||
description: "All data is erased when it is entered."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "checkmark",
|
||||
title: "Message delivery receipts!",
|
||||
description: "The second tick we missed! ✅"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "star",
|
||||
title: "Find chats faster",
|
||||
description: "Filter unread and favorite chats."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
title: "Keep your connections",
|
||||
description: "Fix encryption after restoring backups."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "stopwatch",
|
||||
title: "Make one message disappear",
|
||||
description: "Even when disabled in the conversation."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "desktopcomputer",
|
||||
title: "New desktop app!",
|
||||
description: "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "lock",
|
||||
title: "Encrypt stored files & media",
|
||||
description: "App encrypts new local files (except videos)."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "theatermasks",
|
||||
title: "Simplified incognito mode",
|
||||
description: "Toggle incognito when connecting."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "desktopcomputer",
|
||||
title: "Link mobile and desktop apps! 🔗",
|
||||
description: "Via secure quantum resistant protocol."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.2",
|
||||
title: "Better groups",
|
||||
description: "Faster joining and more reliable messages."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "theatermasks",
|
||||
title: "Incognito groups",
|
||||
description: "Create a group using a random profile."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "hand.raised",
|
||||
title: "Block group members",
|
||||
description: "To hide unwanted messages."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "folder",
|
||||
title: "Private notes",
|
||||
description: "With encrypted files and media."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "link",
|
||||
title: "Paste link to connect!",
|
||||
description: "Search bar accepts invitation links."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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)."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "battery.50",
|
||||
title: "Improved message delivery",
|
||||
description: "With reduced battery usage."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Enable in direct chats (BETA)!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "tray.and.arrow.up",
|
||||
title: "App data migration",
|
||||
description: "Migrate to another device via QR code."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "phone",
|
||||
title: "Picture-in-picture calls",
|
||||
description: "Use the app while in the call."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "hand.raised",
|
||||
title: "Safer groups",
|
||||
description: "Admins can block a member for all."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "key",
|
||||
title: "Quantum resistant encryption",
|
||||
description: "Will be enabled in direct chats!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "arrowshape.turn.up.forward",
|
||||
title: "Forward and save messages",
|
||||
description: "Message source remains private."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "music.note",
|
||||
title: "In-call sounds",
|
||||
description: "When connecting audio and video calls."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "person.crop.square",
|
||||
title: "Shape profile images",
|
||||
description: "Square, circle, or anything in between."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
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."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "network.badge.shield.half.filled",
|
||||
title: "Safely receive files",
|
||||
description: "Confirm files from unknown servers."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: nil,
|
||||
title: "New chat experience 🎉",
|
||||
description: nil,
|
||||
@@ -463,8 +444,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."),
|
||||
]
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: nil,
|
||||
title: "New media options",
|
||||
description: nil,
|
||||
@@ -473,39 +454,39 @@ private let versionDescriptions: [VersionDescription] = [
|
||||
("play.circle", "Play from the chat list."),
|
||||
("circle.filled.pattern.diagonalline.rectangle", "Blur for better privacy.")
|
||||
]
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "arrow.forward",
|
||||
title: "Private message routing 🚀",
|
||||
description: "It protects your IP address and connections."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
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: [
|
||||
.feature(Description(
|
||||
FeatureDescription(
|
||||
icon: "checkmark.shield",
|
||||
title: "Better security ✅",
|
||||
description: "SimpleX protocols reviewed by Trail of Bits."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "video",
|
||||
title: "Better calls",
|
||||
description: "Switch audio and video during the call."
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: "bolt",
|
||||
title: "Better notifications",
|
||||
description: "Improved delivery, reduced traffic usage.\nMore improvements are coming soon!"
|
||||
)),
|
||||
.feature(Description(
|
||||
),
|
||||
FeatureDescription(
|
||||
icon: nil,
|
||||
title: "Better user experience",
|
||||
description: nil,
|
||||
@@ -516,30 +497,9 @@ 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
|
||||
@@ -554,68 +514,14 @@ 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
|
||||
@@ -626,11 +532,9 @@ struct WhatsNewView: View {
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
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)
|
||||
}
|
||||
ForEach(v.features, id: \.title) { f in
|
||||
featureDescription(f)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
if let post = v.post {
|
||||
Link(destination: post) {
|
||||
@@ -640,20 +544,13 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -671,24 +568,20 @@ struct WhatsNewView: View {
|
||||
currentVersionNav = currentVersion
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
private func featureDescription(_ f: FeatureDescription) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
featureHeader(f.icon, f.title)
|
||||
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()
|
||||
}
|
||||
if let d = f.description {
|
||||
Text(d)
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -743,6 +636,6 @@ struct WhatsNewView: View {
|
||||
|
||||
struct NewFeaturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WhatsNewView(updatedConditions: false)
|
||||
WhatsNewView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 + textSpace + Text("(new)").italic()
|
||||
t = t + Text(" ") + 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 + textSpace + Text("(this device v\(session.appVersion))").italic()
|
||||
t = t + Text(" ") + Text("(this device v\(session.appVersion))").italic()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ 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 {
|
||||
@@ -101,7 +100,6 @@ 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:")) + textSpace + Text("Database IDs and Transport isolation option."))
|
||||
((developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option."))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,435 +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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//
|
||||
// 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([])
|
||||
)
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
@@ -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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -237,17 +237,9 @@ struct NotificationsView: View {
|
||||
|
||||
func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
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."
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-40
@@ -12,15 +12,15 @@ import SimpleXChat
|
||||
struct ProtocolServerView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var server: UserServer
|
||||
@State var serverToEdit: UserServer
|
||||
var backLabel: LocalizedStringKey
|
||||
let serverProtocol: ServerProtocol
|
||||
@Binding var server: ServerCfg
|
||||
@State var serverToEdit: ServerCfg
|
||||
@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,33 +32,9 @@ struct ProtocolServerView: View {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.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")
|
||||
)
|
||||
}
|
||||
.modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) {
|
||||
server = serverToEdit
|
||||
dismiss()
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
@@ -86,7 +62,7 @@ struct ProtocolServerView: View {
|
||||
private func customServer() -> some View {
|
||||
VStack {
|
||||
let serverAddress = parseServerAddress(serverToEdit.server)
|
||||
let valid = serverAddress?.valid == true
|
||||
let valid = serverAddress?.valid == true && serverAddress?.serverProtocol == serverProtocol
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $serverToEdit.server)
|
||||
@@ -136,7 +112,10 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +142,7 @@ struct BackButton: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func showTestStatus(server: UserServer) -> some View {
|
||||
@ViewBuilder func showTestStatus(server: ServerCfg) -> some View {
|
||||
switch server.tested {
|
||||
case .some(true):
|
||||
Image(systemName: "checkmark")
|
||||
@@ -176,7 +155,7 @@ struct BackButton: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFailure? {
|
||||
func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailure? {
|
||||
do {
|
||||
let r = try await testProtoServer(server: server.wrappedValue.server)
|
||||
switch r {
|
||||
@@ -199,11 +178,9 @@ func testServerConnection(server: Binding<UserServer>) async -> ProtocolTestFail
|
||||
struct ProtocolServerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ProtocolServerView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
server: Binding.constant(UserServer.sampleData.custom),
|
||||
serverToEdit: UserServer.sampleData.custom,
|
||||
backLabel: "Your SMP servers"
|
||||
serverProtocol: .smp,
|
||||
server: Binding.constant(ServerCfg.sampleData.custom),
|
||||
serverToEdit: ServerCfg.sampleData.custom
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 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)
|
||||
}
|
||||
}
|
||||
+15
-9
@@ -12,8 +12,8 @@ import CodeScanner
|
||||
|
||||
struct ScanProtocolServer: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var servers: [ServerCfg]
|
||||
@State private var showAddressError = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -28,14 +28,23 @@ 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):
|
||||
var server: UserServer = .empty
|
||||
server.server = r.string
|
||||
addServer(server, $userServers, $serverErrors, dismiss)
|
||||
if parseServerAddress(r.string) != nil {
|
||||
servers.append(ServerCfg(server: r.string, preset: false, tested: nil, enabled: false))
|
||||
dismiss()
|
||||
} else {
|
||||
showAddressError = true
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
@@ -45,9 +54,6 @@ struct ScanProtocolServer: View {
|
||||
|
||||
struct ScanProtocolServer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanProtocolServer(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
)
|
||||
ScanProtocolServer(servers: Binding.constant([]))
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ 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"
|
||||
@@ -51,7 +50,6 @@ 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"
|
||||
@@ -109,7 +107,6 @@ 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,
|
||||
@@ -138,7 +135,6 @@ 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,
|
||||
@@ -193,8 +189,6 @@ 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: [:] )
|
||||
@@ -362,7 +356,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
WhatsNewView(viaSettings: true, updatedConditions: false)
|
||||
WhatsNewView(viaSettings: true)
|
||||
.modifier(ThemedBackground())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} label: {
|
||||
|
||||
@@ -9,94 +9,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserAddressLearnMore: View {
|
||||
@State var showCreateAddressButton = false
|
||||
@State private var createAddressLinkActive = false
|
||||
@State private var createOneTimeLinkActive = false
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
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).")
|
||||
}
|
||||
.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()
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,31 +15,45 @@ 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 {
|
||||
userAddressView()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if progressIndicator {
|
||||
ZStack {
|
||||
@@ -53,10 +67,22 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if chatModel.userAddress == nil, autoCreate {
|
||||
createAddress()
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,18 +94,14 @@ struct UserAddressView: View {
|
||||
aas = AutoAcceptState(userAddress: userAddress)
|
||||
savedAAS = aas
|
||||
}
|
||||
.onChange(of: aas.enable) { _ in
|
||||
if !aas.enable { aas = AutoAcceptState() }
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
createAddressButton()
|
||||
} header: {
|
||||
Text("For social media")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
createOneTimeLinkButton()
|
||||
} header: {
|
||||
Text("Or to share privately")
|
||||
} footer: {
|
||||
Text("Create an address to let people connect with you.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
@@ -95,8 +117,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 {
|
||||
@@ -106,6 +128,7 @@ struct UserAddressView: View {
|
||||
chatModel.userAddress = nil
|
||||
chatModel.updateUser(u)
|
||||
if shareViaProfile {
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = false
|
||||
}
|
||||
}
|
||||
@@ -118,12 +141,37 @@ 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($progressIndicator, true)
|
||||
setProfileAddress(true)
|
||||
ignoreShareViaProfileChange = true
|
||||
shareViaProfile = true
|
||||
}, secondaryButton: .cancel()
|
||||
)
|
||||
@@ -138,39 +186,19 @@ struct UserAddressView: View {
|
||||
SimpleXLinkQRCode(uri: userAddress.connReqContact)
|
||||
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)")
|
||||
shareQRCodeButton(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)
|
||||
}
|
||||
if MFMailComposeViewController.canSendMail() {
|
||||
shareViaEmailButton(userAddress)
|
||||
}
|
||||
addressSettingsButton(userAddress)
|
||||
} header: {
|
||||
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)
|
||||
}
|
||||
|
||||
Section {
|
||||
shareWithContactsButton()
|
||||
autoAcceptToggle()
|
||||
learnMoreButton()
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
if aas.enable {
|
||||
autoAcceptSection()
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -179,47 +207,32 @@ struct UserAddressView: View {
|
||||
Text("Your contacts will remain connected.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
.id(bottomID)
|
||||
}
|
||||
|
||||
private func createAddressButton() -> some View {
|
||||
Button {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
} 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
|
||||
@@ -269,140 +282,24 @@ struct UserAddressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 autoAcceptToggle() -> some View {
|
||||
settingsRow("checkmark", color: theme.colors.secondary) {
|
||||
Toggle("Auto-accept", isOn: $aas.enable)
|
||||
.onChange(of: aas.enable) { _ in
|
||||
saveAAS()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func learnMoreButton() -> some View {
|
||||
NavigationLink {
|
||||
UserAddressLearnMore()
|
||||
.navigationTitle("Address or 1-time link?")
|
||||
.navigationTitle("SimpleX address")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
settingsRow("info.circle", color: theme.colors.secondary) {
|
||||
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()
|
||||
Text("About SimpleX address")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,70 +311,70 @@ struct UserAddressSettingsView: View {
|
||||
if ignoreShareViaProfileChange {
|
||||
ignoreShareViaProfileChange = false
|
||||
} else {
|
||||
if on {
|
||||
showAlert(
|
||||
NSLocalizedString("Share address with contacts?", comment: "alert title"),
|
||||
message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"),
|
||||
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)
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
}
|
||||
alert = .profileAddress(on: on)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private func setProfileAddress(_ on: Bool) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
if let u = try await apiSetProfileAddress(on: on) {
|
||||
DispatchQueue.main.async {
|
||||
chatModel.updateUser(u)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func autoAcceptSection() -> some View {
|
||||
@ViewBuilder private func autoAcceptSection() -> some View {
|
||||
Section {
|
||||
if !aas.business {
|
||||
acceptIncognitoToggle()
|
||||
}
|
||||
acceptIncognitoToggle()
|
||||
welcomeMessageEditor()
|
||||
saveAASButton()
|
||||
.disabled(aas == savedAAS)
|
||||
@@ -517,24 +414,22 @@ struct UserAddressSettingsView: View {
|
||||
private func saveAASButton() -> some View {
|
||||
Button {
|
||||
keyboardVisible = false
|
||||
saveAAS($aas, $savedAAS)
|
||||
saveAAS()
|
||||
} label: {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private func saveAAS() {
|
||||
Task {
|
||||
do {
|
||||
if let address = try await userAddressAutoAccept(aas.autoAccept) {
|
||||
chatModel.userAddress = address
|
||||
savedAAS = aas
|
||||
}
|
||||
} 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 chat profiles", comment: "authentication reason")
|
||||
reason: NSLocalizedString("Change user profiles", comment: "authentication reason")
|
||||
) { laResult in
|
||||
switch laResult {
|
||||
case .success, .unavailable:
|
||||
|
||||
@@ -187,18 +187,23 @@
|
||||
<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. 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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
<target state="translated">** الأكثر خصوصية **: لا تستخدم خادم إشعارات SimpleX Chat ، وتحقق من الرسائل بشكل دوري في الخلفية (يعتمد على عدد مرات استخدامكم للتطبيق).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -212,8 +217,8 @@
|
||||
<target state="translated">** يرجى ملاحظة **: لن تتمكنوا من استعادة أو تغيير عبارة المرور إذا فقدتموها.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<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>
|
||||
<target state="translated">** موصى به **: يتم إرسال رمز الجهاز والإشعارات إلى خادم إشعارات SimpleX Chat ، ولكن ليس محتوى الرسالة أو حجمها أو مصدرها.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1043,8 +1048,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1059,8 +1064,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." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1423,16 +1428,16 @@
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1523,8 +1528,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" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1620,8 +1625,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." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</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">
|
||||
@@ -1921,8 +1926,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." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -1973,8 +1978,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="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -2005,8 +2010,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="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2585,8 +2590,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="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -2617,8 +2622,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 future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private 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">
|
||||
@@ -2681,8 +2686,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 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -2855,8 +2860,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." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -2967,6 +2972,10 @@ 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>
|
||||
@@ -3743,8 +3752,8 @@ SimpleX servers cannot see your profile.</source>
|
||||
<source>%u messages skipped.</source>
|
||||
<target state="translated">%u تم تخطي الرسائل.</target>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<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>
|
||||
<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,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -193,16 +193,20 @@
|
||||
<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. 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -213,8 +217,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 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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -1247,8 +1251,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1267,8 +1271,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." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disappears at" xml:space="preserve">
|
||||
@@ -1747,24 +1751,24 @@
|
||||
<source>Group links</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can add message reactions." xml:space="preserve">
|
||||
<source>Members can add message reactions.</source>
|
||||
<trans-unit id="Group members can add message reactions." xml:space="preserve">
|
||||
<source>Group 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="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1895,8 +1899,8 @@
|
||||
<source>Immediately</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -2016,8 +2020,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." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</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">
|
||||
@@ -2203,8 +2207,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." xml:space="preserve">
|
||||
<source>Message reactions are prohibited.</source>
|
||||
<trans-unit id="Message reactions are prohibited in this group." xml:space="preserve">
|
||||
<source>Message reactions are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Message text" xml:space="preserve">
|
||||
@@ -2235,8 +2239,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">
|
||||
@@ -2405,8 +2409,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." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2473,8 +2477,8 @@
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Opening database…" xml:space="preserve">
|
||||
@@ -2533,8 +2537,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="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -3369,8 +3373,8 @@
|
||||
<source>Thanks to the users – contribute via Weblate!</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -3414,8 +3418,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 future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private 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">
|
||||
@@ -3486,8 +3490,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 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3720,8 +3724,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." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -3872,6 +3876,10 @@ 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,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -186,16 +186,20 @@ 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. 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -206,8 +210,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 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>
|
||||
<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>
|
||||
<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">
|
||||
@@ -1124,8 +1128,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." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1140,8 +1144,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." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1576,16 +1580,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="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1704,8 +1708,8 @@ Available in v5.1</source>
|
||||
<source>Immediately</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Immune to spam" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1817,8 +1821,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." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</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">
|
||||
@@ -2012,8 +2016,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">
|
||||
@@ -2170,8 +2174,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." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2230,8 +2234,8 @@ Available in v5.1</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -2286,8 +2290,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="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2990,8 +2994,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="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -3035,8 +3039,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 future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private 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">
|
||||
@@ -3107,8 +3111,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 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3333,8 +3337,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." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -3474,6 +3478,10 @@ 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,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
@@ -217,18 +217,23 @@ 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. 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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
<target state="translated">**הכי פרטי**: אל תשתמש בשרת ההתראות של SimpleX Chat, בדוק הודעות מעת לעת ברקע (תלוי בתדירות השימוש באפליקציה).</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -242,8 +247,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 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>
|
||||
<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>
|
||||
<target state="translated">**מומלץ**: אסימון מכשיר והתראות נשלחים לשרת ההתראות של SimpleX Chat, אך לא תוכן ההודעה, גודלה או ממי היא.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1386,8 +1391,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." xml:space="preserve" approved="no">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<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>
|
||||
<target state="translated">הודעות ישירות בין חברי קבוצה אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1406,8 +1411,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." xml:space="preserve" approved="no">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<target state="translated">הודעות נעלמות אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -1951,18 +1956,18 @@ Available in v5.1</source>
|
||||
<target state="translated">חברי הקבוצה יכולים למחוק באופן בלתי הפיך הודעות שנשלחו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send direct messages.</source>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות ישירות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות נעלמות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve" approved="no">
|
||||
<source>Members can send voice messages.</source>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve" approved="no">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח הודעות קוליות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2110,8 +2115,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" xml:space="preserve" approved="no">
|
||||
<source>Immune to spam</source>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve" approved="no">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<target state="translated">חסין מפני ספאם ושימוש לרעה</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2252,8 +2257,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." xml:space="preserve" approved="no">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<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>
|
||||
<target state="translated">מחיקה בלתי הפיכה של הודעות אסורה בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -2497,9 +2502,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">
|
||||
@@ -2696,8 +2701,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." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -2756,8 +2761,8 @@ Available in v5.1</source>
|
||||
<source>Open user profiles</source>
|
||||
<note>authentication reason</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -2812,8 +2817,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="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -3516,8 +3521,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="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -3561,8 +3566,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 future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private 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">
|
||||
@@ -3633,8 +3638,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 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -3859,8 +3864,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." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -4000,6 +4005,10 @@ 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>
|
||||
@@ -4958,8 +4967,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." xml:space="preserve" approved="no">
|
||||
<source>Files and media are prohibited.</source>
|
||||
<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>
|
||||
<target state="translated">קבצים ומדיה אסורים בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -5018,13 +5027,13 @@ SimpleX servers cannot see your profile.</source>
|
||||
<target state="translated">הזמן חברים</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can add message reactions." xml:space="preserve" approved="no">
|
||||
<source>Members can add message reactions.</source>
|
||||
<trans-unit id="Group members can add message reactions." xml:space="preserve" approved="no">
|
||||
<source>Group members can add message reactions.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים להוסיף תגובות אמוג׳י להודעות.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send files and media." xml:space="preserve" approved="no">
|
||||
<source>Members can send files and media.</source>
|
||||
<trans-unit id="Group members can send files and media." xml:space="preserve" approved="no">
|
||||
<source>Group members can send files and media.</source>
|
||||
<target state="translated">חברי הקבוצה יכולים לשלוח קבצים ומדיה.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
@@ -5222,8 +5231,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." xml:space="preserve" approved="no">
|
||||
<source>Message reactions are prohibited.</source>
|
||||
<trans-unit id="Message reactions are prohibited in this group." xml:space="preserve" approved="no">
|
||||
<source>Message reactions are prohibited in this group.</source>
|
||||
<target state="translated">תגובות אמוג׳י להודעות אסורות בקבוצה זו.</target>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
|
||||
@@ -181,18 +181,23 @@
|
||||
<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. 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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -206,8 +211,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 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -1034,8 +1039,8 @@
|
||||
<source>Direct messages</source>
|
||||
<note>chat feature</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Direct messages between members are prohibited." xml:space="preserve">
|
||||
<source>Direct messages between members are prohibited.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disable SimpleX Lock" xml:space="preserve">
|
||||
@@ -1050,8 +1055,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." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited.</source>
|
||||
<trans-unit id="Disappearing messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Disappearing messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Disconnect" xml:space="preserve">
|
||||
@@ -1414,16 +1419,16 @@
|
||||
<source>Group members can irreversibly delete sent messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send direct messages." xml:space="preserve">
|
||||
<source>Members can send direct messages.</source>
|
||||
<trans-unit id="Group members can send direct messages." xml:space="preserve">
|
||||
<source>Group members can send direct messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send disappearing messages." xml:space="preserve">
|
||||
<source>Members can send disappearing messages.</source>
|
||||
<trans-unit id="Group members can send disappearing messages." xml:space="preserve">
|
||||
<source>Group members can send disappearing messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Members can send voice messages." xml:space="preserve">
|
||||
<source>Members can send voice messages.</source>
|
||||
<trans-unit id="Group members can send voice messages." xml:space="preserve">
|
||||
<source>Group members can send voice messages.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Group message:" xml:space="preserve">
|
||||
@@ -1514,8 +1519,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" xml:space="preserve">
|
||||
<source>Immune to spam</source>
|
||||
<trans-unit id="Immune to spam and abuse" xml:space="preserve">
|
||||
<source>Immune to spam and abuse</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Import" xml:space="preserve">
|
||||
@@ -1611,8 +1616,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." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited.</source>
|
||||
<trans-unit id="Irreversible message deletion is prohibited in this group." xml:space="preserve">
|
||||
<source>Irreversible message deletion is prohibited in this group.</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">
|
||||
@@ -1912,8 +1917,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." xml:space="preserve">
|
||||
<source>Only client devices store user profiles, contacts, groups, and messages.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Only group owners can change group preferences." xml:space="preserve">
|
||||
@@ -1960,8 +1965,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="Anybody can host servers." xml:space="preserve">
|
||||
<source>Anybody can host servers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -1992,8 +1997,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="You decide who can connect." xml:space="preserve">
|
||||
<source>You decide who can connect.</source>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Periodically" xml:space="preserve">
|
||||
@@ -2572,8 +2577,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="No user identifiers." xml:space="preserve">
|
||||
<source>No user identifiers.</source>
|
||||
<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>
|
||||
<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">
|
||||
@@ -2604,8 +2609,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 future of messaging" xml:space="preserve">
|
||||
<source>The future of messaging</source>
|
||||
<trans-unit id="The next generation of private messaging" xml:space="preserve">
|
||||
<source>The next generation of private 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">
|
||||
@@ -2668,8 +2673,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 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>
|
||||
<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>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="To protect timezone, image/voice files use UTC." xml:space="preserve">
|
||||
@@ -2842,8 +2847,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." xml:space="preserve">
|
||||
<source>Voice messages are prohibited.</source>
|
||||
<trans-unit id="Voice messages are prohibited in this group." xml:space="preserve">
|
||||
<source>Voice messages are prohibited in this group.</source>
|
||||
<note>No comment provided by engineer.</note>
|
||||
</trans-unit>
|
||||
<trans-unit id="Voice messages prohibited!" xml:space="preserve">
|
||||
@@ -2954,6 +2959,10 @@ 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,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
/* 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*";
|
||||
|
||||
@@ -24,3 +27,4 @@
|
||||
|
||||
/* No comment provided by engineer. */
|
||||
"No group!" = "Group not found!";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user