Compare commits

..

5 Commits

Author SHA1 Message Date
Levitating Pineapple 08db2852f3 cleanup 2024-11-21 15:50:18 +02:00
Levitating Pineapple 7d23ed3086 implement format menu 2024-11-18 22:10:52 +02:00
Levitating Pineapple 9e1c543c51 Merge branch 'master' into lp/markdown-compose 2024-11-15 13:57:32 +02:00
Levitating Pineapple c52065b5ef add encode stub 2024-11-15 13:57:04 +02:00
Levitating Pineapple c0e673d63f ios: add markdown format menu to compose 2024-11-01 13:55:46 +02:00
419 changed files with 15117 additions and 37311 deletions
+11 -11
View File
@@ -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
+7 -5
View File
@@ -233,8 +233,6 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
@@ -243,14 +241,20 @@ Recent and important updates:
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md).
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
[All updates](./blog)
## :zap: Quick installation of a terminal app
@@ -378,11 +382,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Improve sending videos (including encryption of locally stored videos).
- ✅ Post-quantum resistant key exchange in double ratchet protocol.
- ✅ Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- ✅ Support multiple network operators in the app.
- 🏗 Large groups, communities and public channels.
- 🏗 Short links to connect and join groups.
- 🏗 Improve stability and reduce battery usage.
- 🏗 Improve experience for the new users.
- 🏗 Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address.
-1
View File
@@ -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
}
}
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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+5 -48
View File
@@ -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 {
+2 -4
View File
@@ -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
}
+1 -5
View File
@@ -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)
}
}
}
+38 -110
View File
@@ -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 {
+1 -2
View File
@@ -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)
+25 -106
View File
@@ -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
@@ -156,8 +154,8 @@ struct ChatInfoView: View {
HStack(alignment: .center, spacing: 8) {
let buttonWidth = g.size.width / 4
searchButton(width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
muteButton(width: buttonWidth)
}
}
@@ -314,15 +312,7 @@ struct ChatInfoView: View {
case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert:
return syncConnectionForceAlert({
Task {
if let stats = await syncContactConnection(contact, force: true, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
})
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case let .queueInfo(info): return queueInfoAlert(info)
case let .someAlert(a): return a.alert
case let .error(title, error): return mkAlert(title: title, message: error)
@@ -337,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 {
@@ -359,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)
)
@@ -467,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))
@@ -501,12 +480,7 @@ struct ChatInfoView: View {
private func synchronizeConnectionButton() -> some View {
Button {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: { alert = .someAlert(alert: $0) }) {
connectionStats = stats
dismiss()
}
}
syncContactConnection(force: false)
} label: {
Label("Fix connection", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
.foregroundColor(.orange)
@@ -625,50 +599,29 @@ struct ChatInfoView: View {
}
}
private func savePreferences() {
private func syncContactConnection(force: Bool) {
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
}
let stats = try apiSyncContactRatchet(contact.apiId, force)
connectionStats = stats
await MainActor.run {
chatModel.updateContactConnectionStats(contact, stats)
dismiss()
}
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
} catch {
logger.error("ContactPreferencesView apiSetContactPrefs error: \(responseError(error))")
}
}
}
}
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
do {
let stats = try apiSyncContactRatchet(contact.apiId, force)
await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats)
}
return stats
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
let a = getErrorAlert(error, "Error synchronizing connection")
await MainActor.run {
showAlert(
SomeAlert(
alert: mkAlert(title: a.title, message: a.message),
id: "syncContactConnection error"
)
)
}
return nil
}
}
struct AudioCallButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -676,7 +629,6 @@ struct AudioCallButton: View {
CallButton(
chat: chat,
contact: contact,
connectionStats: $connectionStats,
image: "phone.fill",
title: "call",
mediaType: .audio,
@@ -689,7 +641,6 @@ struct AudioCallButton: View {
struct VideoButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var width: CGFloat
var showAlert: (SomeAlert) -> Void
@@ -697,7 +648,6 @@ struct VideoButton: View {
CallButton(
chat: chat,
contact: contact,
connectionStats: $connectionStats,
image: "video.fill",
title: "video",
mediaType: .video,
@@ -710,7 +660,6 @@ struct VideoButton: View {
private struct CallButton: View {
var chat: Chat
var contact: Contact
@Binding var connectionStats: ConnectionStats?
var image: String
var title: LocalizedStringKey
var mediaType: CallMediaType
@@ -722,40 +671,12 @@ private struct CallButton: View {
InfoViewButton(image: image, title: title, disabledLook: !canCall, width: width) {
if canCall {
if let connStats = connectionStats {
if connStats.ratchetSyncState == .ok {
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if connStats.ratchetSyncAllowed {
showAlert(SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
Task {
if let stats = await syncContactConnection(contact, force: false, showAlert: showAlert) {
connectionStats = stats
}
}
},
secondaryButton: .cancel()
),
id: "can't call contact, fix connection"
))
} else {
showAlert(SomeAlert(
alert: mkAlert(
title: "Can't call contact",
message: "Encryption renegotiation in progress."
),
id: "can't call contact, encryption renegotiation in progress"
))
if CallController.useCallKit() {
CallController.shared.startCall(contact, mediaType)
} else {
// When CallKit is not used, colorscheme will be changed and it will be visible if not hiding sheets first
dismissAllSheets(animated: true) {
CallController.shared.startCall(contact, mediaType)
}
}
} else if contact.nextSendGrpInv {
@@ -1252,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 {
+12 -130
View File
@@ -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,15 +14,11 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@State var groupInfo: GroupInfo
@ObservedObject var chat: Chat
@ObservedObject var groupMember: GMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var connectionLoaded: Bool = false
@State private var knownContactChat: Chat? = nil
@State private var knownContact: Contact? = nil
@State private var knownContactConnectionStats: ConnectionStats? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@@ -122,8 +118,8 @@ struct GroupMemberInfoView: View {
} label: {
Label("Share address", systemImage: "square.and.arrow.up")
}
if member.memberContactId != nil {
if knownContactChat == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
connectViaAddressButton(contactLink)
}
} else {
@@ -139,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) {
@@ -232,18 +227,6 @@ struct GroupMemberInfoView: View {
}
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
if let contactId = member.memberContactId, let (contactChat, contact) = knownDirectChat(contactId) {
knownContactChat = contactChat
knownContact = contact
do {
let (stats, _) = try await apiContactInfo(contactChat.chatInfo.apiId)
await MainActor.run {
knownContactConnectionStats = stats
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
}
}
}
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
@@ -277,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))
}
@@ -289,10 +267,10 @@ struct GroupMemberInfoView: View {
GeometryReader { g in
let buttonWidth = g.size.width / 4
HStack(alignment: .center, spacing: 8) {
if let chat = knownContactChat, let contact = knownContact {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat, width: buttonWidth)
AudioCallButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, connectionStats: $knownContactConnectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
AudioCallButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
VideoButton(chat: chat, contact: contact, width: buttonWidth) { alert = .someAlert(alert: $0) }
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
@@ -327,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"
))
@@ -381,49 +354,25 @@ struct GroupMemberInfoView: View {
func createMemberContactButton(width: CGFloat) -> some View {
InfoViewButton(image: "message.fill", title: "message", width: width) {
if let connStats = connectionStats {
if connStats.ratchetSyncState == .ok {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true)
}
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
} else if connStats.ratchetSyncAllowed {
alert = .someAlert(alert: SomeAlert(
alert: Alert(
title: Text("Fix connection?"),
message: Text("Connection requires encryption renegotiation."),
primaryButton: .default(Text("Fix")) {
syncMemberConnection(force: false)
},
secondaryButton: .cancel()
),
id: "can't message member, fix connection"
))
} else {
alert = .someAlert(alert: SomeAlert(
alert: mkAlert(
title: "Can't message member",
message: "Encryption renegotiation in progress."
),
id: "can't message contact, encryption renegotiation in progress"
))
}
}
}
@@ -439,7 +388,7 @@ struct GroupMemberInfoView: View {
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
+ Text(" ")
+ Text(mem.displayName)
.font(.largeTitle)
)
@@ -588,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 {
@@ -618,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 {
@@ -634,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))")
@@ -803,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 {
@@ -587,7 +596,7 @@ struct SMPStatsView: View {
} header: {
Text("Statistics")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
}
}
}
@@ -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 {
@@ -703,7 +721,7 @@ struct XFTPStatsView: View {
} header: {
Text("Statistics")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.")
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is private to your device.")
}
}
}
@@ -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 {
+123 -227
View File
@@ -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)
+32 -41
View File
@@ -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."
}
}
@@ -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)
}
}
@@ -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).&#10;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).&#10;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).&#10;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