From 995863d78bd4105a5dba39bdc35939bf27af052d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 May 2024 10:22:03 +0400 Subject: [PATCH] ios: contacts UI improvements --- PRIVACY.md | 7 +- apps/ios/Shared/Model/SimpleXAPI.swift | 42 ++- .../Chat/ChatItem/CIGroupInvitationView.swift | 6 +- .../Views/Chat/ChatItem/CIMetaView.swift | 22 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 6 +- .../Views/Chat/ChatItem/FramedItemView.swift | 24 +- .../Views/Chat/ChatItem/MsgContentView.swift | 4 +- .../Shared/Views/Chat/ChatItemInfoView.swift | 12 +- apps/ios/Shared/Views/Chat/ChatView.swift | 1 + .../Chat/ComposeMessage/ComposeView.swift | 9 +- .../Chat/ComposeMessage/SendMessageView.swift | 3 +- .../Views/Chat/Contacts/ContactsView.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Views/ChatList/ChatPreviewView.swift | 6 +- apps/ios/Shared/Views/Home/HomeView.swift | 74 ++--- .../UserSettings/AppearanceSettings.swift | 12 +- .../Views/UserSettings/DeveloperView.swift | 17 +- .../UserSettings/NetworkAndServers.swift | 96 ++++++- .../Views/UserSettings/SettingsView.swift | 6 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 64 ++--- apps/ios/SimpleXChat/APITypes.swift | 75 ++++- apps/ios/SimpleXChat/AppGroup.swift | 22 ++ apps/ios/SimpleXChat/ChatTypes.swift | 68 ++++- .../chat/simplex/common/model/ChatModel.kt | 80 +++++- .../chat/simplex/common/model/SimpleXAPI.kt | 84 +++++- .../common/views/chat/ChatItemInfoView.kt | 22 +- .../simplex/common/views/chat/ChatView.kt | 9 +- .../simplex/common/views/chat/ComposeView.kt | 9 +- .../simplex/common/views/chat/SendMsgView.kt | 2 +- .../common/views/chat/item/CICallItemView.kt | 2 +- .../common/views/chat/item/CIFileView.kt | 22 +- .../views/chat/item/CIGroupInvitationView.kt | 2 +- .../common/views/chat/item/CIMetaView.kt | 53 +++- .../views/chat/item/CIRcvDecryptionError.kt | 4 +- .../common/views/chat/item/CIVoiceView.kt | 8 +- .../common/views/chat/item/ChatItemView.kt | 50 ++-- .../common/views/chat/item/DeletedItemView.kt | 7 +- .../common/views/chat/item/EmojiItemView.kt | 4 +- .../common/views/chat/item/FramedItemView.kt | 27 +- .../views/chat/item/IntegrityErrorItemView.kt | 2 +- .../views/chat/item/MarkedDeletedItemView.kt | 7 +- .../common/views/chat/item/TextItemView.kt | 5 +- .../usersettings/AdvancedNetworkSettings.kt | 2 + .../views/usersettings/NetworkAndServers.kt | 153 +++++++++- .../commonMain/resources/MR/base/strings.xml | 33 +++ .../resources/MR/images/ic_arrow_forward.svg | 1 + .../MR/images/ic_arrows_left_right.svg | 4 + apps/multiplatform/gradle.properties | 8 +- .../src/Directory/Events.hs | 66 +++-- .../src/Directory/Service.hs | 264 ++++++++++-------- ...221206-simplex-chat-v4.3-voice-messages.md | 2 +- ...vision-funding-v5-videos-files-passcode.md | 2 +- ...local-file-encryption-directory-service.md | 2 +- ...desktop-quantum-resistant-better-groups.md | 2 +- ...istance-signal-double-ratchet-algorithm.md | 4 +- ...40416-dangers-of-metadata-in-messengers.md | 6 +- ...ransparency-v5-7-better-user-experience.md | 10 +- ...simplex-redefining-privacy-hard-choices.md | 79 ++++++ blog/README.md | 14 + blog/images/20240314-comparison.jpg | Bin 142671 -> 142707 bytes blog/images/20240516-parliament.jpg | Bin 0 -> 114710 bytes blog/images/simplex-explained.png | Bin 0 -> 66138 bytes blog/images/simplex-explained.svg | 82 ++++++ blog/lang/fr-fr/README_fr.md | 40 +-- cabal.project | 2 +- docs/CLI.md | 2 +- docs/SECURITY.md | 2 +- docs/lang/cs/CLI.md | 2 +- docs/lang/cs/README.md | 38 +-- docs/lang/cs/SERVER.md | 2 +- docs/lang/fr/CLI.md | 2 +- docs/lang/pl/CLI.md | 10 +- docs/lang/pl/README.md | 72 ++--- docs/lang/pl/SERVER.md | 2 +- docs/lang/pl/SIMPLEX.md | 2 +- package.yaml | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 3 +- src/Simplex/Chat.hs | 108 ++++--- src/Simplex/Chat/AppSettings.hs | 2 +- src/Simplex/Chat/Controller.hs | 13 +- src/Simplex/Chat/Messages.hs | 89 +++++- .../M20240510_chat_items_via_proxy.hs | 20 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/Messages.hs | 56 ++-- src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/View.hs | 2 + tests/ChatClient.hs | 7 +- tests/ChatTests/Direct.hs | 18 +- tests/ChatTests/Groups.hs | 27 +- tests/ChatTests/Profiles.hs | 34 ++- tests/ChatTests/Utils.hs | 2 +- website/.eleventy.js | 39 ++- .../src/_includes/blog_previews/20240516.html | 17 ++ website/src/call/call.js | 3 +- 95 files changed, 1716 insertions(+), 626 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg create mode 100644 blog/20240516-simplex-redefining-privacy-hard-choices.md create mode 100644 blog/images/20240516-parliament.jpg create mode 100644 blog/images/simplex-explained.png create mode 100644 blog/images/simplex-explained.svg create mode 100644 src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs create mode 100644 website/src/_includes/blog_previews/20240516.html diff --git a/PRIVACY.md b/PRIVACY.md index 3b4ba2ba8a..66dff0e807 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,5 +1,6 @@ --- layout: layouts/privacy.html +permalink: /privacy/index.html --- # SimpleX Chat Privacy Policy and Conditions of Use @@ -10,7 +11,7 @@ SimpleX Chat communication protocol is the first protocol that has no user profi Double ratchet algorithm has such important properties as [forward secrecy](/docs/GLOSSARY.md#forward-secrecy), sender [repudiation](/docs/GLOSSARY.md#) and break-in recovery (also known as [post-compromise security](/docs/GLOSSARY.md#post-compromise-security)). -If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +If you believe that any part of this document is not aligned with our mission or values, please raise it with us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). ## Privacy Policy @@ -78,7 +79,7 @@ When you choose to use instant push notifications in SimpleX iOS app, because th Preset notification server cannot observe the actual addresses of these queues, as a separate address is used to subscribe to the notifications. It also cannot observe who sends messages to you. Apple push notifications servers can only observe how many notifications are sent to you, but not from how many contacts, or from which messaging relays, as notifications are delivered to your device end-to-end encrypted by one of the preset notification servers - these notifications only contain end-to-end encrypted metadata, not even encrypted message content, and they look completely random to Apple push notification servers. -You can read more about the design of iOS push notifications [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html#our-ios-approach-has-one-trade-off). +You can read more about the design of iOS push notifications [here](./blog/20220404-simplex-chat-instant-notifications.md#our-ios-approach-has-one-trade-off). #### Another information stored on the servers @@ -115,7 +116,7 @@ We will update this Privacy Policy as needed so that it is current, accurate, an Please also read our Conditions of Use of Software and Infrastructure below. -If you have questions about our Privacy Policy please contact us via [email](chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). +If you have questions about our Privacy Policy please contact us via [email](mailto:chat@simplex.chat) or [chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). ## Conditions of Use of Software and Infrastructure diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7b85d4e7e8..92fa3df24d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -341,8 +341,8 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } -func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) async -> ChatItem? { - let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId) +func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? { + let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -1903,21 +1903,35 @@ func processReceivedMsg(_ res: ChatResponse) async { } case .chatSuspended: chatSuspended() - case let .contactSwitch(_, contact, switchProgress): - await MainActor.run { - m.updateContactConnectionStats(contact, switchProgress.connectionStats) + case let .contactSwitch(user, contact, switchProgress): + if active(user) { + await MainActor.run { + m.updateContactConnectionStats(contact, switchProgress.connectionStats) + } } - case let .groupMemberSwitch(_, groupInfo, member, switchProgress): - await MainActor.run { - m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats) + case let .groupMemberSwitch(user, groupInfo, member, switchProgress): + if active(user) { + await MainActor.run { + m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats) + } } - case let .contactRatchetSync(_, contact, ratchetSyncProgress): - await MainActor.run { - m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats) + case let .contactRatchetSync(user, contact, ratchetSyncProgress): + if active(user) { + await MainActor.run { + m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats) + } } - case let .groupMemberRatchetSync(_, groupInfo, member, ratchetSyncProgress): - await MainActor.run { - m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) + case let .groupMemberRatchetSync(user, groupInfo, member, ratchetSyncProgress): + if active(user) { + await MainActor.run { + m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats) + } + } + case let .contactDisabled(user, contact): + if active(user) { + await MainActor.run { + m.updateContact(contact) + } } case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 1c9df5fcbf..2f92f778c3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -21,6 +21,8 @@ struct CIGroupInvitationView: View { @State private var inProgress = false @State private var progressByTimeout = false + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + var body: some View { let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let v = ZStack(alignment: .bottomTrailing) { @@ -43,7 +45,7 @@ struct CIGroupInvitationView: View { .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) ) .overlay(DetermineWidth()) } @@ -51,7 +53,7 @@ struct CIGroupInvitationView: View { ( groupInvitationText() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) ) .overlay(DetermineWidth()) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 17b93930fe..24c2c07962 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -17,6 +17,8 @@ struct CIMetaView: View { var showStatus = true var showEdited = true + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + var body: some View { if chatItem.isDeletedContent { chatItem.timestampText.font(.caption).foregroundColor(metaColor) @@ -27,24 +29,24 @@ struct CIMetaView: View { switch meta.itemStatus { case let .sndSent(sndProgress): switch sndProgress { - case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited) - case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited) + case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) } case let .sndRcvd(_, sndProgress): switch sndProgress { case .complete: ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) } case .partial: ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) } } default: - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) } } } @@ -64,7 +66,8 @@ func ciMetaText( transparent: Bool = false, sent: SentCheckmark? = nil, showStatus: Bool = true, - showEdited: Bool = true + showEdited: Bool = true, + showViaProxy: Bool ) -> Text { var r = Text("") if showEdited, meta.itemEdited { @@ -78,6 +81,9 @@ func ciMetaText( } r = r + Text(" ") } + if showViaProxy, meta.sentViaProxy == true { + r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2) + } if showStatus { if let (icon, statusColor) = meta.statusIcon(color) { let t = Text(Image(systemName: icon)).font(.caption2) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 3ad45d6987..da9d5e7d50 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -19,6 +19,8 @@ struct CIRcvDecryptionError: View { var chatItem: ChatItem @State private var alert: CIRcvDecryptionErrorAlert? + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + enum CIRcvDecryptionErrorAlert: Identifiable { case syncAllowedAlert(_ syncConnection: () -> Void) case syncNotSupportedContactAlert @@ -119,7 +121,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(syncSupported ? .accentColor : .secondary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) ) } .padding(.horizontal, 12) @@ -140,7 +142,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(.red) .italic() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) } .padding(.horizontal, 12) CIMetaView(chat: chat, chatItem: chatItem) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 95c3347f90..a1642769b3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -87,12 +87,17 @@ struct FramedItemView: View { .cornerRadius(18) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } - switch chatItem.meta.itemStatus { - case .sndErrorAuth: - v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") } - case let .sndError(agentError): - v.onTapGesture { msgDeliveryError("Unexpected error: \(agentError)") } - default: v + if let (title, text) = chatItem.meta.itemStatus.statusInfo { + v.onTapGesture { + AlertManager.shared.showAlert( + Alert( + title: Text(title), + message: Text(text) + ) + ) + } + } else { + v } } @@ -157,13 +162,6 @@ struct FramedItemView: View { } } } - - private func msgDeliveryError(_ err: LocalizedStringKey) { - AlertManager.shared.showAlertMsg( - title: "Message delivery error", - message: err - ) - } @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index ccd7ac0a12..11e94cb2c9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -35,6 +35,8 @@ struct MsgContentView: View { @State private var typingIdx = 0 @State private var timer: Timer? + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false + var body: some View { if meta?.isLive == true { msgContentView() @@ -81,7 +83,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true) + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 19aa261396..29bfdb6288 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -383,7 +383,7 @@ struct ChatItemInfoView: View { let mss = membersStatuses(memberDeliveryStatuses) if !mss.isEmpty { ForEach(mss, id: \.0.groupMemberId) { memberStatus in - memberDeliveryStatusView(memberStatus.0, memberStatus.1) + memberDeliveryStatusView(memberStatus.0, memberStatus.1, memberStatus.2) } } else { Text("No delivery information") @@ -392,23 +392,27 @@ struct ChatItemInfoView: View { } } - private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { + private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] { memberDeliveryStatuses.compactMap({ mds in if let mem = chatModel.getGroupMember(mds.groupMemberId) { - return (mem.wrapped, mds.memberDeliveryStatus) + return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy) } else { return nil } }) } - private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus) -> some View { + private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View { HStack{ ProfileImage(imageStr: member.image, size: 30) .padding(.trailing, 2) Text(member.chatViewName) .lineLimit(1) Spacer() + if sentViaProxy == true { + Image(systemName: "arrow.forward") + .foregroundColor(.secondary).opacity(0.67) + } let v = Group { if let (icon, statusColor) = status.statusIcon(Color.secondary) { switch status { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 1f51af7f64..aafafbbc80 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -42,6 +42,7 @@ struct ChatView: View { var body: some View { if #available(iOS 16.0, *) { viewBody + .toolbarBackground(.visible, for: .navigationBar) .scrollDismissesKeyboard(.immediately) .keyboardPadding() } else { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6cf9df782b..3f1824cd6a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -712,9 +712,9 @@ struct ComposeView: View { if chat.chatInfo.contact?.nextSendGrpInv ?? false { await sendMemberContactInvitation() } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem { - sent = await forwardItem(ci, fromChatInfo) + sent = await forwardItem(ci, fromChatInfo, ttl) if !composeState.message.isEmpty { - sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: nil) + sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: ttl) } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) @@ -890,13 +890,14 @@ struct ComposeView: View { return nil } - func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo) async -> ChatItem? { + func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? { if let chatItem = await apiForwardChatItem( toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, - itemId: forwardedItem.id + itemId: forwardedItem.id, + ttl: ttl ) { await MainActor.run { chatModel.addChatItem(chat.chatInfo, chatItem) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8b528a201c..a180efbd28 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -224,8 +224,7 @@ struct SendMessageView: View { @ViewBuilder private func sendButtonContextMenuItems() -> some View { if composeState.liveMessage == nil, - !composeState.editing, - !composeState.forwarding { + !composeState.editing { if case .noContextItem = composeState.contextItem, !composeState.voicePreview, let send = sendLiveMessage, diff --git a/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift b/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift index 91b6104bb4..4959ad774c 100644 --- a/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift +++ b/apps/ios/Shared/Views/Chat/Contacts/ContactsView.swift @@ -17,7 +17,7 @@ struct ContactsView: View { @State private var searchText = "" @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false - @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false var body: some View { if #available(iOS 16.0, *) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index f9d389025e..da8d1723f6 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -19,7 +19,7 @@ struct ChatListView: View { @State private var searchChatFilteredBySimplexLink: String? = nil @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false - @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false var body: some View { if #available(iOS 16.0, *) { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 1096481f42..6bcbc6db61 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -240,14 +240,14 @@ struct ChatPreviewView: View { private func itemStatusMark(_ cItem: ChatItem) -> Text { switch cItem.meta.itemStatus { - case .sndErrorAuth: + case .sndErrorAuth, .sndError: return Text(Image(systemName: "multiply")) .font(.caption) .foregroundColor(.red) + Text(" ") - case .sndError: + case .sndWarning: return Text(Image(systemName: "exclamationmark.triangle.fill")) .font(.caption) - .foregroundColor(.yellow) + Text(" ") + .foregroundColor(.orange) + Text(" ") default: return Text("") } } diff --git a/apps/ios/Shared/Views/Home/HomeView.swift b/apps/ios/Shared/Views/Home/HomeView.swift index ec961db59e..90f9974ef2 100644 --- a/apps/ios/Shared/Views/Home/HomeView.swift +++ b/apps/ios/Shared/Views/Home/HomeView.swift @@ -21,8 +21,9 @@ struct HomeView: View { @State private var userPickerVisible = false @State private var showConnectDesktop = false @State private var newChatMenuOption: NewChatMenuOption? = nil + @State private var toolbarHeight: CGFloat = 0 - @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false var body: some View { ZStack(alignment: .bottomLeading) { @@ -31,32 +32,9 @@ struct HomeView: View { get: { chatModel.chatId != nil }, set: { _ in } ), - destination: chatView - ) { - VStack { - switch homeTab { - case .contacts: - contactsView() - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Contacts") - case .chats: - chatListView() - .navigationBarTitleDisplayMode(.inline) - .navigationTitle("Chats") - } - } - .toolbar { - ToolbarItemGroup(placement: .bottomBar) { - settingsButton() - Spacer() - contactsButton() - Spacer() - chatsButton() - Spacer() - newChatButton() - } - } - } + destination: chatView, + content: homeView + ) if userPickerVisible { Rectangle().fill(.white.opacity(0.001)).onTapGesture { @@ -76,6 +54,40 @@ struct HomeView: View { } } + @ViewBuilder private func homeView() -> some View { + let v = VStack { + switch homeTab { + case .contacts: withToolbar("Contacts", contactsView) + case .chats: withToolbar("Chats", chatListView) + } + } + .toolbar { + ToolbarItemGroup(placement: .bottomBar) { + settingsButton() + Spacer() + contactsButton() + Spacer() + chatsButton() + Spacer() + newChatButton() + } + } + + if #unavailable(iOS 16) { + v + } else if oneHandUI { + v.toolbarBackground(.visible, for: .navigationBar, .bottomBar) + } else { + v.toolbarBackground(.visible, for: .bottomBar) + } + } + + func withToolbar(_ title: LocalizedStringKey, _ content: () -> V) -> some View { + content() + .navigationBarTitleDisplayMode(oneHandUI ? .inline : .large) + .navigationTitle(title) + } + @ViewBuilder private func settingsButton() -> some View { let user = chatModel.currentUser ?? User.sampleData let multiUser = chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 @@ -90,8 +102,8 @@ struct HomeView: View { } label: { VStack(spacing: 2) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: user.image, size: 38, color: Color(uiColor: .quaternaryLabel)) - .padding(.top, 2) + ProfileImage(imageStr: user.image, size: 42, color: Color(uiColor: .quaternaryLabel)) + .padding(.top, 3) .padding(.trailing, 3) let allRead = chatModel.users .filter { u in !u.user.activeUser && !u.user.hidden } @@ -115,7 +127,7 @@ struct HomeView: View { Button { homeTab = .contacts } label: { - iconLabel(homeTab == .contacts ? "book.fill" : "book", "Contacts") + iconLabel(homeTab == .contacts ? "person.crop.circle.fill" : "person.crop.circle", "Contacts") } .foregroundColor(.secondary) } @@ -196,13 +208,11 @@ struct HomeView: View { @ViewBuilder private func contactsView() -> some View { ContactsView() - .padding(.vertical, oneHandUI ? 1 : 0) } @ViewBuilder private func chatListView() -> some View { // TODO reverse scale effect for swipe actions ChatListView() - .padding(.vertical, oneHandUI ? 1 : 0) } @ViewBuilder private func chatView() -> some View { diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index ccc1b174d7..a8fb14d591 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -25,7 +25,7 @@ struct AppearanceSettings: View { @State private var userInterfaceStyle = getUserInterfaceStyleDefault() @State private var uiTintColor = getUIAccentColorDefault() @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner - @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = true + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false var body: some View { VStack{ @@ -40,11 +40,11 @@ struct AppearanceSettings: View { } } - Section("Interface") { - settingsRow("hand.wave") { - Toggle("One-hand UI", isOn: $oneHandUI) - } - } + // Section("Interface") { + // settingsRow("hand.wave") { + // Toggle("One-hand UI", isOn: $oneHandUI) + // } + // } Section("App icon") { HStack { diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 3bbfbfe33e..a4ef99a189 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct DeveloperView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false + @AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false @Environment(\.colorScheme) var colorScheme var body: some View { @@ -31,9 +32,6 @@ struct DeveloperView: View { } label: { settingsRow("terminal") { Text("Chat console") } } - settingsRow("internaldrive") { - Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) - } settingsRow("chevron.left.forwardslash.chevron.right") { Toggle("Show developer options", isOn: $developerTools) } @@ -42,6 +40,19 @@ struct DeveloperView: View { } footer: { (developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.") } + + if developerTools { + Section { + settingsRow("internaldrive") { + Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades) + } + settingsRow("hand.wave") { + Toggle("One-hand UI", isOn: $oneHandUI) + } + } header: { + Text("Developer options") + } + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index a6702b1821..54a4ef1489 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -12,12 +12,16 @@ import SimpleXChat private enum NetworkAlert: Identifiable { case updateOnionHosts(hosts: OnionHosts) case updateSessionMode(mode: TransportSessionMode) + case updateSMPProxyMode(proxyMode: SMPProxyMode) + case updateSMPProxyFallback(proxyFallback: SMPProxyFallback) case error(err: String) var id: String { switch self { case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)" case let .updateSessionMode(mode): return "updateSessionMode \(mode)" + case let .updateSMPProxyMode(proxyMode): return "updateSMPProxyMode \(proxyMode)" + case let .updateSMPProxyFallback(proxyFallback): return "updateSMPProxyFallback \(proxyFallback)" case let .error(err): return "error \(err)" } } @@ -26,11 +30,14 @@ private enum NetworkAlert: Identifiable { struct NetworkAndServers: View { @EnvironmentObject var m: ChatModel @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = true @State private var cfgLoaded = false @State private var currentNetCfg = NetCfg.defaults @State private var netCfg = NetCfg.defaults @State private var onionHosts: OnionHosts = .no @State private var sessionMode: TransportSessionMode = .user + @State private var proxyMode: SMPProxyMode = .never + @State private var proxyFallback: SMPProxyFallback = .allow @State private var alert: NetworkAlert? var body: some View { @@ -75,6 +82,30 @@ struct NetworkAndServers: View { Text("Using .onion hosts requires compatible VPN provider.") } +// Section { +// Picker("Private routing", selection: $proxyMode) { +// ForEach(SMPProxyMode.values, id: \.self) { Text($0.text) } +// } +// .frame(height: 36) +// +// Picker("Allow downgrade", selection: $proxyFallback) { +// ForEach(SMPProxyFallback.values, id: \.self) { Text($0.text) } +// } +// .disabled(proxyMode == .never) +// .frame(height: 36) +// +// Toggle("Show message status", isOn: $showSentViaProxy) +// } header: { +// Text("Private message routing") +// } footer: { +// VStack(alignment: .leading) { +// Text("To protect your IP address, private routing uses your SMP servers to deliver messages.") +// if showSentViaProxy { +// Text("Show → on messages sent via private routing.") +// } +// } +// } + Section("Calls") { NavigationLink { RTCServers() @@ -99,14 +130,24 @@ struct NetworkAndServers: View { currentNetCfg = getNetCfg() resetNetCfgView() } - .onChange(of: onionHosts) { _ in - if onionHosts != OnionHosts(netCfg: currentNetCfg) { - alert = .updateOnionHosts(hosts: onionHosts) + .onChange(of: onionHosts) { hosts in + if hosts != OnionHosts(netCfg: currentNetCfg) { + alert = .updateOnionHosts(hosts: hosts) } } - .onChange(of: sessionMode) { _ in - if sessionMode != netCfg.sessionMode { - alert = .updateSessionMode(mode: sessionMode) + .onChange(of: sessionMode) { mode in + if mode != netCfg.sessionMode { + alert = .updateSessionMode(mode: mode) + } + } + .onChange(of: proxyMode) { mode in + if mode != netCfg.smpProxyMode { + alert = .updateSMPProxyMode(proxyMode: mode) + } + } + .onChange(of: proxyFallback) { fallbackMode in + if fallbackMode != netCfg.smpProxyFallback { + alert = .updateSMPProxyFallback(proxyFallback: fallbackMode) } } .alert(item: $alert) { a in @@ -137,6 +178,30 @@ struct NetworkAndServers: View { resetNetCfgView() } ) + case let .updateSMPProxyMode(proxyMode): + return Alert( + title: Text("Message routing mode"), + message: Text(proxyModeInfo(proxyMode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + primaryButton: .default(Text("Ok")) { + netCfg.smpProxyMode = proxyMode + saveNetCfg() + }, + secondaryButton: .cancel() { + resetNetCfgView() + } + ) + case let .updateSMPProxyFallback(proxyFallback): + return Alert( + title: Text("Message routing fallback"), + message: Text(proxyFallbackInfo(proxyFallback)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + primaryButton: .default(Text("Ok")) { + netCfg.smpProxyFallback = proxyFallback + saveNetCfg() + }, + secondaryButton: .cancel() { + resetNetCfgView() + } + ) case let .error(err): return Alert( title: Text("Error updating settings"), @@ -166,6 +231,8 @@ struct NetworkAndServers: View { netCfg = currentNetCfg onionHosts = OnionHosts(netCfg: netCfg) sessionMode = netCfg.sessionMode + proxyMode = netCfg.smpProxyMode + proxyFallback = netCfg.smpProxyFallback } private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey { @@ -182,6 +249,23 @@ struct NetworkAndServers: View { case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." } } + + private func proxyModeInfo(_ mode: SMPProxyMode) -> LocalizedStringKey { + switch mode { + case .always: return "Always use private routing." + case .unknown: return "Use private routing with unknown servers." + case .unprotected: return "Use private routing with unknown servers when IP address is not protected." + case .never: return "Do NOT use private routing." + } + } + + private func proxyFallbackInfo(_ proxyFallback: SMPProxyFallback) -> LocalizedStringKey { + switch proxyFallback { + case .allow: return "Send messages directly when your or destination server does not support private routing." + case .allowProtected: return "Send messages directly when IP address is protected and your or destination server does not support private routing." + case .prohibit: return "Do NOT send messages directly, even if your or destination server does not support private routing." + } + } } struct NetworkServersView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 32e1ecedbe..5cc9d7f8ee 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -63,6 +63,7 @@ let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" +let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" @@ -92,7 +93,7 @@ let appDefaults: [String: Any] = [ DEFAULT_ACCENT_COLOR_BLUE: 1.000, DEFAULT_USER_INTERFACE_STYLE: 0, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, - DEFAULT_ONE_HAND_UI: true, + DEFAULT_ONE_HAND_UI: false, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, @@ -105,7 +106,8 @@ let appDefaults: [String: Any] = [ DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true, - DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true + DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, + DEFAULT_SHOW_SENT_VIA_RPOXY: false ] // not used anymore diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7032ccf71b..d0a0f6fb5f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -76,6 +76,11 @@ 5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; 5C9D811A2AA8727A001D49FD /* CryptoFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */; }; + 5C9F3DCC2BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F3DC72BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a */; }; + 5C9F3DCD2BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F3DC82BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a */; }; + 5C9F3DCE2BF7A6900003B86B /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F3DC92BF7A6900003B86B /* libgmp.a */; }; + 5C9F3DCF2BF7A6900003B86B /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F3DCA2BF7A6900003B86B /* libgmpxx.a */; }; + 5C9F3DD02BF7A6900003B86B /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C9F3DCB2BF7A6900003B86B /* libffi.a */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; }; @@ -115,11 +120,6 @@ 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; - 5CE0E8AB2BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE0E8A62BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a */; }; - 5CE0E8AC2BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE0E8A72BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a */; }; - 5CE0E8AD2BF0C1B5008D6E06 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE0E8A82BF0C1B5008D6E06 /* libgmp.a */; }; - 5CE0E8AE2BF0C1B5008D6E06 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE0E8A92BF0C1B5008D6E06 /* libffi.a */; }; - 5CE0E8AF2BF0C1B5008D6E06 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE0E8AA2BF0C1B5008D6E06 /* libgmpxx.a */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */; }; @@ -357,6 +357,11 @@ 5C9CC7AC28C55D7800BEF955 /* DatabaseEncryptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEncryptionView.swift; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoFile.swift; sourceTree = ""; }; + 5C9F3DC72BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a"; sourceTree = ""; }; + 5C9F3DC82BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a"; sourceTree = ""; }; + 5C9F3DC92BF7A6900003B86B /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C9F3DCA2BF7A6900003B86B /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C9F3DCB2BF7A6900003B86B /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXApp.swift; sourceTree = ""; }; @@ -423,11 +428,6 @@ 5CDCAD7428188D2900503DA2 /* APITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITypes.swift; sourceTree = ""; }; 5CDCAD7D2818941F00503DA2 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 5CDCAD80281A7E2700503DA2 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; - 5CE0E8A62BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a"; sourceTree = ""; }; - 5CE0E8A72BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a"; sourceTree = ""; }; - 5CE0E8A82BF0C1B5008D6E06 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CE0E8A92BF0C1B5008D6E06 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CE0E8AA2BF0C1B5008D6E06 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CE1330328E118CC00FFFD8C /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = "de.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CE1330428E118CC00FFFD8C /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SimpleXChat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -535,13 +535,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C9F3DCF2BF7A6900003B86B /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CE0E8AE2BF0C1B5008D6E06 /* libffi.a in Frameworks */, - 5CE0E8AD2BF0C1B5008D6E06 /* libgmp.a in Frameworks */, + 5C9F3DCE2BF7A6900003B86B /* libgmp.a in Frameworks */, + 5C9F3DCC2BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a in Frameworks */, + 5C9F3DD02BF7A6900003B86B /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CE0E8AC2BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a in Frameworks */, - 5CE0E8AB2BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a in Frameworks */, - 5CE0E8AF2BF0C1B5008D6E06 /* libgmpxx.a in Frameworks */, + 5C9F3DCD2BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -609,11 +609,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CE0E8A92BF0C1B5008D6E06 /* libffi.a */, - 5CE0E8A82BF0C1B5008D6E06 /* libgmp.a */, - 5CE0E8AA2BF0C1B5008D6E06 /* libgmpxx.a */, - 5CE0E8A62BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg-ghc9.6.3.a */, - 5CE0E8A72BF0C1B5008D6E06 /* libHSsimplex-chat-5.7.3.0-BQ0iWJUV1AuAQdR2Nu1hMg.a */, + 5C9F3DCB2BF7A6900003B86B /* libffi.a */, + 5C9F3DC92BF7A6900003B86B /* libgmp.a */, + 5C9F3DCA2BF7A6900003B86B /* libgmpxx.a */, + 5C9F3DC82BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g-ghc9.6.3.a */, + 5C9F3DC72BF7A6900003B86B /* libHSsimplex-chat-5.8.0.1-BrjXjAnJqNV7yWXU89n05g.a */, ); path = Libraries; sourceTree = ""; @@ -1580,7 +1580,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1605,7 +1605,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1629,7 +1629,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1654,7 +1654,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1715,7 +1715,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -1730,7 +1730,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1752,7 +1752,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -1767,7 +1767,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1789,7 +1789,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1815,7 +1815,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1840,7 +1840,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 216; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1866,7 +1866,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.7.3; + MARKETING_VERSION = 5.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index fe53e42de3..4041c97db0 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -48,7 +48,7 @@ public enum ChatCommand { case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) - case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) + case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -192,7 +192,9 @@ public enum ChatCommand { case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" - case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId): return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId)" + case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl): + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId) ttl=\(ttlStr)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -251,7 +253,7 @@ public enum ChatCommand { case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" - case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(encodeJSON(chatDeleteMode))" + case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" @@ -473,10 +475,6 @@ public enum ChatCommand { return nil } - private func onOff(_ b: Bool) -> String { - b ? "on" : "off" - } - private func onOffParam(_ param: String, _ b: Bool?) -> String { if let b = b { return " \(param)=\(onOff(b))" @@ -489,6 +487,10 @@ public enum ChatCommand { } } +private func onOff(_ b: Bool) -> String { + b ? "on" : "off" +} + public struct APIResponse: Decodable { var resp: ChatResponse } @@ -635,6 +637,7 @@ public enum ChatResponse: Decodable, Error { case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) + case contactDisabled(user: UserRef, contact: Contact) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) case remoteCtrlFound(remoteCtrl: RemoteCtrlInfo, ctrlAppInfo_: CtrlAppInfo?, appVersion: String, compatible: Bool) @@ -792,6 +795,7 @@ public enum ChatResponse: Decodable, Error { case .ntfMessages: return "ntfMessages" case .ntfMessage: return "ntfMessage" case .contactConnectionDeleted: return "contactConnectionDeleted" + case .contactDisabled: return "contactDisabled" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" case .remoteCtrlConnecting: return "remoteCtrlConnecting" @@ -949,6 +953,7 @@ public enum ChatResponse: Decodable, Error { case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) + case let .contactDisabled(u, contact): return withUser(u, String(describing: contact)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" case let .remoteCtrlConnecting(remoteCtrl_, ctrlAppInfo, appVersion): return "remoteCtrl_:\n\(String(describing: remoteCtrl_))\nctrlAppInfo:\n\(String(describing: ctrlAppInfo))\nappVersion: \(appVersion)" @@ -988,6 +993,14 @@ public enum ChatDeleteMode: Codable { case full(notify: Bool) case entity(notify: Bool) case messages + + var cmdString: String { + switch self { + case let .full(notify): "full notify=\(onOff(notify))" + case let .entity(notify): "entity notify=\(onOff(notify))" + case .messages: "messages" + } + } } public enum ConnectionPlan: Decodable { @@ -1244,9 +1257,12 @@ public struct ServerAddress: Decodable { public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil + var socksMode: SocksMode = .always public var hostMode: HostMode = .publicHost public var requiredHostMode = true public var sessionMode: TransportSessionMode + public var smpProxyMode: SMPProxyMode = .never + public var smpProxyFallback: SMPProxyFallback = .allow public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpTimeoutPerKb: Int // microseconds @@ -1291,6 +1307,49 @@ public enum HostMode: String, Codable { case publicHost = "public" } +public enum SocksMode: String, Codable { + case always = "always" + case onion = "onion" +} + +public enum SMPProxyMode: String, Codable { + case always = "always" + case unknown = "unknown" + case unprotected = "unprotected" + case never = "never" + + public var text: LocalizedStringKey { + switch self { + case .always: return "always" + case .unknown: return "unknown relays" + case .unprotected: return "unprotected" + case .never: return "never" + } + } + + public var id: SMPProxyMode { self } + + public static let values: [SMPProxyMode] = [.always, .unknown, .unprotected, .never] +} + +public enum SMPProxyFallback: String, Codable { + case allow = "allow" + case allowProtected = "allowProtected" + case prohibit = "prohibit" + + public var text: LocalizedStringKey { + switch self { + case .allow: return "yes" + case .allowProtected: return "when IP hidden" + case .prohibit: return "no" + } + } + + public var id: SMPProxyFallback { self } + + public static let values: [SMPProxyFallback] = [.allow, .allowProtected, .prohibit] +} + public enum OnionHosts: String, Identifiable { case no case prefer @@ -2055,7 +2114,7 @@ public struct AppSettings: Codable, Equatable { androidCallOnLockScreen: AppSettingsLockScreenCalls.show, iosCallKitEnabled: true, iosCallKitCallsInRecents: false, - oneHandUI: true + oneHandUI: false ) } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 0511a8486c..118acae993 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -26,6 +26,8 @@ public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" +let GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE = "networkSMPProxyMode" +let GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK = "networkSMPProxyFallback" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB = "networkTCPTimeoutPerKb" @@ -53,6 +55,8 @@ public func registerGroupDefaults() { GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false, GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.user.rawValue, + GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.never.rawValue, + GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allow.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb, @@ -191,6 +195,18 @@ public let networkSessionModeGroupDefault = EnumDefault( withDefault: .user ) +public let networkSMPProxyModeGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE, + withDefault: .never +) + +public let networkSMPProxyFallbackGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK, + withDefault: .allow +) + public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) @@ -275,6 +291,8 @@ public func getNetCfg() -> NetCfg { let onionHosts = networkUseOnionHostsGroupDefault.get() let (hostMode, requiredHostMode) = onionHosts.hostMode let sessionMode = networkSessionModeGroupDefault.get() + let smpProxyMode = networkSMPProxyModeGroupDefault.get() + let smpProxyFallback = networkSMPProxyFallbackGroupDefault.get() let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let tcpTimeoutPerKb = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) @@ -295,6 +313,8 @@ public func getNetCfg() -> NetCfg { hostMode: hostMode, requiredHostMode: requiredHostMode, sessionMode: sessionMode, + smpProxyMode: smpProxyMode, + smpProxyFallback: smpProxyFallback, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpTimeoutPerKb: tcpTimeoutPerKb, @@ -309,6 +329,8 @@ public func getNetCfg() -> NetCfg { public func setNetCfg(_ cfg: NetCfg) { networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg)) networkSessionModeGroupDefault.set(cfg.sessionMode) + networkSMPProxyModeGroupDefault.set(cfg.smpProxyMode) + networkSMPProxyFallbackGroupDefault.set(cfg.smpProxyFallback) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.tcpTimeoutPerKb, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7f0f5cc732..1a5496405e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1520,7 +1520,12 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var ready: Bool { get { activeConn?.connStatus == .ready } } public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false)) + ( + ready + && active + && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?? false) + && !(activeConn?.connDisabled ?? true) + ) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1613,15 +1618,20 @@ public struct Connection: Decodable { public var pqEncryption: Bool public var pqSndEnabled: Bool? public var pqRcvEnabled: Bool? + public var authErrCounter: Int public var connectionStats: ConnectionStats? = nil private enum CodingKeys: String, CodingKey { - case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled + case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter } public var id: ChatId { get { ":\(connId)" } } + public var connDisabled: Bool { + authErrCounter >= 10 // authErrDisableCount in core + } + public var connPQEnabled: Bool { pqSndEnabled == true && pqRcvEnabled == true } @@ -1634,7 +1644,8 @@ public struct Connection: Decodable { connLevel: 0, viaGroupLink: false, pqSupport: false, - pqEncryption: false + pqEncryption: false, + authErrCounter: 0 ) } @@ -2633,6 +2644,7 @@ public struct CIMeta: Decodable { public var itemTs: Date var itemText: String public var itemStatus: CIStatus + public var sentViaProxy: Bool? public var createdAt: Date public var updatedAt: Date public var itemForwarded: CIForwardedFrom? @@ -2722,7 +2734,8 @@ public enum CIStatus: Decodable { case sndSent(sndProgress: SndCIStatusProgress) case sndRcvd(msgRcptStatus: MsgReceiptStatus, sndProgress: SndCIStatusProgress) case sndErrorAuth - case sndError(agentError: String) + case sndError(agentError: SndError) + case sndWarning(agentError: SndError) case rcvNew case rcvRead case invalid(text: String) @@ -2734,6 +2747,7 @@ public enum CIStatus: Decodable { case .sndRcvd: return "sndRcvd" case .sndErrorAuth: return "sndErrorAuth" case .sndError: return "sndError" + case .sndWarning: return "sndWarning" case .rcvNew: return "rcvNew" case .rcvRead: return "rcvRead" case .invalid: return "invalid" @@ -2750,7 +2764,8 @@ public enum CIStatus: Decodable { case .badMsgHash: return ("checkmark", .red) } case .sndErrorAuth: return ("multiply", .red) - case .sndError: return ("exclamationmark.triangle.fill", .yellow) + case .sndError: return ("multiply", .red) + case .sndWarning: return ("exclamationmark.triangle.fill", .orange) case .rcvNew: return ("circlebadge.fill", Color.accentColor) case .rcvRead: return nil case .invalid: return ("questionmark", metaColor) @@ -2768,7 +2783,11 @@ public enum CIStatus: Decodable { ) case let .sndError(agentError): return ( NSLocalizedString("Message delivery error", comment: "item status text"), - String.localizedStringWithFormat(NSLocalizedString("Unexpected error: %@", comment: "item status description"), agentError) + agentError.errorInfo + ) + case let .sndWarning(agentError): return ( + NSLocalizedString("Message delivery warning", comment: "item status text"), + agentError.errorInfo ) case .rcvNew: return nil case .rcvRead: return nil @@ -2780,6 +2799,42 @@ public enum CIStatus: Decodable { } } +public enum SndError: Decodable { + case auth + case quota + case expired + case relay(srvError: SrvError) + case proxy(proxyServer: String, srvError: SrvError) + case proxyRelay(proxyServer: String, srvError: SrvError) + case other(sndError: String) + + public var errorInfo: String { + switch self { + case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text") + case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text") + case .expired: NSLocalizedString("Network issues - message expired after many attempts to send it.", comment: "snd error text") + case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("Destination server error: %@", comment: "snd error text"), srvError.errorInfo) + case let .proxy(proxyServer, srvError): String.localizedStringWithFormat(NSLocalizedString("Forwarding server: %@\nError: %@", comment: "snd error text"), proxyServer, srvError.errorInfo) + case let .proxyRelay(proxyServer, srvError): String.localizedStringWithFormat(NSLocalizedString("Forwarding server: %@\nDestination server error: %@", comment: "snd error text"), proxyServer, srvError.errorInfo) + case let .other(sndError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "snd error text"), sndError) + } + } +} + +public enum SrvError: Decodable { + case host + case version + case other(srvError: String) + + public var errorInfo: String { + switch self { + case .host: NSLocalizedString("Server address is incompatible with network settings.", comment: "srv error text.") + case .version: NSLocalizedString("Server version is incompatible with network settings.", comment: "srv error text") + case let .other(srvError): srvError + } + } +} + public enum MsgReceiptStatus: String, Decodable { case ok case badMsgHash @@ -3898,4 +3953,5 @@ public struct ChatItemVersion: Decodable { public struct MemberDeliveryStatus: Decodable { public var groupMemberId: Int64 public var memberDeliveryStatus: CIStatus + public var sentViaProxy: Bool? } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 3838cd45a6..3780066092 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1048,9 +1048,13 @@ data class Contact( override val apiId get() = contactId override val ready get() = activeConn?.connStatus == ConnStatus.Ready val active get() = contactStatus == ContactStatus.Active - override val sendMsgEnabled get() = - (ready && active && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false)) - || nextSendGrpInv + override val sendMsgEnabled get() = ( + ready + && active + && !(activeConn?.connectionStats?.ratchetSyncSendProhibited ?: false) + && !(activeConn?.connDisabled ?: true) + ) + || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito @@ -1150,15 +1154,19 @@ data class Connection( val pqEncryption: Boolean, val pqSndEnabled: Boolean? = null, val pqRcvEnabled: Boolean? = null, - val connectionStats: ConnectionStats? = null + val connectionStats: ConnectionStats? = null, + val authErrCounter: Int ) { val id: ChatId get() = ":$connId" + val connDisabled: Boolean + get() = authErrCounter >= 10 // authErrDisableCount in core + val connPQEnabled: Boolean get() = pqSndEnabled == true && pqRcvEnabled == true companion object { - val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false) + val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false, authErrCounter = 0) } } @@ -1900,6 +1908,7 @@ data class ChatItem ( ts: Instant = Clock.System.now(), text: String = "hello\nthere", status: CIStatus = CIStatus.SndNew(), + sentViaProxy: Boolean? = null, quotedItem: CIQuote? = null, file: CIFile? = null, itemForwarded: CIForwardedFrom? = null, @@ -1911,7 +1920,7 @@ data class ChatItem ( ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status, itemForwarded, itemDeleted, itemEdited, itemTimed, deletable, editable), + meta = CIMeta.getSample(id, ts, text, status, sentViaProxy, itemForwarded, itemDeleted, itemEdited, itemTimed, deletable, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem, reactions = listOf(), @@ -1993,6 +2002,7 @@ data class ChatItem ( itemTs = Clock.System.now(), itemText = generalGetString(MR.strings.deleted_description), itemStatus = CIStatus.RcvRead(), + sentViaProxy = null, createdAt = Clock.System.now(), updatedAt = Clock.System.now(), itemForwarded = null, @@ -2016,6 +2026,7 @@ data class ChatItem ( itemTs = Clock.System.now(), itemText = "", itemStatus = CIStatus.RcvRead(), + sentViaProxy = null, createdAt = Clock.System.now(), updatedAt = Clock.System.now(), itemForwarded = null, @@ -2118,6 +2129,7 @@ data class CIMeta ( val itemTs: Instant, val itemText: String, val itemStatus: CIStatus, + val sentViaProxy: Boolean?, val createdAt: Instant, val updatedAt: Instant, val itemForwarded: CIForwardedFrom?, @@ -2144,7 +2156,7 @@ data class CIMeta ( companion object { fun getSample( - id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), + id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), sentViaProxy: Boolean? = null, itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, deletable: Boolean = true, editable: Boolean = true ): CIMeta = @@ -2153,6 +2165,7 @@ data class CIMeta ( itemTs = ts, itemText = text, itemStatus = status, + sentViaProxy = sentViaProxy, createdAt = ts, updatedAt = ts, itemForwarded = itemForwarded, @@ -2171,6 +2184,7 @@ data class CIMeta ( itemTs = Clock.System.now(), itemText = "invalid JSON", itemStatus = CIStatus.SndNew(), + sentViaProxy = null, createdAt = Clock.System.now(), updatedAt = Clock.System.now(), itemForwarded = null, @@ -2227,7 +2241,8 @@ sealed class CIStatus { @Serializable @SerialName("sndSent") class SndSent(val sndProgress: SndCIStatusProgress): CIStatus() @Serializable @SerialName("sndRcvd") class SndRcvd(val msgRcptStatus: MsgReceiptStatus, val sndProgress: SndCIStatusProgress): CIStatus() @Serializable @SerialName("sndErrorAuth") class SndErrorAuth: CIStatus() - @Serializable @SerialName("sndError") class SndError(val agentError: String): CIStatus() + @Serializable @SerialName("sndError") class CISSndError(val agentError: SndError): CIStatus() + @Serializable @SerialName("sndWarning") class SndWarning(val agentError: SndError): CIStatus() @Serializable @SerialName("rcvNew") class RcvNew: CIStatus() @Serializable @SerialName("rcvRead") class RcvRead: CIStatus() @Serializable @SerialName("invalid") class Invalid(val text: String): CIStatus() @@ -2251,7 +2266,8 @@ sealed class CIStatus { MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red } is SndErrorAuth -> MR.images.ic_close to Color.Red - is SndError -> MR.images.ic_warning_filled to WarningYellow + is CISSndError -> MR.images.ic_close to Color.Red + is SndWarning -> MR.images.ic_warning_filled to WarningOrange is RcvNew -> MR.images.ic_circle_filled to primaryColor is RcvRead -> null is CIStatus.Invalid -> MR.images.ic_question_mark to metaColor @@ -2262,13 +2278,48 @@ sealed class CIStatus { is SndSent -> null is SndRcvd -> null is SndErrorAuth -> generalGetString(MR.strings.message_delivery_error_title) to generalGetString(MR.strings.message_delivery_error_desc) - is SndError -> generalGetString(MR.strings.message_delivery_error_title) to (generalGetString(MR.strings.unknown_error) + ": $agentError") + is CISSndError -> generalGetString(MR.strings.message_delivery_error_title) to agentError.errorInfo + is SndWarning -> generalGetString(MR.strings.message_delivery_warning_title) to agentError.errorInfo is RcvNew -> null is RcvRead -> null is Invalid -> "Invalid status" to this.text } } +@Serializable +sealed class SndError { + @Serializable @SerialName("auth") class Auth: SndError() + @Serializable @SerialName("quota") class Quota: SndError() + @Serializable @SerialName("expired") class Expired: SndError() + @Serializable @SerialName("relay") class Relay(val srvError: SrvError): SndError() + @Serializable @SerialName("proxy") class Proxy(val proxyServer: String, val srvError: SrvError): SndError() + @Serializable @SerialName("proxyRelay") class ProxyRelay(val proxyServer: String, val srvError: SrvError): SndError() + @Serializable @SerialName("other") class Other(val sndError: String): SndError() + + val errorInfo: String get() = when (this) { + is SndError.Auth -> generalGetString(MR.strings.snd_error_auth) + is SndError.Quota -> generalGetString(MR.strings.snd_error_quota) + is SndError.Expired -> generalGetString(MR.strings.snd_error_expired) + is SndError.Relay -> generalGetString(MR.strings.snd_error_relay).format(srvError.errorInfo) + is SndError.Proxy -> generalGetString(MR.strings.snd_error_proxy).format(proxyServer, srvError.errorInfo) + is SndError.ProxyRelay -> generalGetString(MR.strings.snd_error_proxy_relay).format(proxyServer, srvError.errorInfo) + is SndError.Other -> generalGetString(MR.strings.ci_status_other_error).format(sndError) + } +} + +@Serializable +sealed class SrvError { + @Serializable @SerialName("host") class Host: SrvError() + @Serializable @SerialName("version") class Version: SrvError() + @Serializable @SerialName("other") class Other(val srvError: String): SrvError() + + val errorInfo: String get() = when (this) { + is SrvError.Host -> generalGetString(MR.strings.srv_error_host) + is SrvError.Version -> generalGetString(MR.strings.srv_error_version) + is SrvError.Other -> srvError + } +} + @Serializable enum class MsgReceiptStatus { @SerialName("ok") Ok, @@ -2653,6 +2704,12 @@ data class CIFile( return res } + fun forwardingAllowed(): Boolean = when { + chatModel.connectedToRemote() && cachedRemoteFileRequests[fileSource] != false && loaded -> true + getLoadedFilePath(this) != null -> true + else -> false + } + companion object { fun getSample( fileId: Long = 1, @@ -3348,7 +3405,8 @@ data class ChatItemVersion( @Serializable data class MemberDeliveryStatus( val groupMemberId: Long, - val memberDeliveryStatus: CIStatus + val memberDeliveryStatus: CIStatus, + val sentViaProxy: Boolean? ) enum class NotificationPreviewMode { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b71610597e..fa9c2580ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -130,6 +130,8 @@ class AppPreferences { }, set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) } ) + val networkSMPProxyMode = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_MODE, SMPProxyMode.Never.name) + val networkSMPProxyFallback = mkStrPreference(SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK, SMPProxyFallback.Allow.name) val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) @@ -185,6 +187,8 @@ class AppPreferences { val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false) + val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true) val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false) @@ -306,6 +310,8 @@ class AppPreferences { private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" + private const val SHARED_PREFS_NETWORK_SMP_PROXY_MODE = "NetworkSMPProxyMode" + private const val SHARED_PREFS_NETWORK_SMP_PROXY_FALLBACK = "NetworkSMPProxyFallback" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" @@ -348,6 +354,7 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled" private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents" @@ -775,8 +782,8 @@ object ChatController { } } - suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long): ChatItem? { - val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId) + suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long, ttl: Int?): ChatItem? { + val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl) return processSendMessageCmd(rh, cmd)?.chatItem } @@ -2031,13 +2038,21 @@ object ChatController { } } is CR.ContactSwitch -> - chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + if (active(r.user)) { + chatModel.updateContactConnectionStats(rhId, r.contact, r.switchProgress.connectionStats) + } is CR.GroupMemberSwitch -> - chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + if (active(r.user)) { + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.switchProgress.connectionStats) + } is CR.ContactRatchetSync -> - chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + if (active(r.user)) { + chatModel.updateContactConnectionStats(rhId, r.contact, r.ratchetSyncProgress.connectionStats) + } is CR.GroupMemberRatchetSync -> - chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + if (active(r.user)) { + chatModel.updateGroupMemberConnectionStats(rhId, r.groupInfo, r.member, r.ratchetSyncProgress.connectionStats) + } is CR.RemoteHostSessionCode -> { chatModel.remoteHostPairing.value = r.remoteHost_ to RemoteHostSessionState.PendingConfirmation(r.sessionCode) } @@ -2046,6 +2061,11 @@ object ChatController { chatModel.currentRemoteHost.value = r.remoteHost switchUIRemoteHost(r.remoteHost.remoteHostId) } + is CR.ContactDisabled -> { + if (active(r.user)) { + chatModel.updateContact(rhId, r.contact) + } + } is CR.RemoteHostStopped -> { val disconnectedHost = chatModel.remoteHosts.firstOrNull { it.remoteHostId == r.remoteHostId_ } chatModel.remoteHostPairing.value = null @@ -2302,6 +2322,8 @@ object ChatController { val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) val requiredHostMode = appPrefs.networkRequiredHostMode.get() val sessionMode = appPrefs.networkSessionMode.get() + val smpProxyMode = SMPProxyMode.valueOf(appPrefs.networkSMPProxyMode.get()!!) + val smpProxyFallback = SMPProxyFallback.valueOf(appPrefs.networkSMPProxyFallback.get()!!) val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val tcpTimeoutPerKb = appPrefs.networkTCPTimeoutPerKb.get() @@ -2322,6 +2344,8 @@ object ChatController { hostMode = hostMode, requiredHostMode = requiredHostMode, sessionMode = sessionMode, + smpProxyMode = smpProxyMode, + smpProxyFallback = smpProxyFallback, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpTimeoutPerKb = tcpTimeoutPerKb, @@ -2340,6 +2364,8 @@ object ChatController { appPrefs.networkHostMode.set(cfg.hostMode.name) appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) appPrefs.networkSessionMode.set(cfg.sessionMode) + appPrefs.networkSMPProxyMode.set(cfg.smpProxyMode.name) + appPrefs.networkSMPProxyFallback.set(cfg.smpProxyFallback.name) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkTCPTimeoutPerKb.set(cfg.tcpTimeoutPerKb) @@ -2407,7 +2433,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() - class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long): CC() + class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() @@ -2549,7 +2575,10 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" - is ApiForwardChatItem -> "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId" + is ApiForwardChatItem -> { + val ttlStr = if (ttl != null) "$ttl" else "default" + "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId ttl=${ttlStr}" + } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -3025,9 +3054,12 @@ data class ParsedServerAddress ( @Serializable data class NetCfg( val socksProxy: String?, + val socksMode: SocksMode = SocksMode.Always, val hostMode: HostMode, val requiredHostMode: Boolean, val sessionMode: TransportSessionMode, + val smpProxyMode: SMPProxyMode, + val smpProxyFallback: SMPProxyFallback, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpTimeoutPerKb: Long, // microseconds @@ -3056,6 +3088,8 @@ data class NetCfg( hostMode = HostMode.OnionViaSocks, requiredHostMode = false, sessionMode = TransportSessionMode.User, + smpProxyMode = SMPProxyMode.Never, + smpProxyFallback = SMPProxyFallback.Allow, tcpConnectTimeout = 25_000_000, tcpTimeout = 15_000_000, tcpTimeoutPerKb = 10_000, @@ -3071,6 +3105,8 @@ data class NetCfg( hostMode = HostMode.OnionViaSocks, requiredHostMode = false, sessionMode = TransportSessionMode.User, + smpProxyMode = SMPProxyMode.Never, + smpProxyFallback = SMPProxyFallback.Allow, tcpConnectTimeout = 35_000_000, tcpTimeout = 20_000_000, tcpTimeoutPerKb = 15_000, @@ -3109,6 +3145,35 @@ enum class HostMode { @SerialName("public") Public; } +@Serializable +enum class SocksMode { + @SerialName("always") Always, + @SerialName("onion") Onion; +} + +@Serializable +enum class SMPProxyMode { + @SerialName("always") Always, + @SerialName("unknown") Unknown, + @SerialName("unprotected") Unprotected, + @SerialName("never") Never; + + companion object { + val default = Never + } +} + +@Serializable +enum class SMPProxyFallback { + @SerialName("allow") Allow, + @SerialName("allowProtected") AllowProtected, + @SerialName("prohibit") Prohibit; + + companion object { + val default = Allow + } +} + @Serializable enum class TransportSessionMode { @SerialName("user") User, @@ -4197,6 +4262,7 @@ sealed class CR { @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: UserRef, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() @Serializable @SerialName("callEnded") class CallEnded(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: UserRef, val connection: PendingContactConnection): CR() + @Serializable @SerialName("contactDisabled") class ContactDisabled(val user: UserRef, val contact: Contact): CR() // remote events (desktop) @Serializable @SerialName("remoteHostList") class RemoteHostList(val remoteHosts: List): CR() @Serializable @SerialName("currentRemoteHost") class CurrentRemoteHost(val remoteHost_: RemoteHostInfo?): CR() @@ -4361,6 +4427,7 @@ sealed class CR { is CallExtraInfo -> "callExtraInfo" is CallEnded -> "callEnded" is ContactConnectionDeleted -> "contactConnectionDeleted" + is ContactDisabled -> "contactDisabled" is RemoteHostList -> "remoteHostList" is CurrentRemoteHost -> "currentRemoteHost" is RemoteHostStarted -> "remoteHostStarted" @@ -4521,6 +4588,7 @@ sealed class CR { is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") is CallEnded -> withUser(user, "contact: ${contact.id}") is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) + is ContactDisabled -> withUser(user, json.encodeToString(contact)) // remote events (mobile) is RemoteHostList -> json.encodeToString(remoteHosts) is CurrentRemoteHost -> if (remoteHost_ == null) "local" else json.encodeToString(remoteHost_) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index df8e535f82..e00592bce9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -304,7 +304,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } @Composable - fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) { + fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus, sentViaProxy: Boolean?) { SectionItemView( padding = PaddingValues(horizontal = 0.dp) ) { @@ -317,6 +317,18 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools overflow = TextOverflow.Ellipsis ) Spacer(Modifier.fillMaxWidth().weight(1f)) + if (sentViaProxy == true) { + Box( + Modifier.size(36.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(MR.images.ic_arrow_forward), + contentDescription = null, + tint = CurrentColors.value.colors.secondary + ) + } + } val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary) var modifier = Modifier.size(36.dp).clip(RoundedCornerShape(20.dp)) val info = status.statusInto @@ -357,8 +369,8 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools if (mss.isNotEmpty()) { SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(stringResource(MR.strings.delivery), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) - mss.forEach { (member, status) -> - MemberDeliveryStatusView(member, status) + mss.forEach { (member, status, sentViaProxy) -> + MemberDeliveryStatusView(member, status, sentViaProxy) } } } else { @@ -482,10 +494,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } -private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { +private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { return memberDeliveryStatuses.mapNotNull { mds -> chatModel.getGroupMember(mds.groupMemberId)?.let { mem -> - mem to mds.memberDeliveryStatus + Triple(mem, mds.memberDeliveryStatus, mds.sentViaProxy) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e6f90c1599..6c3a884a0e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -479,6 +479,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, onComposed, developerTools = chatModel.controller.appPrefs.developerTools.get(), + showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(), ) } is ChatInfo.ContactConnection -> { @@ -548,6 +549,7 @@ fun ChatLayout( onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, + showViaProxy: Boolean ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -606,7 +608,7 @@ fun ChatLayout( useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, + setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy, ) } } @@ -885,6 +887,7 @@ fun BoxWithConstraintsScope.ChatItemsList( setFloatingButton: (@Composable () -> Unit) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, + showViaProxy: Boolean ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -975,7 +978,7 @@ fun BoxWithConstraintsScope.ChatItemsList( tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { - ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy) } } @@ -1543,6 +1546,7 @@ fun PreviewChatLayout() { onSearchValueChanged = {}, onComposed = {}, developerTools = false, + showViaProxy = false, ) } } @@ -1615,6 +1619,7 @@ fun PreviewGroupChatLayout() { onSearchValueChanged = {}, onComposed = {}, developerTools = false, + showViaProxy = false, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 26ff8796d4..d9ea35096b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -408,14 +408,15 @@ fun ComposeView( composeState.value = composeState.value.copy(inProgress = true) } - suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo): ChatItem? { + suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? { val chatItem = controller.apiForwardChatItem( rh = rhId, toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, - itemId = forwardedItem.id + itemId = forwardedItem.id, + ttl = ttl ) if (chatItem != null) { chatModel.addChatItem(rhId, chat.chatInfo, chatItem) @@ -490,9 +491,9 @@ fun ComposeView( sendMemberContactInvitation() sent = null } else if (cs.contextItem is ComposeContextItem.ForwardingItem) { - sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo) + sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo, ttl = ttl) if (cs.message.isNotEmpty()) { - sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = null) + sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = ttl) } } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 58705bd00a..779371a07c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -157,7 +157,7 @@ fun SendMsgView( fun MenuItems(): List<@Composable () -> Unit> { val menuItems = mutableListOf<@Composable () -> Unit>() - if (cs.liveMessage == null && !cs.editing && !cs.forwarding && !nextSendGrpInv || sendMsgEnabled) { + if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) { if ( cs.preview !is ComposePreview.VoicePreview && cs.contextItem is ComposeContextItem.NoContextItem && diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 5d3d5aa94a..74c6e38566 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -47,7 +47,7 @@ fun CICallItemView( CICallStatus.Error -> {} } - CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false) + CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 6ad75057f6..f079a152ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape @@ -28,6 +29,7 @@ import java.net.URI fun CIFileView( file: CIFile?, edited: Boolean, + showMenu: MutableState, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @@ -86,7 +88,7 @@ fun CIFileView( ) FileProtocol.LOCAL -> {} } - file.fileStatus is CIFileStatus.RcvComplete || (file.fileStatus is CIFileStatus.SndStored && file.fileProtocol == FileProtocol.LOCAL) -> { + file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) if (chatModel.connectedToRemote() && filePath == null) { @@ -136,8 +138,7 @@ fun CIFileView( Box( Modifier .size(42.dp) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = { fileAction() }), + .clip(RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center ) { if (file != null) { @@ -154,7 +155,13 @@ fun CIFileView( FileProtocol.SMP -> progressIndicator() FileProtocol.LOCAL -> {} } - is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(MR.images.ic_check_filled)) + is CIFileStatus.SndComplete -> { + if ((file.forwardingAllowed() || (chatModel.connectedToRemote() && CIFile.cachedRemoteFileRequests[file.fileSource] == true))) { + fileIcon() + } else { + fileIcon(innerIcon = painterResource(MR.images.ic_check_filled)) + } + } is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.RcvInvitation -> @@ -181,7 +188,12 @@ fun CIFileView( } Row( - Modifier.clickable(onClick = { fileAction() }).padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), + Modifier + .combinedClickable( + onClick = { fileAction() }, + onLongClick = { showMenu.value = true } + ) + .padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), //Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index e3008f36b3..a2338bb895 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -144,7 +144,7 @@ fun CIGroupInvitationView( } } - CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false) + CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index b40d8989e1..14fa6910da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -35,7 +35,8 @@ fun CIMetaView( blue = minOf(metaColor.red * 1.33F, 1F)) }, showStatus: Boolean = true, - showEdited: Boolean = true + showEdited: Boolean = true, + showViaProxy: Boolean ) { Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) { if (chatItem.isDeletedContent) { @@ -53,7 +54,8 @@ fun CIMetaView( metaColor, paleMetaColor, showStatus = showStatus, - showEdited = showEdited + showEdited = showEdited, + showViaProxy = showViaProxy ) } } @@ -68,7 +70,8 @@ private fun CIMetaText( color: Color, paleColor: Color, showStatus: Boolean = true, - showEdited: Boolean = true + showEdited: Boolean = true, + showViaProxy: Boolean ) { if (showEdited && meta.itemEdited) { StatusIconText(painterResource(MR.images.ic_edit), color) @@ -82,6 +85,9 @@ private fun CIMetaText( } Spacer(Modifier.width(4.dp)) } + if (showViaProxy && meta.sentViaProxy == true) { + Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp), tint = CurrentColors.value.colors.secondary) + } if (showStatus) { val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor) if (statusIcon != null) { @@ -105,7 +111,14 @@ private fun CIMetaText( } // the conditions in this function should match CIMetaText -fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, showStatus: Boolean = true, showEdited: Boolean = true): String { +fun reserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + showStatus: Boolean = true, + showEdited: Boolean = true, + showViaProxy: Boolean = false +): String { val iconSpace = " " var res = "" if (showEdited && meta.itemEdited) res += iconSpace @@ -116,6 +129,9 @@ fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?, encrypted: Boolean?, showSt res += shortTimeText(ttl) } } + if (showViaProxy && meta.sentViaProxy == true) { + res += iconSpace + } if (showStatus && (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing)) { res += iconSpace } @@ -137,7 +153,8 @@ fun PreviewCIMetaView() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), - null + null, + showViaProxy = false ) } @@ -149,7 +166,8 @@ fun PreviewCIMetaViewUnread() { 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.RcvNew() ), - null + null, + showViaProxy = false ) } @@ -159,9 +177,10 @@ fun PreviewCIMetaViewSendFailed() { CIMetaView( chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", - status = CIStatus.SndError("CMD SYNTAX") + status = CIStatus.CISSndError(SndError.Other("CMD SYNTAX")) ), - null + null, + showViaProxy = false ) } @@ -172,7 +191,8 @@ fun PreviewCIMetaViewSendNoAuth() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth() ), - null + null, + showViaProxy = false ) } @@ -183,7 +203,8 @@ fun PreviewCIMetaViewSendSent() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete) ), - null + null, + showViaProxy = false ) } @@ -195,7 +216,8 @@ fun PreviewCIMetaViewEdited() { 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = true ), - null + null, + showViaProxy = false ) } @@ -208,7 +230,8 @@ fun PreviewCIMetaViewEditedUnread() { itemEdited = true, status= CIStatus.RcvNew() ), - null + null, + showViaProxy = false ) } @@ -221,7 +244,8 @@ fun PreviewCIMetaViewEditedSent() { itemEdited = true, status= CIStatus.SndSent(SndCIStatusProgress.Complete) ), - null + null, + showViaProxy = false ) } @@ -230,6 +254,7 @@ fun PreviewCIMetaViewEditedSent() { fun PreviewCIMetaViewDeletedContent() { CIMetaView( chatItem = ChatItem.getDeletedContentSampleData(), - null + null, + showViaProxy = false ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 318a8a6a05..eb5d1e731a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -174,7 +174,7 @@ fun DecryptionErrorItemFixButton( ) } } - CIMetaView(ci, timedMessagesTTL = null) + CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false) } } } @@ -202,7 +202,7 @@ fun DecryptionErrorItem( }, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) ) - CIMetaView(ci, timedMessagesTTL = null) + CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index e59c5f1370..cac89b2587 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -35,6 +35,7 @@ fun CIVoiceView( hasText: Boolean, ci: ChatItem, timedMessagesTTL: Int?, + showViaProxy: Boolean, longClick: () -> Unit, receiveFile: (Long) -> Unit, ) { @@ -76,7 +77,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, play, pause, longClick, receiveFile) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } } else { @@ -102,6 +103,7 @@ private fun VoiceLayout( sent: Boolean, hasText: Boolean, timedMessagesTTL: Int?, + showViaProxy: Boolean, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, @@ -171,7 +173,7 @@ private fun VoiceLayout( VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) } Box(Modifier.padding(top = 6.dp, end = 6.dp)) { - CIMetaView(ci, timedMessagesTTL) + CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } } @@ -186,7 +188,7 @@ private fun VoiceLayout( } } Box(Modifier.padding(top = 6.dp)) { - CIMetaView(ci, timedMessagesTTL) + CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 10078dc266..0bc0415590 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -68,6 +68,7 @@ fun ChatItemView( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, developerTools: Boolean, + showViaProxy: Boolean ) { val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent @@ -83,17 +84,15 @@ fun ChatItemView( .fillMaxWidth(), contentAlignment = alignment, ) { - val onClick = { - when (cItem.meta.itemStatus) { - is CIStatus.SndErrorAuth -> { - showMsgDeliveryErrorAlert(generalGetString(MR.strings.message_delivery_error_desc)) - } - is CIStatus.SndError -> { - showMsgDeliveryErrorAlert(generalGetString(MR.strings.unknown_error) + ": ${cItem.meta.itemStatus.agentError}") - } - else -> {} + val info = cItem.meta.itemStatus.statusInto + val onClick = if (info != null) { + { + AlertManager.shared.showAlertMsg( + title = info.first, + text = info.second, + ) } - } + } else { {} } @Composable fun ChatItemReactions() { @@ -130,7 +129,7 @@ fun ChatItemView( ) { @Composable fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem) + FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, receiveFile, onLinkLongClick, scrollToItem) } fun deleteMessageQuestionText(): String { @@ -204,14 +203,10 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - fun fileForwardingAllowed() = when { - cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true - getLoadedFilePath(cItem.file) != null -> true - else -> false - } + val copyAndShareAllowed = when { cItem.content.text.isNotEmpty() -> true - fileForwardingAllowed() -> true + cItem.file?.forwardingAllowed() == true -> true else -> false } @@ -261,7 +256,7 @@ fun ChatItemView( }) } if (cItem.meta.itemDeleted == null && - (cItem.file == null || fileForwardingAllowed()) && + (cItem.file == null || cItem.file.forwardingAllowed()) && !cItem.isLiveDummy && !live ) { ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { @@ -338,14 +333,14 @@ fun ChatItemView( fun ContentItem() { val mc = cItem.content.msgContent if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL) + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy) } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile) } else { framedItemView() } @@ -357,7 +352,7 @@ fun ChatItemView( } @Composable fun LegacyDeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL) + DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -410,7 +405,7 @@ fun ChatItemView( @Composable fun DeletedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) @@ -820,13 +815,6 @@ fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteM ) } -private fun showMsgDeliveryErrorAlert(description: String) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_delivery_error_title), - text = description, - ) -} - expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) @Preview @@ -862,6 +850,7 @@ fun PreviewChatItemView() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, developerTools = false, + showViaProxy = false ) } } @@ -897,6 +886,7 @@ fun PreviewChatItemViewDeletedContent() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, developerTools = false, + showViaProxy = false ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt index 7514b6e280..644c1997c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatItem import chat.simplex.common.ui.theme.* @Composable -fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { +fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) { val sent = ci.chatDir.sent val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage @@ -36,7 +36,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), modifier = Modifier.padding(end = 8.dp) ) - CIMetaView(ci, timedMessagesTTL) + CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } } @@ -50,7 +50,8 @@ fun PreviewDeletedItemView() { SimpleXTheme { DeletedItemView( ChatItem.getDeletedContentSampleData(), - null + null, + showViaProxy = false ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 3ede737ffa..4969eccbb6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -17,13 +17,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFo val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont) @Composable -fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) { +fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) { Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { EmojiText(chatItem.content.text) - CIMetaView(chatItem, timedMessagesTTL) + CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 2ac97321c6..aec314d2c1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -23,7 +23,6 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE import chat.simplex.res.MR import kotlin.math.min @@ -34,6 +33,7 @@ fun FramedItemView( uriHandler: UriHandler? = null, imageProvider: (() -> ImageGalleryProvider)? = null, linkMode: SimplexLinkMode, + showViaProxy: Boolean, showMenu: MutableState, receiveFile: (Long) -> Unit, onLinkLongClick: (link: String) -> Unit = {}, @@ -179,9 +179,9 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy) } } @@ -245,7 +245,7 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy) } } is MsgContent.MCVideo -> { @@ -253,35 +253,35 @@ fun FramedItemView( if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy) } } is MsgContent.MCVoice -> { - CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile) + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile) if (mc.text != "") { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler) + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy) } } is MsgContent.MCFile -> ciFileView(ci, mc.text) is MsgContent.MCUnknown -> if (ci.file == null) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick) + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy) } else { ciFileView(ci, mc.text) } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview) Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick) + CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy) } } - else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick) + else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy) } } } } Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { - CIMetaView(ci, chatTTL, metaColor) + CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy) } } } @@ -293,14 +293,15 @@ fun CIMarkdownText( chatTTL: Int?, linkMode: SimplexLinkMode, uriHandler: UriHandler?, - onLinkLongClick: (link: String) -> Unit = {} + onLinkLongClick: (link: String) -> Unit = {}, + showViaProxy: Boolean ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 7be0cc2f6c..bd2aaf140c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -69,7 +69,7 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) { style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), modifier = Modifier.padding(end = 8.dp) ) - CIMetaView(ci, timedMessagesTTL) + CIMetaView(ci, timedMessagesTTL, showViaProxy = false) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 0e2e8867cb..6ecec47b6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState, showViaProxy: Boolean) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( @@ -35,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl Box(Modifier.weight(1f, false)) { MergedMarkedDeletedText(ci, revealed) } - CIMetaView(ci, timedMessagesTTL) + CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } } @@ -112,7 +112,8 @@ fun PreviewMarkedDeletedItemView() { SimpleXTheme { DeletedItemView( ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())), - null + null, + showViaProxy = false ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 66061767e5..343fc47f76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -69,7 +69,8 @@ fun MarkdownText ( modifier: Modifier = Modifier, linkMode: SimplexLinkMode, inlineContent: Pair Unit, Map>? = null, - onLinkLongClick: (link: String) -> Unit = {} + onLinkLongClick: (link: String) -> Unit = {}, + showViaProxy: Boolean = false ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -77,7 +78,7 @@ fun MarkdownText ( val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { "\n" } else if (meta != null) { - reserveSpaceForMeta(meta, chatTTL, null) // LALAL + reserveSpaceForMeta(meta, chatTTL, null, showViaProxy = showViaProxy) } else { " " } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index 3ef219fc9f..cd3e5902d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -66,6 +66,8 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { hostMode = currentCfg.value.hostMode, requiredHostMode = currentCfg.value.requiredHostMode, sessionMode = currentCfg.value.sessionMode, + smpProxyMode = currentCfg.value.smpProxyMode, + smpProxyFallback = currentCfg.value.smpProxyFallback, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpTimeoutPerKb = networkTCPTimeoutPerKb.value, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 4d33040e29..b2124df988 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -43,6 +43,8 @@ fun NetworkAndServersView() { val developerTools = chatModel.controller.appPrefs.developerTools.get() val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } val sessionMode = remember { mutableStateOf(netCfg.sessionMode) } + val smpProxyMode = remember { mutableStateOf(netCfg.smpProxyMode) } + val smpProxyFallback = remember { mutableStateOf(netCfg.smpProxyFallback) } val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } } NetworkAndServersLayout( @@ -51,6 +53,8 @@ fun NetworkAndServersView() { networkUseSocksProxy = networkUseSocksProxy, onionHosts = onionHosts, sessionMode = sessionMode, + smpProxyMode = smpProxyMode, + smpProxyFallback = smpProxyFallback, proxyPort = proxyPort, toggleSocksProxy = { enable -> if (enable) { @@ -137,6 +141,59 @@ fun NetworkAndServersView() { } } } + }, + updateSMPProxyMode = { + if (smpProxyMode.value == it) return@NetworkAndServersLayout + val prevValue = smpProxyMode.value + smpProxyMode.value = it + val startsWith = when (it) { + SMPProxyMode.Always -> generalGetString(MR.strings.network_smp_proxy_mode_always_description) + SMPProxyMode.Unknown -> generalGetString(MR.strings.network_smp_proxy_mode_unknown_description) + SMPProxyMode.Unprotected -> generalGetString(MR.strings.network_smp_proxy_mode_unprotected_description) + SMPProxyMode.Never -> generalGetString(MR.strings.network_smp_proxy_mode_never_description) + } + showUpdateNetworkSettingsDialog( + title = generalGetString(MR.strings.update_network_smp_proxy_mode_question), + startsWith, + onDismiss = { smpProxyMode.value = prevValue } + ) { + withBGApi { + val newCfg = chatModel.controller.getNetCfg().copy(smpProxyMode = it) + val res = chatModel.controller.apiSetNetworkConfig(newCfg) + if (res) { + chatModel.controller.setNetCfg(newCfg) + smpProxyMode.value = it + } else { + smpProxyMode.value = prevValue + } + } + } + }, + updateSMPProxyFallback = { + if (smpProxyFallback.value == it) return@NetworkAndServersLayout + val prevValue = smpProxyFallback.value + smpProxyFallback.value = it + val startsWith = when (it) { + SMPProxyFallback.Allow -> generalGetString(MR.strings.network_smp_proxy_fallback_allow_description) + SMPProxyFallback.AllowProtected -> generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description) + SMPProxyFallback.Prohibit -> generalGetString(MR.strings.network_smp_proxy_fallback_prohibit_description) + } + showUpdateNetworkSettingsDialog( + title = generalGetString(MR.strings.update_network_smp_proxy_fallback_question), + startsWith, + onDismiss = { smpProxyFallback.value = prevValue } + ) { + withBGApi { + val newCfg = chatModel.controller.getNetCfg().copy(smpProxyFallback = it) + val res = chatModel.controller.apiSetNetworkConfig(newCfg) + if (res) { + chatModel.controller.setNetCfg(newCfg) + smpProxyFallback.value = it + } else { + smpProxyFallback.value = prevValue + } + } + } } ) } @@ -147,16 +204,22 @@ fun NetworkAndServersView() { networkUseSocksProxy: MutableState, onionHosts: MutableState, sessionMode: MutableState, + smpProxyMode: MutableState, + smpProxyFallback: MutableState, proxyPort: State, toggleSocksProxy: (Boolean) -> Unit, useOnion: (OnionHosts) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, + updateSMPProxyMode: (SMPProxyMode) -> Unit, + updateSMPProxyFallback: (SMPProxyFallback) -> Unit, ) { val m = chatModel ColumnWithScrollBar( Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } + AppBarTitle(stringResource(MR.strings.network_and_servers)) if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.settings_section_title_messages)) { @@ -165,7 +228,6 @@ fun NetworkAndServersView() { SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } }) if (currentRemoteHost == null) { - val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false) UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) if (developerTools) { @@ -188,6 +250,18 @@ fun NetworkAndServersView() { Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 24.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) } +// if (currentRemoteHost == null) { +// SectionView(generalGetString(MR.strings.settings_section_title_private_message_routing)) { +// SMPProxyModePicker(smpProxyMode, showModal, updateSMPProxyMode) +// SMPProxyFallbackPicker(smpProxyFallback, showModal, updateSMPProxyFallback, enabled = remember { mutableStateOf(smpProxyMode.value != SMPProxyMode.Never) }) +// SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) +// } +// SectionCustomFooter { +// Text(stringResource(MR.strings.private_routing_explanation)) +// } +// Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 32.dp, end = DEFAULT_PADDING_HALF, bottom = 30.dp)) +// } + SectionView(generalGetString(MR.strings.settings_section_title_calls)) { SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) } @@ -452,6 +526,79 @@ private fun SessionModePicker( ) } +@Composable +private fun SMPProxyModePicker( + smpProxyMode: MutableState, + showModal: (@Composable ModalData.() -> Unit) -> Unit, + updateSMPProxyMode: (SMPProxyMode) -> Unit, +) { + val density = LocalDensity.current + val values = remember { + SMPProxyMode.values().map { + when (it) { + SMPProxyMode.Always -> ValueTitleDesc(SMPProxyMode.Always, generalGetString(MR.strings.network_smp_proxy_mode_always), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_always_description), density)) + SMPProxyMode.Unknown -> ValueTitleDesc(SMPProxyMode.Unknown, generalGetString(MR.strings.network_smp_proxy_mode_unknown), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unknown_description), density)) + SMPProxyMode.Unprotected -> ValueTitleDesc(SMPProxyMode.Unprotected, generalGetString(MR.strings.network_smp_proxy_mode_unprotected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_unprotected_description), density)) + SMPProxyMode.Never -> ValueTitleDesc(SMPProxyMode.Never, generalGetString(MR.strings.network_smp_proxy_mode_never), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_mode_never_description), density)) + } + } + } + + SectionItemWithValue( + generalGetString(MR.strings.network_smp_proxy_mode_private_routing), + smpProxyMode, + values, + icon = painterResource(MR.images.ic_settings_ethernet), + onSelected = { + showModal { + ColumnWithScrollBar( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_smp_proxy_mode_private_routing)) + SectionViewSelectable(null, smpProxyMode, values, updateSMPProxyMode) + } + } + } + ) +} + +@Composable +private fun SMPProxyFallbackPicker( + smpProxyFallback: MutableState, + showModal: (@Composable ModalData.() -> Unit) -> Unit, + updateSMPProxyFallback: (SMPProxyFallback) -> Unit, + enabled: State, +) { + val density = LocalDensity.current + val values = remember { + SMPProxyFallback.values().map { + when (it) { + SMPProxyFallback.Allow -> ValueTitleDesc(SMPProxyFallback.Allow, generalGetString(MR.strings.network_smp_proxy_fallback_allow), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_description), density)) + SMPProxyFallback.AllowProtected -> ValueTitleDesc(SMPProxyFallback.AllowProtected, generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_allow_protected_description), density)) + SMPProxyFallback.Prohibit -> ValueTitleDesc(SMPProxyFallback.Prohibit, generalGetString(MR.strings.network_smp_proxy_fallback_prohibit), escapedHtmlToAnnotatedString(generalGetString(MR.strings.network_smp_proxy_fallback_prohibit_description), density)) + } + } + } + + SectionItemWithValue( + generalGetString(MR.strings.network_smp_proxy_fallback_allow_downgrade), + smpProxyFallback, + values, + icon = painterResource(MR.images.ic_arrows_left_right), + enabled = enabled, + onSelected = { + showModal { + ColumnWithScrollBar( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_smp_proxy_fallback_allow_downgrade)) + SectionViewSelectable(null, smpProxyFallback, values, updateSMPProxyFallback) + } + } + } + ) +} + @Composable private fun NetworkSectionFooter(revert: () -> Unit, save: () -> Unit, revertDisabled: Boolean, saveDisabled: Boolean) { Row( @@ -506,8 +653,12 @@ fun PreviewNetworkAndServersLayout() { toggleSocksProxy = {}, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, + smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) }, + smpProxyFallback = remember { mutableStateOf(SMPProxyFallback.Allow) }, useOnion = {}, updateSessionMode = {}, + updateSMPProxyMode = {}, + updateSMPProxyFallback = {}, ) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 6ca14aace2..93e6a902f4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -255,8 +255,20 @@ Message delivery error + Message delivery warning Most likely this contact has deleted the connection with you. + + Error: %1$s + Wrong key or unknown connection - most likely this connection is deleted. + Capacity exceeded - recipient did not receive previously sent messages. + Network issues - message expired after many attempts to send it. + Destination server error: %1$s + Forwarding server: %1$s\nError: %2$s + Forwarding server: %1$s\nDestination server error: %2$s + Server address is incompatible with network settings. + Server version is incompatible with network settings. + Reply Share @@ -710,6 +722,26 @@ Update transport isolation mode? Use .onion hosts to No if SOCKS proxy does not support them.]]> Please note: message and file relays are connected via SOCKS proxy. Calls and sending link previews use direct connection.]]> + Private routing + Always + Unknown relays + Unprotected + Never + Always use private routing. + Use private routing with unknown servers. + Use private routing with unknown servers when IP address is not protected. + Do NOT use private routing. + Message routing mode + Allow downgrade + Yes + When IP hidden + No + Send messages directly when your or destination server does not support private routing. + Send messages directly when IP address is protected and your or destination server does not support private routing. + Do NOT send messages directly, even if your or destination server does not support private routing. + Message routing fallback + Show message status + To protect your IP address, private routing uses your SMP servers to deliver messages. Appearance Customize theme THEME COLORS @@ -1035,6 +1067,7 @@ THEMES Profile images MESSAGES AND FILES + PRIVATE MESSAGE ROUTING CALLS Network connection Incognito mode diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg new file mode 100644 index 0000000000..d393921c05 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg new file mode 100644 index 0000000000..de3a39b826 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index fcbf8b9d9e..69097e29e0 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -26,11 +26,11 @@ android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=5.7.3 -android.version_code=206 +android.version_name=5.8-beta.0 +android.version_code=208 -desktop.version_name=5.7.3 -desktop.version_code=44 +desktop.version_name=5.8-beta.0 +desktop.version_code=45 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 76f57585a8..87950ecce7 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -14,6 +14,7 @@ module Directory.Events DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, + directoryCmdTag, viewName, ) where @@ -21,6 +22,8 @@ where import Control.Applicative ((<|>)) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Char (isSpace) +import Data.Either (fromRight) import Data.Functor (($>)) import Data.Text (Text) import qualified Data.Text as T @@ -34,13 +37,11 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Shared import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) -import Data.Char (isSpace) -import Data.Either (fromRight) data DirectoryEvent = DEContactConnected Contact | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} - | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} + | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} | DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo} | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed | DEServiceRoleChanged GroupInfo GroupMemberRole @@ -140,25 +141,26 @@ directoryCmdP = cmdStrP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) <|> pure (ADC SDRUser DCUnknownCommand) - tagP = A.takeTill (== ' ') >>= \case - "help" -> u DCHelp_ - "h" -> u DCHelp_ - "next" -> u DCSearchNext_ - "all" -> u DCAllGroups_ - "new" -> u DCRecentGroups_ - "submit" -> u DCSubmitGroup_ - "confirm" -> u DCConfirmDuplicateGroup_ - "list" -> u DCListUserGroups_ - "ls" -> u DCListUserGroups_ - "delete" -> u DCDeleteGroup_ - "approve" -> su DCApproveGroup_ - "reject" -> su DCRejectGroup_ - "suspend" -> su DCSuspendGroup_ - "resume" -> su DCResumeGroup_ - "last" -> su DCListLastGroups_ - "exec" -> su DCExecuteCommand_ - "x" -> su DCExecuteCommand_ - _ -> fail "bad command tag" + tagP = + A.takeTill (== ' ') >>= \case + "help" -> u DCHelp_ + "h" -> u DCHelp_ + "next" -> u DCSearchNext_ + "all" -> u DCAllGroups_ + "new" -> u DCRecentGroups_ + "submit" -> u DCSubmitGroup_ + "confirm" -> u DCConfirmDuplicateGroup_ + "list" -> u DCListUserGroups_ + "ls" -> u DCListUserGroups_ + "delete" -> u DCDeleteGroup_ + "approve" -> su DCApproveGroup_ + "reject" -> su DCRejectGroup_ + "suspend" -> su DCSuspendGroup_ + "resume" -> su DCResumeGroup_ + "last" -> su DCListLastGroups_ + "exec" -> su DCExecuteCommand_ + "x" -> su DCExecuteCommand_ + _ -> fail "bad command tag" where u = pure . ADCT SDRUser su = pure . ADCT SDRSuperUser @@ -192,3 +194,23 @@ directoryCmdP = viewName :: String -> String viewName n = if ' ' `elem` n then "'" <> n <> "'" else n + +directoryCmdTag :: DirectoryCmd r -> Text +directoryCmdTag = \case + DCHelp -> "help" + DCSearchGroup _ -> "search" + DCSearchNext -> "next" + DCAllGroups -> "all" + DCRecentGroups -> "new" + DCSubmitGroup _ -> "submit" + DCConfirmDuplicateGroup {} -> "confirm" + DCListUserGroups -> "list" + DCDeleteGroup {} -> "delete" + DCApproveGroup {} -> "approve" + DCRejectGroup {} -> "reject" + DCSuspendGroup {} -> "suspend" + DCResumeGroup {} -> "resume" + DCListLastGroups _ -> "last" + DCExecuteCommand _ -> "exec" + DCUnknownCommand -> "unknown" + DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index d158b57e22..eefb1f77a4 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -2,9 +2,9 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE MultiWayIf #-} module Directory.Service ( welcomeGetOpts, @@ -15,6 +15,7 @@ where import Control.Concurrent (forkIO) import Control.Concurrent.Async import Control.Concurrent.STM +import Control.Logger.Simple import Control.Monad import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, maybeToList) @@ -37,7 +38,7 @@ import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Shared -import Simplex.Chat.View (serializeChatResponse, simplexChatContact) +import Simplex.Chat.View (serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM @@ -96,9 +97,11 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi DEUnsupportedMessage _ct _ciId -> pure () DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () - DEContactCommand ct ciId aCmd -> case aCmd of - ADC SDRUser cmd -> deUserCommand env ct ciId cmd - ADC SDRSuperUser cmd -> deSuperUserCommand ct ciId cmd + DEContactCommand ct ciId (ADC sUser cmd) -> do + logInfo $ "command received " <> directoryCmdTag cmd + case sUser of + SDRUser -> deUserCommand env ct ciId cmd + SDRSuperUser -> deSuperUserCommand ct ciId cmd where withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId notifySuperUsers s = withSuperUsers $ \contactId -> sendMessage' cc contactId s @@ -107,7 +110,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi withGroupReg GroupInfo {groupId, localDisplayName} err action = do atomically (getGroupReg st groupId) >>= \case Just gr -> action gr - Nothing -> putStrLn $ T.unpack $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId + Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId groupInfoText GroupProfile {displayName = n, fullName = fn, description = d} = n <> (if n == fn || T.null fn then "" else " (" <> fn <> ")") <> maybe "" ("\nWelcome message:\n" <>) d userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName @@ -152,23 +155,25 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi deContactConnected :: Contact -> IO () deContactConnected ct = when (contactDirect ct) $ do - unless testing $ putStrLn $ T.unpack (localDisplayName' ct) <> " connected" + logInfo $ (viewContactName ct) <> " connected" sendMessage cc ct $ - "Welcome to " <> serviceName <> " service!\n\ - \Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ - \For example, send _privacy_ to find groups about privacy.\n\ - \Or send */all* or */new* to list groups.\n\n\ - \Content and privacy policy: https://simplex.chat/docs/directory.html" + ("Welcome to " <> serviceName <> " service!\n") + <> "Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ + \For example, send _privacy_ to find groups about privacy.\n\ + \Or send */all* or */new* to list groups.\n\n\ + \Content and privacy policy: https://simplex.chat/docs/directory.html" deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO () deGroupInvitation ct g@GroupInfo {groupProfile = GroupProfile {displayName, fullName}} fromMemberRole memberRole = do + logInfo $ "invited to group " <> viewGroupName g <> " by " <> viewContactName ct case badRolesMsg $ groupRolesStatus fromMemberRole memberRole of Just msg -> sendMessage cc ct msg - Nothing -> getDuplicateGroup g >>= \case - Just DGUnique -> processInvitation ct g - Just DGRegistered -> askConfirmation - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Nothing -> + getDuplicateGroup g >>= \case + Just DGUnique -> processInvitation ct g + Just DGRegistered -> askConfirmation + Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." where askConfirmation = do ugrId <- addGroupReg st ct g GRSPendingConfirmation @@ -205,7 +210,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi _ -> Nothing deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () - deServiceJoinedGroup ctId g owner = + deServiceJoinedGroup ctId g owner = do + logInfo $ "service joined group " <> viewGroupName g withGroupReg g "joined group" $ \gr -> when (ctId `isOwner` gr) $ do setGroupRegOwner st gr owner @@ -214,7 +220,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case CRGroupLinkCreated {connReqContact} -> do setGroupStatus st gr GRSPendingUpdate - notifyOwner gr + notifyOwner + gr "Created the public link to join the group via this directory service that is always online.\n\n\ \Please add it to the group welcome message.\n\ \For example, add:" @@ -228,24 +235,26 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi _ -> notifyOwner gr $ unexpectedError "can't create group link" deGroupUpdated :: ContactId -> GroupInfo -> GroupInfo -> IO () - deGroupUpdated ctId fromGroup toGroup = + deGroupUpdated ctId fromGroup toGroup = do + logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do withGroupReg toGroup "group updated" $ \gr -> do let userGroupRef = userGroupReference gr toGroup readTVarIO (groupRegStatus gr) >>= \case GRSPendingConfirmation -> pure () GRSProposed -> pure () - GRSPendingUpdate -> groupProfileUpdate >>= \case - GPNoServiceLink -> - when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded - | ctId `isOwner` gr -> groupLinkAdded gr - | otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself." - GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it." - GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr - GPServiceLinkError -> do - when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." - putStrLn $ "Error: no group link for " <> userGroupRef + GRSPendingUpdate -> + groupProfileUpdate >>= \case + GPNoServiceLink -> + when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message." + GPServiceLinkAdded + | ctId `isOwner` gr -> groupLinkAdded gr + | otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself." + GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it." + GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr + GPServiceLinkError -> do + when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." + logError $ "Error: no group link for " <> T.pack userGroupRef GRSPendingApproval n -> processProfileChange gr $ n + 1 GRSActive -> processProfileChange gr 1 GRSSuspended -> processProfileChange gr 1 @@ -288,7 +297,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved." notifySuperUsers $ "The group " <> groupRef <> " is updated." checkRolesSendToApprove gr n' - GPServiceLinkError -> putStrLn $ "Error: no group link for " <> groupRef <> " pending approval." + GPServiceLinkError -> logError $ "Error: no group link for " <> T.pack groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case @@ -297,7 +306,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi groupLink2 = safeDecodeUtf8 $ strEncode $ simplexChatContact connReqContact hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' - in if + in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink | hadLinkBefore -> GPServiceLinkRemoved | hasLinkNow -> GPServiceLinkAdded @@ -311,18 +320,20 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do - ct_ <- getContact cc dbContactId + ct_ <- getContact cc dbContactId gr_ <- getGroupAndSummary cc dbGroupId let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ - text = maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ - <> "\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:" + text = + maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withSuperUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> viewName (T.unpack displayName) <> " " <> show gaId deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = + deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole withGroupReg g "contact role changed" $ \gr -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole @@ -348,6 +359,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () deServiceRoleChanged g serviceRole = do + logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole withGroupReg g "service role changed" $ \gr -> do let userGroupRef = userGroupReference gr g uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole @@ -371,11 +383,12 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." whenContactIsOwner gr action = - getGroupMember gr >>= - mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) + getGroupMember gr + >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g = + deContactRemovedFromGroup ctId g = do + logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> do when (ctId `isOwner` gr) $ do setGroupStatus st gr GRSRemoved @@ -383,7 +396,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g = + deContactLeftGroup ctId g = do + logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g withGroupReg g "contact left" $ \gr -> do when (ctId `isOwner` gr) $ do setGroupStatus st gr GRSRemoved @@ -391,7 +405,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." deServiceRemovedFromGroup :: GroupInfo -> IO () - deServiceRemovedFromGroup g = + deServiceRemovedFromGroup g = do + logInfo $ "service removed from group " <> viewGroupName g withGroupReg g "service removed" $ \gr -> do setGroupStatus st gr GRSRemoved notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." @@ -402,11 +417,15 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi DCHelp -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ - \1. Invite " <> serviceName <> " bot to your group as *admin* (you can send `/list` to see all groups you submitted).\n\ - \2. " <> serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\ - \3. You will then need to add this link to the group welcome message.\n\ - \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\ - \Start from inviting the bot to your group as admin - it will guide you through the process" + \1. Invite " + <> serviceName + <> " bot to your group as *admin* (you can send `/list` to see all groups you submitted).\n\ + \2. " + <> serviceName + <> " bot will create a public group link for the new members to join even when you are offline.\n\ + \3. You will then need to add this link to the group welcome message.\n\ + \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\ + \Start from inviting the bot to your group as admin - it will guide you through the process" DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case @@ -434,13 +453,13 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found" Just g@GroupInfo {groupProfile = GroupProfile {displayName}} | displayName == gName -> - readTVarIO groupRegStatus >>= \case - GRSPendingConfirmation -> do - getDuplicateGroup g >>= \case - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - _ -> processInvitation ct g - _ -> sendReply $ "Error: the group ID " <> show ugrId <> " (" <> T.unpack displayName <> ") is not pending confirmation." + readTVarIO groupRegStatus >>= \case + GRSPendingConfirmation -> do + getDuplicateGroup g >>= \case + Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g + _ -> sendReply $ "Error: the group ID " <> show ugrId <> " (" <> T.unpack displayName <> ") is not pending confirmation." | otherwise -> sendReply $ "Group ID " <> show ugrId <> " has the display name " <> T.unpack displayName DCListUserGroups -> atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do @@ -462,7 +481,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi let gs' = takeTop searchResults gs moreGroups = length gs - length gs' more = if moreGroups > 0 then ", sending top " <> show (length gs') else "" - sendReply $ "Found " <> show (length gs) <> " group(s)" <> more <> "." + sendReply $ "Found " <> show (length gs) <> " group(s)" <> more <> "." updateSearchRequest (STSearch s) $ groupIds gs' sendFoundGroups gs' moreGroups sendAllGroups takeFirst sortName searchType = \case @@ -499,74 +518,76 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ sendComposedMessage cc ct Nothing msg when (moreGroups > 0) $ - sendComposedMessage cc ct Nothing $ MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." + sendComposedMessage cc ct Nothing $ + MCText $ + "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd | superUser `elem` superUsers = case cmd of - DCApproveGroup {groupId, displayName = n, groupApprovalId} -> do - getGroupAndReg groupId n >>= \case - Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." - Just (g, gr) -> - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingApproval gaId - | gaId == groupApprovalId -> do - getDuplicateGroup g >>= \case - Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." - _ -> do - getGroupRolesStatus g gr >>= \case - Just GRSOk -> do - setGroupStatus st gr GRSActive - sendReply "Group approved!" - notifyOwner gr $ "The group " <> userGroupReference' gr n <> " is approved and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." - Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin - Just GRSContactNotOwner -> replyNotApproved "user is not an owner." - Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin - Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." - where - replyNotApproved reason = sendReply $ "Group is not approved: " <> reason - serviceNotAdmin = serviceName <> " is not an admin." - | otherwise -> sendReply "Incorrect approval code" - _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." - where - groupRef = groupReference' groupId n - DCRejectGroup _gaId _gName -> pure () - DCSuspendGroup groupId gName -> do - let groupRef = groupReference' groupId gName - getGroupAndReg groupId gName >>= \case - Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." - Just (_, gr) -> - readTVarIO (groupRegStatus gr) >>= \case - GRSActive -> do - setGroupStatus st gr GRSSuspended - notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is suspended and hidden from directory. Please contact the administrators." - sendReply "Group suspended!" - _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." - DCResumeGroup groupId gName -> do - let groupRef = groupReference' groupId gName - getGroupAndReg groupId gName >>= \case - Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." - Just (_, gr) -> - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspended -> do - setGroupStatus st gr GRSActive - notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is listed in the directory again!" - sendReply "Group listing resumed!" - _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." - DCListLastGroups count -> - readTVarIO (groupRegs st) >>= \grs -> do - sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "") - void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact cc dbContactId - let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ - sendGroupInfo ct gr dbGroupId $ Just ownerStr - DCExecuteCommand cmdStr -> - sendChatCmdStr cc cmdStr >>= \r -> do - ts <- getCurrentTime - tz <- getCurrentTimeZone - sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r - DCCommandError tag -> sendReply $ "Command error: " <> show tag + DCApproveGroup {groupId, displayName = n, groupApprovalId} -> + getGroupAndReg groupId n >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (g, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSPendingApproval gaId + | gaId == groupApprovalId -> do + getDuplicateGroup g >>= \case + Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." + Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + _ -> do + getGroupRolesStatus g gr >>= \case + Just GRSOk -> do + setGroupStatus st gr GRSActive + sendReply "Group approved!" + notifyOwner gr $ "The group " <> userGroupReference' gr n <> " is approved and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." + Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Just GRSContactNotOwner -> replyNotApproved "user is not an owner." + Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." + where + replyNotApproved reason = sendReply $ "Group is not approved: " <> reason + serviceNotAdmin = serviceName <> " is not an admin." + | otherwise -> sendReply "Incorrect approval code" + _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." + where + groupRef = groupReference' groupId n + DCRejectGroup _gaId _gName -> pure () + DCSuspendGroup groupId gName -> do + let groupRef = groupReference' groupId gName + getGroupAndReg groupId gName >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (_, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSActive -> do + setGroupStatus st gr GRSSuspended + notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is suspended and hidden from directory. Please contact the administrators." + sendReply "Group suspended!" + _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." + DCResumeGroup groupId gName -> do + let groupRef = groupReference' groupId gName + getGroupAndReg groupId gName >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (_, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSSuspended -> do + setGroupStatus st gr GRSActive + notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is listed in the directory again!" + sendReply "Group listing resumed!" + _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." + DCListLastGroups count -> + readTVarIO (groupRegs st) >>= \grs -> do + sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "") + void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do + ct_ <- getContact cc dbContactId + let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ + sendGroupInfo ct gr dbGroupId $ Just ownerStr + DCExecuteCommand cmdStr -> + sendChatCmdStr cc cmdStr >>= \r -> do + ts <- getCurrentTime + tz <- getCurrentTimeZone + sendReply $ serializeChatResponse (Nothing, Just user) ts tz Nothing r + DCCommandError tag -> sendReply $ "Command error: " <> show tag | otherwise = sendReply "You are not allowed to use this command" where superUser = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} @@ -577,8 +598,9 @@ directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testi getGroup cc gId $>>= \g@GroupInfo {groupProfile = GroupProfile {displayName}} -> if displayName == gName - then atomically (getGroupReg st gId) - $>>= \gr -> pure $ Just (g, gr) + then + atomically (getGroupReg st gId) + $>>= \gr -> pure $ Just (g, gr) else pure Nothing sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () diff --git a/blog/20221206-simplex-chat-v4.3-voice-messages.md b/blog/20221206-simplex-chat-v4.3-voice-messages.md index 1ca25ce5d0..07a6e227f0 100644 --- a/blog/20221206-simplex-chat-v4.3-voice-messages.md +++ b/blog/20221206-simplex-chat-v4.3-voice-messages.md @@ -14,7 +14,7 @@ permalink: "/blog/20221206-simplex-chat-v4.3-voice-messages.html" ## SimpleX Chat reviews -Since we published [the security assessment of SimpleX Chat](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) completed by Trail of Bits in November, several sites published the reviews and included it in their recommendations: +Since we published [the security assessment of SimpleX Chat](./20221108-simplex-chat-v4.2-security-audit-new-website.md) completed by Trail of Bits in November, several sites published the reviews and included it in their recommendations: - Privacy Guides added SimpleX Chat to [the recommended private and secure messengers](https://www.privacyguides.org/real-time-communication/#simplex-chat). - Mike Kuketz – a well-known security expert – published [the review of SimpleX Chat](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) and added it to [the messenger matrix](https://www.messenger-matrix.de). diff --git a/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md b/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md index 0a66124934..6f32586bc1 100644 --- a/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md +++ b/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md @@ -42,7 +42,7 @@ Many large tech companies prioritizing value extraction over value creation earn We started working full-time on the project in 2021 when [Portman Wills](https://www.linkedin.com/in/portmanwills/) and [Peter Briffett](https://www.linkedin.com/in/peterbriffett/) (the founders of [Wagestream](https://wagestream.com/en/) where I led the engineering team) supported the company very early on, and several other angel investors joined later. In July 2022 SimpleX Chat raised a pre-seed funding from the VC fund [Village Global](https://www.villageglobal.vc) - its co-founder [Ben Casnocha](https://casnocha.com) was very excited about our vision of privacy-first fully decentralized messaging and community platform, both for the individual users and for the companies, independent of any crypto-currencies, that might grow to replace large centralized platforms, such as WhatsApp, Telegram and Signal. -Overall we raised from our investors approximately $370,000 for a small share of the company to allow the project team working full time for almost two years, funding product design and development, infrastructure, and also [the security assessment by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html). A large part of this money is not spent yet. +Overall we raised from our investors approximately $370,000 for a small share of the company to allow the project team working full time for almost two years, funding product design and development, infrastructure, and also [the security assessment by Trail of Bits](./20221108-simplex-chat-v4.2-security-audit-new-website.md). A large part of this money is not spent yet. The project was hugely supported by the users as well - collectively, [you donated](https://github.com/simplex-chat/simplex-chat#help-us-with-donations) over $25,000. Without these donations the investment we raised would not be possible, because we believe that voluntary user donations can sustain the project in the long term – it already covers all infrastructure costs. There are only two ways an Internet service can exist - either users are paying for it, or the users data becomes the product for the real customers, as happened with many large Internet companies. In the latter case the users are losing much more money than they are saving by giving away their privacy and the rights to the content they create on the centralized platforms. diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index fc924a8706..0222c25d77 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -50,7 +50,7 @@ Other limitations of the desktop app: - you cannot send voice messages. - there is no support for calls yet. -You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads). Windows version will be available soon. +You can download the desktop app for Linux and Mac via [downloads page](../docs/DOWNLOADS.md). Windows version will be available soon. ## Group directory service and other group improvements diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md index 4fbfc400ad..7f50446bfa 100644 --- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -40,7 +40,7 @@ This is only possible when both devices are connected to the same local network. **On desktop** -If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. +If you don't have desktop app installed yet, [download it](../docs/DOWNLOADS.md) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. Then in desktop app settings choose *Link a mobile* - it will show a QR code. diff --git a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md index 1e4c3adfb5..6d4c8b77a2 100644 --- a/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md +++ b/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md @@ -10,6 +10,8 @@ permalink: "/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ra # SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm +**Published:** Mar 14, 2024 + This is a major upgrade for SimpleX messaging protocols, we are really proud to present the results of the hard work of our whole team on the [Pi day](https://en.wikipedia.org/wiki/Pi_Day). This post also covers various aspects of end-to-end encryption, compares different messengers, and explains why and how quantum-resistant encryption is added to SimpleX Chat: @@ -101,7 +103,7 @@ This attack is much less understood by the users, and forward secrecy does not p Out of all encryption algorithms known to us only _Signal double ratchet algorithm_ (also referred to as _Signal algorithm_ or _double ratchet algorithm_, which is not the same as Signal messaging platform and protocols) provides the ability for the encryption security to recover after break-ins attacks. This recovery happens automatically and transparently to the users, without them doing anything special or even knowing about break-in, by simply sending messages. Every time one of the communication parties replies to another party message, new random keys are generated and previously stolen keys become useless. -Double ratchet algorithm is used in Signal, Cwtch and SimpleX Chat. This is why you cannot use SimpleX Chat profile on more than one device at the same time - the encryption scheme rotates the long term keys, randomly, and keys on another device become useless, as they would become useless for the attacker who stole them. Security always has some costs to the convenience. +Double ratchet algorithm is used in Signal, Cwtch and SimpleX Chat. But Signal app by allowing to use the same profile on multiple devices compromises the break-in recovery function of Signal algorithm, as explained in [this paper](https://eprint.iacr.org/2021/626.pdf). Because of break-in recovery you cannot use SimpleX Chat profile on more than one device at the same time - the encryption scheme rotates the long term keys, randomly, and keys on another device become useless, as they would become useless for the attacker who stole them. Security always has some costs to the convenience. ### 5. Man-in-the-middle attack - mitigated by two-factor key exchange diff --git a/blog/20240416-dangers-of-metadata-in-messengers.md b/blog/20240416-dangers-of-metadata-in-messengers.md index 3b30003798..b0832af4f7 100644 --- a/blog/20240416-dangers-of-metadata-in-messengers.md +++ b/blog/20240416-dangers-of-metadata-in-messengers.md @@ -33,7 +33,7 @@ For example, while WhatsApp messages are [end-to-end encrypted](https://faq.what This is called [metadata](https://en.wikipedia.org/wiki/Metadata). It reveals a wealth of information about you and your connections, and in the hands of a centralized monopoly, this can and does get misused in incredibly dangerous ways. Once such metadata is logged, it can create very detailed profiles about who you are, everywhere you’ve been, and everyone you’ve ever spoken to. In settling for apps that normalize this while giving you the illusion of privacy in their marketing, we are doing ourselves a disservice by accepting this as the default. Collectively, we aren’t doing enough to protect ourselves and our social graph from this invasive overreach. -When stored, aggregated and analyzed, this metadata provides ample information that could potentially incriminate someone or be submitted to authorities. When WhatsApp and Facebook Messenger enabled end-to-end encryption for messages, of course it was a welcome and widely celebrated change. But it’s important to remember that not all end-to-end encryption utilizes the same standards, [some implementations are more secure](https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html#how-secure-is-end-to-end-encryption-in-different-messengers) than others, so it’s something that shouldn’t necessarily be accepted at face value. More importantly: collecting and storing an obscene amount of metadata should invite global scrutiny, considering this data is often combined with whatever other information companies like Meta harvest about your identity (which is [a lot](https://www.vox.com/recode/23172691/meta-tracking-privacy-hospitals).) +When stored, aggregated and analyzed, this metadata provides ample information that could potentially incriminate someone or be submitted to authorities. When WhatsApp and Facebook Messenger enabled end-to-end encryption for messages, of course it was a welcome and widely celebrated change. But it’s important to remember that not all end-to-end encryption utilizes the same standards, [some implementations are more secure](./20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md#how-secure-is-end-to-end-encryption-in-different-messengers) than others, so it’s something that shouldn’t necessarily be accepted at face value. More importantly: collecting and storing an obscene amount of metadata should invite global scrutiny, considering this data is often combined with whatever other information companies like Meta harvest about your identity (which is [a lot](https://www.vox.com/recode/23172691/meta-tracking-privacy-hospitals).) @@ -45,8 +45,8 @@ But we also need to acknowledge that the world is becoming increasingly dangerou End-to-end encryption is a solid start, but it's just the beginning of our pursuit for true privacy and security. True privacy means that even when legal demands come knocking, there's no useful metadata to hand over. It's not enough to just protect the content of messages; we need consistent innovation in protecting metadata too. -Changing ingrained habits is tough, but your privacy is always worth the fight. Although giants like WhatsApp and Telegram may dominate global messaging for now, increasing concerns about data harvesting and AI-driven surveillance are fueling demand for alternatives. SimpleX Chat aims to be one of those strong alternatives, hence its radical focus on a decentralized framework with no user identifiers (in other words, nothing that uniquely identifies users on the protocol level to their contacts or to the relays) and extra optionality (self-hosting an [SMP server](https://simplex.chat/docs/server.html) or [XFTP server](https://simplex.chat/docs/xftp-server.html), access via Tor, [chat profiles](https://simplex.chat/docs/guide/chat-profiles.html) with incognito mode, etc.) +Changing ingrained habits is tough, but your privacy is always worth the fight. Although giants like WhatsApp and Telegram may dominate global messaging for now, increasing concerns about data harvesting and AI-driven surveillance are fueling demand for alternatives. SimpleX Chat aims to be one of those strong alternatives, hence its radical focus on a decentralized framework with no user identifiers (in other words, nothing that uniquely identifies users on the protocol level to their contacts or to the relays) and extra optionality (self-hosting an [SMP server](../docs/SERVER.md) or [XFTP server](../docs/XFTP-SERVER.md), access via Tor, [chat profiles](../docs/guide/chat-profiles.md) with incognito mode, etc.) As of today, most messaging alternatives, including SimpleX, will have some limitations. But with the limited resources we have, we are committed to daily progress towards creating a truly private messenger that anyone can use while maintaining the features that users have come to know and love in messaging interfaces. We want to be the prime example of a messenger that achieves genuine privacy without compromising it for convenience. We need to be able to reliably move away from small and niche use cases to endorsing and enforcing global standards for privacy and making it accessible for all users regardless of their technical expertise. -We’re grateful for the users and [donors](https://github.com/simplex-chat/simplex-chat#help-us-with-donations) who have been following along on this journey thus far and helping with feedback, anything from bug reports to identifying potential risks. Building in the open has always been a necessity for transparency and ongoing [auditability](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html), because we don’t want anyone to just take our word for it. [See for yourself](https://github.com/simplex-chat) and engage in the discussions. We fully expect you to hold us accountable to our word. +We’re grateful for the users and [donors](https://github.com/simplex-chat/simplex-chat#help-us-with-donations) who have been following along on this journey thus far and helping with feedback, anything from bug reports to identifying potential risks. Building in the open has always been a necessity for transparency and ongoing [auditability](./20221108-simplex-chat-v4.2-security-audit-new-website.md), because we don’t want anyone to just take our word for it. [See for yourself](https://github.com/simplex-chat) and engage in the discussions. We fully expect you to hold us accountable to our word. diff --git a/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md b/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md index a321dbc6fa..cb3e5b2d10 100644 --- a/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md +++ b/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md @@ -10,6 +10,8 @@ permalink: "/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user # SimpleX network: legally binding transparency, v5.7 released with better calls and messages +**Published:** Apr 26, 2024 + What's new in v5.7: - [quantum resistant end-to-end encryption](#quantum-resistant-end-to-end-encryption) with all contacts. - [forward and save messages](#forward-and-save-messages) without revealing the source. @@ -23,10 +25,10 @@ Also, we added Lithuanian interface language to the Android and desktop apps, th We are committed to open-source, privacy and security. Here are the recent changes we made: -- We now have a [Transparency Reports](https://simplex.chat/transparency/) page. -- We updated our [Privacy Policy](https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md) to remove undefined terms "impermissible" and "acceptable", which would allow us to remove anything we don't like, without any clarity on what that is. You can see the edits [here](https://github.com/simplex-chat/simplex-chat/pull/4076/files). -- We published a new page with [Frequently Asked Questions](https://simplex.chat/faq/), thanks to the guidance from users. -- We also have a new [Security Policy](https://simplex.chat/security/) – we welcome your feedback on it. +- We now have a [Transparency Reports](../docs/TRANSPARENCY.md) page. +- We updated our [Privacy Policy](../PRIVACY.md) to remove undefined terms "impermissible" and "acceptable", which would allow us to remove anything we don't like, without any clarity on what that is. You can see the edits [here](https://github.com/simplex-chat/simplex-chat/pull/4076/files). +- We published a new page with [Frequently Asked Questions](../docs/FAQ.md), thanks to the guidance from users. +- We also have a new [Security Policy](../docs/SECURITY.md) – we welcome your feedback on it. What do we mean by “legally binding transparency?”. It includes these principles: - Accountability: an empty promise or commitment to transparency that is not legally binding is just marketing, and can provide opportunities for the organizations to be misleading or not disclose important information that can affect their users privacy and security. diff --git a/blog/20240516-simplex-redefining-privacy-hard-choices.md b/blog/20240516-simplex-redefining-privacy-hard-choices.md new file mode 100644 index 0000000000..30c8a89e14 --- /dev/null +++ b/blog/20240516-simplex-redefining-privacy-hard-choices.md @@ -0,0 +1,79 @@ +--- +layout: layouts/article.html +title: "SimpleX: Redefining Privacy by Making Hard Choices" +date: 2024-05-16 +previewBody: blog_previews/20240516.html +image: images/simplex-explained.png +imageWide: true +permalink: "/blog/20240516-simplex-redefining-privacy-hard-choices.html" +--- + +# SimpleX: Redefining Privacy by Making Hard Choices + +**Published:** May 16, 2024 + +When it comes to open source privacy tools, the status quo often dictates the limitations of existing protocols and structures. However, these norms need to be challenged to radically shift how we approach genuinely private communication. This requires doing some uncomfortable things, like making hard choices as it relates to funding, alternative decentralization models, doubling down on privacy over convenience, and more. + +There will always be questions on why the SimpleX Chat and network makes the choices it makes, and that’s good! It’s important to question us and to understand the reasoning behind each decision, whether it’s technical, structural, financial or any other. + +In this post we explain a bit more about why SimpleX operates and makes decisions the way it does. + +## No user accounts + +Within SimpleX network there are no user accounts, and more importantly, no user profile identifiers whatsoever at the protocol level, not even random numbers or cryptographic keys used to identify the users. This means there is absolutely nothing that uniquely links users to their contacts or to the network relays. While it's accurate to say, "You need an address to send something," it's crucial to understand that this "address" serves merely as a transient delivery destination, and not as a user profile identifier in any sense. + +You can read more about how SimpleX works [here](https://simplex.chat/#how-simplex-works). + +## Privacy over convenience + +One of the main considerations often ignored in security and privacy comparisons between messaging applications is multi-device access. For example, in Signal’s case, the Sesame protocol used to support multi-device access has the vulnerability that is [explained in detail here](https://eprint.iacr.org/2021/626.pdf): + +_"We present an attack on the post-compromise security of the Signal messenger that allows to stealthily register a new device via the Sesame protocol. [...] This new device can send and receive messages without raising any ‘Bad encrypted message’ errors. Our attack thus shows that the Signal messenger does not guarantee post-compromise security at all in the multi-device setting"_. + + + +Solutions are possible, and even the quoted paper proposes improvements, but they are not implemented in any existing communication solutions. Unfortunately this results in most communication systems, even those in the privacy space, having compromised security in multi-device settings due to these limitations. That's the reason we are not rushing a full multi-device support, and currently only provide [the ability to use mobile app profiles via the desktop app](./20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol), while they are on the same network. + +Another choice that compromises privacy for convenience and usability is 3rd party push notifications. At SimpleX, we take a slow path of optimizing the network and battery consumption in the app, rather than simply hiding inefficiencies behind the quick fix solution of 3rd party push notifications that [increases vulnerability](https://www.wired.com/story/apple-google-push-notification-surveillance/), a path Signal and others chose. Like other choices, it has usability and optimization trade offs, but ultimately it’s the right thing to continue progressing towards a better solution as we explain [here](https://simplex.chat/blog/20220404-simplex-chat-instant-notifications.html). + +Whenever possible, we strive to achieve significantly higher levels of privacy and security. For example, unlike most, if not all, applications (including Signal), [we encrypt application files with per-file unique key](https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html#encrypted-local-files-and-media-with-forward-secrecy). Consequently, once a message is deleted, there's no means to open a file that someone may have stolen in hopes of acquiring the key later. Similarly, apps like Session have done away with forward secrecy, a decision which caused them [not to be recommended](https://www.privacyguides.org/en/real-time-communication/#additional-options) for "long-term or sensitive communications". And [misinformation](https://simplifiedprivacy.com/spain-has-banned-telegram-defending-session/) around this makes it dangerous and irresponsible to recommend without such necessary disclosures for people’s awareness. + +Session’s decision was based on [the incorrect statements](https://getsession.org/blog/session-protocol-explained) about double ratchet being impossible in decentralized networks, and underplayed importance of forward secrecy, break-in recovery and deniability - the absence of these crucial qualities makes Session a much weaker choice for private messaging. For transparency, this was something that was debated with their team [here](https://twitter.com/SimpleXChat/status/1755216356159414602). We also made [a separate post](./20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md#end-to-end-encryption-security-attacks-and-defense) about these qualities of end-to-end encryption and their presence in different messengers, to show that not all end-to-end encrypted apps offer the same level of protections. + +## Network decentralization + + + +It's important to recognize that a model of decentralization where all servers are openly known and accessible to all clients, that some users ask for, actually results in a less decentralized network, and as the network grows it often requires an introduction of a central authority to protect from bad actors with malicious intent. Therefore, we've deliberately opted for a slower path towards achieving a higher degree of decentralization where there is no central server registry or network authority. For example, p2p designs may offer higher initial decentralization but often compromise on privacy and eventual decentralization. In essence, our approach prioritizes a balance between initial decentralization, privacy, and higher degree of decentralization down the line. + +Additionally, while it's true that we haven't yet established a model to incentivize other network operators, it's certainly on the roadmap. We see the decentralization of network operators offered within the app as a top priority.  + +Where it stands today, users have the freedom to select their preferred servers within the SimpleX network by configuring the app, with thousands of self-hosted servers in operation. Moreover, numerous third-party applications rely on our code for their in-app communications, operating independently of our servers, many of which we may not even be aware of. + +Decentralization is an ongoing journey, and we strive to proceed at a measured pace to ensure its proper implementation. While the immediate results may not always appear ideal, prioritizing a careful approach ensures that in the long run, the decisions made in this area align with our ultimate objectives of a private, efficient, reliable and fully decentralized network. + +## Funding and profitability + +We explain our rationale for funding [here](../docs/FAQ.md#funding-and-business-model). Funding sources is always one of the most difficult choices to make, and it’s important to underline that VC models don’t necessarily translate to a quest for control, interference of any kind, or overall influence on product roadmap and strategy. The vast majority of investors seek profitability. Irrespective of the organization type profitability is essential for a sustainable operation, and it can and should be done while adhering to the highest possible standards for privacy. For-profit vs. nonprofit is also not an accurate metric to measure a commitment towards privacy and open standards, which is further explained [here](./20240404-why-i-joined-simplex-chat-esraa-al-shafei.md).   + +To make a profit, satisfying customers is the key. Unlike the many companies that profit from selling customer data, we put user privacy first. Doing this at scale requires investments. If the investors don’t own or control a company, their participation becomes merely about profit for them, and not about how this profit is obtained. With the investors we have, we are completely aligned on this - they are betting on the future where privacy is the norm. They do not dictate on anything related to our model. We build SimpleX chat, protocols and network the way Internet should have been built if we as developers always put the privacy and empowerment of users first. + +## Company jurisdiction + + + +With regards to jurisdictions, nowhere is perfect. For that reason we plan to establish the foundations for protocol governance in [various jurisdictions](https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.html#the-journey-to-the-decentralized-non-profit-protocol-governance). + +But we’d like to clarify some misconceptions about the UK, where SimpleX Chat Ltd. is registered, and the UK legislation. + +For example, the Online Safety Act (OSA). Some people believe that it applies only to UK companies. But the OSA applicability isn’t determined by the company’s jurisdiction - it applies based on the nature and characteristics of the business and its services, as well as the number of its users in the UK. In case of SimpleX network, the OSA doesn’t apply for both of these reasons. + +The UK’s position on communication encryption, and more specifically, on end-to-end encrypted messaging, remains the subject of political debates. But with the OSA, the legislative intent was to propose technical measures to block CSAM, and it was trying to explore ways to do this via client-side scanning, which of course would undermine the encryption. However, and thanks to the hard work of privacy experts, researchers, academics and rights organizations throughout the UK and the rest of the world, the Online Safety Bill did not prohibit end-to-end encrypted apps without such scanners. It is an open question whether such technology will ever be possible, and the UK government made a public commitment that client-side scanning won't be required until it is. + +For now, strong end-to-end encryption remains permissible and protected, and we hope to also add to the privacy advocacy and debates as a UK-based company to keep it legally protected. + +Overall, we view the UK as being better jurisdiction for privacy than many alternatives - there are some trade-offs everywhere. + +## Looking ahead  + +The future of the Internet should be based on decentralized infrastructure operated by commercially viable organizations. These operators need to possess minimal user data, so that users have genuine control over their identities, and free from lock-in by the operators, to support fair competition. This requires a drastic re-imagining of the current norms and newer, more privacy-minded protocols. All in all, private messaging is surrounded by very difficult challenges but it’s worth it to keep pushing the industry forward and not settle for the status quo and current trade offs, protocol limitations and vulnerabilities. The Internet deserves better standards, and so do users. diff --git a/blog/README.md b/blog/README.md index 2f3b5aeeb2..a5f3d60b2e 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,19 @@ # Blog +May 16, 2024 [SimpleX: Redefining Privacy by Making Hard Choices](./20240516-simplex-redefining-privacy-hard-choices.md) + +When it comes to open source privacy tools, the status quo often dictates the limitations of existing protocols and structures. However, these norms need to be challenged to radically shift how we approach genuinely private communication. This requires doing some uncomfortable things, like making hard choices as it relates to funding, alternative decentralization models, doubling down on privacy over convenience, and more. + +In this post we explain a bit more about why SimpleX operates and makes decisions the way it does: + +- No user accounts. +- Privacy over convenience. +- Network decentralization. +- Funding and profitability. +- Company jurisdiction. + +--- + Apr 26, 2024 [SimpleX network: legally binding transparency, v5.7 released with better calls and messages](./20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md) We published Transparency Reports, Security Policy, and Frequently Asked Questions, and updated Privacy Policy. diff --git a/blog/images/20240314-comparison.jpg b/blog/images/20240314-comparison.jpg index 579b2fd73c10ac7c89b24a08e30bccd7a1b1c156..5ff22be0052c3f080a382af4aef961d207b5a44c 100644 GIT binary patch delta 1631 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�i1iutr(5`55I@pxL2luDS3&mJ;!scp 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/blog/lang/fr-fr/README_fr.md b/blog/lang/fr-fr/README_fr.md index bebc42d7bc..471f3ce046 100644 --- a/blog/lang/fr-fr/README_fr.md +++ b/blog/lang/fr-fr/README_fr.md @@ -1,6 +1,6 @@ # Blog -4 févr. 2023 [SimpleX Chat v4.5 publié](./20230103-simplex-chat-v4.4-disappearing-messages.md) +4 févr. 2023 [SimpleX Chat v4.5 publié](../../20230103-simplex-chat-v4.4-disappearing-messages.md) - profils de chat multiples. - brouillon de message. @@ -10,7 +10,7 @@ Nous avons également ajouté [l'interface en italien](#french-language-interface), grâce à nos utilisateurs et à Weblate ! -3 janv. 2023 [SimpleX Chat v4.4 publié](./20230103-simplex-chat-v4.4-disappearing-messages.md) +3 janv. 2023 [SimpleX Chat v4.4 publié](../../20230103-simplex-chat-v4.4-disappearing-messages.md) - messages éphèméres. - messages "en direct" (dynamique). @@ -19,7 +19,7 @@ Nous avons également ajouté [l'interface en italien](#french-language-interfac Nous avons également ajouté [l'interface en français](#french-language-interface), grâce à nos utilisateurs et à Weblate ! -6 déc. 2022 [SimpleX Chat : révision et sortie de la v4.3](./20221206-simplex-chat-v4.3-voice-messages.md) +6 déc. 2022 [SimpleX Chat : révision et sortie de la v4.3](../../20221206-simplex-chat-v4.3-voice-messages.md) Critiques de novembre : @@ -35,7 +35,7 @@ Sortie de la v4.3 : - amélioration de la configuration du serveur SMP et du support des mots de passe du serveur - améliorations de la confidentialité et de la sécurité : protection de l'écran de l'application, sécurité des liens SimpleX, etc. -8 nov. 2022 [Audit de sécurité par Trail of Bits, nouveau site web et sortie de la v4.2](./20221108-simplex-chat-v4.2-security-audit-new-website.md) +8 nov. 2022 [Audit de sécurité par Trail of Bits, nouveau site web et sortie de la v4.2](../../20221108-simplex-chat-v4.2-security-audit-new-website.md) _"Avez-vous été audité ou devons-nous simplement vous ignorer ?"_ @@ -51,7 +51,7 @@ Sortie de la v4.2 : - changer manuellement de contact ou de membre vers une autre adresse / serveur (BETA) - recevoir des fichiers plus rapidement (BETA) -28 sept. 2022 [v4 : chiffrement de la base de données locale](./20220928-simplex-chat-v4-encrypted-database.md) +28 sept. 2022 [v4 : chiffrement de la base de données locale](../../20220928-simplex-chat-v4-encrypted-database.md) - base de données locale de chat chiffrée - si vous utilisez déjà l'application, vous pouvez chiffrer la base de données dans les paramètres de l'application. - support pour les serveurs WebRTC ICE auto-hébergés @@ -61,7 +61,7 @@ Sortie de la v4.2 : - support des images animées dans l'application Android - Interface utilisateur en allemand pour les applications mobiles -1 sept. 2022 [v3.2 : Mode Incognito](./20220901-simplex-chat-v3.2-incognito-mode.md) +1 sept. 2022 [v3.2 : Mode Incognito](../../20220901-simplex-chat-v3.2-incognito-mode.md) - Mode Incognito - utiliser un nouveau nom de profil aléatoire pour chaque contact - utiliser des adresses de serveur .onion avec Tor @@ -71,7 +71,7 @@ Sortie de la v4.2 : L'audit d'implémentation est prévu pour Octobre ! -8 août 2022 [v3.1 : groupes de discussion](./20220808-simplex-chat-v3.1-chat-groups.md) +8 août 2022 [v3.1 : groupes de discussion](../../20220808-simplex-chat-v3.1-chat-groups.md) - enfin, des groupes de chat secrets - personne d'autre que les membres ne sait qu'ils existent ! - accès aux serveurs de messagerie via Tor sur toutes les plateformes @@ -79,37 +79,37 @@ L'audit d'implémentation est prévu pour Octobre ! - protocole de chat publié - nouvelles icônes d'application -23 juil. 2022 [v3.1-beta : accès aux serveurs via Tor](./20220723-simplex-chat-v3.1-tor-groups-efficiency.md) +23 juil. 2022 [v3.1-beta : accès aux serveurs via Tor](../../20220723-simplex-chat-v3.1-tor-groups-efficiency.md) - application terminale : accès aux serveurs de messagerie via un proxy SOCKS5 (par exemple, Tor). - applications mobiles : rejoindre et quitter des groupes de discussion. - utilisation optimisée de la batterie et du trafic - réduction jusqu'à 90x ! - deux configurations docker pour les serveurs SMP auto-hébergés. -11 juil. 2022 [v3 : notifications push instantanées pour iOS et appels audio/vidéo](./20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md) : +11 juil. 2022 [v3 : notifications push instantanées pour iOS et appels audio/vidéo](../../20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md) : - exportation et importation de la base de données de chat - appels audio/vidéo chiffrés de bout en bout - amélioration de la confidentialité du protocole et des performances -4 juin 2022 [v2.2 : nouveaux paramètres de confidentialité et de sécurité](./20220604-simplex-chat-new-privacy-security-settings.md) +4 juin 2022 [v2.2 : nouveaux paramètres de confidentialité et de sécurité](../../20220604-simplex-chat-new-privacy-security-settings.md) -24 mai 2022 [v2.1 : effacement des messages pour une meilleure confidentialité des conversations](./20220524-simplex-chat-better-privacy.md) +24 mai 2022 [v2.1 : effacement des messages pour une meilleure confidentialité des conversations](../../20220524-simplex-chat-better-privacy.md) -11 mai 2022 [Publication de la v2.0 - envoi d'images et de fichiers dans les applications mobiles](./20220511-simplex-chat-v2-images-files.md) +11 mai 2022 [Publication de la v2.0 - envoi d'images et de fichiers dans les applications mobiles](../../20220511-simplex-chat-v2-images-files.md) -04 avr. 2022 [Notifications instantanées pour les applications mobiles SimpleX Chat](./20220404-simplex-chat-instant-notifications.md) +04 avr. 2022 [Notifications instantanées pour les applications mobiles SimpleX Chat](../../20220404-simplex-chat-instant-notifications.md) -08 mars 2022 [Applications mobiles pour iOS et Android](./20220308-simplex-chat-mobile-apps.md) +08 mars 2022 [Applications mobiles pour iOS et Android](../../20220308-simplex-chat-mobile-apps.md) -14 févr. 2022. [SimpleX Chat : rejoignez notre version bêta publique pour iOS](./20220214-simplex-chat-ios-public-beta.md) +14 févr. 2022. [SimpleX Chat : rejoignez notre version bêta publique pour iOS](../../20220214-simplex-chat-ios-public-beta.md) -12 janv. 2022. [SimpleX Chat v1 : la plateforme de chat et d'application la plus privée et la plus sécurisée](./20220112-simplex-chat-v1-released.md) +12 janv. 2022. [SimpleX Chat v1 : la plateforme de chat et d'application la plus privée et la plus sécurisée](../../20220112-simplex-chat-v1-released.md) -08 déc. 2021. [Sortie de SimpleX Chat v0.5 : la première plateforme de chat 100% privée par définition - aucun accès à votre graphe de connexions](./20211208-simplex-chat-v0.5-released.md) +08 déc. 2021. [Sortie de SimpleX Chat v0.5 : la première plateforme de chat 100% privée par définition - aucun accès à votre graphe de connexions](../../20211208-simplex-chat-v0.5-released.md) -14 septembre 2021. [SimpleX Chat v0.4 publié : chat open-source qui utilise un protocole de routage de messages préservant la confidentialité](./20210914-simplex-chat-v0.4-released.md) +14 septembre 2021. [SimpleX Chat v0.4 publié : chat open-source qui utilise un protocole de routage de messages préservant la confidentialité](../../20210914-simplex-chat-v0.4-released.md) -12 mai 2021. [Prototype de chat SimpleX](./20210512-simplex-chat-terminal-ui.md) +12 mai 2021. [Prototype de chat SimpleX](../../20210512-simplex-chat-terminal-ui.md) -22 oct. 2020. [SimpleX Chat](./20201022-simplex-chat.md) +22 oct. 2020. [SimpleX Chat](../../20201022-simplex-chat.md) diff --git a/cabal.project b/cabal.project index f45ff7bfb7..1a6942e4b5 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 4455b8bd0e243aa3bb4dc854037b2e64677963b0 + tag: 1116aeeea1869e0de38e9faccea76b329b549804 source-repository-package type: git diff --git a/docs/CLI.md b/docs/CLI.md index d4f799c7af..abc09b0e7c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -98,7 +98,7 @@ git checkout stable DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . ``` -> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.7-stretch` base image (change it in your local [Dockerfile](Dockerfile)). +> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.7-stretch` base image (change it in your local [Dockerfile](/Dockerfile)). #### In any OS diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 77f588ec26..0885e31725 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -8,7 +8,7 @@ revision: 23.04.2024 While great care is taken to ensure the highest level of security and privacy in SimpleX network servers and clients, all software can have flaws, and we believe it is a critical part of an organization's social responsibility to minimize the impact of these flaws through continual vulnerability discovery efforts, defense in depth design, and prompt remediation and notification. -The security assessment of SimpleX cryptography and networking was done by Trail of Bits in [November 2022](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html). +The security assessment of SimpleX cryptography and networking was done by Trail of Bits in [November 2022](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). We are planning design review of SimpleX protocols in July 2024 and implementation review in December 2024/January 2025. diff --git a/docs/lang/cs/CLI.md b/docs/lang/cs/CLI.md index 9cbce8e6fe..338e48e57e 100644 --- a/docs/lang/cs/CLI.md +++ b/docs/lang/cs/CLI.md @@ -97,7 +97,7 @@ git checkout stable DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . ``` -> **Upozornění:** Pokud narazíte na chybu `` verze `GLIBC_2.28' nenalezena ``, obnovte jej pomocí základního obrazu `haskell:8.10.7-stretch` (změňte jej ve svém lokálním [Dockerfile](Dockerfile)). +> **Upozornění:** Pokud narazíte na chybu `` verze `GLIBC_2.28' nenalezena ``, obnovte jej pomocí základního obrazu `haskell:8.10.7-stretch` (změňte jej ve svém lokálním [Dockerfile](/Dockerfile)). #### V libovolném operačním systému diff --git a/docs/lang/cs/README.md b/docs/lang/cs/README.md index f536cb1aa6..7eab61395e 100644 --- a/docs/lang/cs/README.md +++ b/docs/lang/cs/README.md @@ -26,7 +26,7 @@ - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) s novými funkcemi o 1-2 týdny dříve - **omezeno na 10 000 uživatelů**! - 🖥 K dispozici jako terminálová (konzolová) [aplikace / CLI](#zap-quick-installation-of-a-terminal-app) v systémech Linux, MacOS, Windows. -**NOVINKA**: Bezpečnostní audit od [Trail of Bits](https://www.trailofbits.com/about), [nové webové stránky](https://simplex.chat) a vydána verze 4.2! [Viz oznámení](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +**NOVINKA**: Bezpečnostní audit od [Trail of Bits](https://www.trailofbits.com/about), [nové webové stránky](https://simplex.chat) a vydána verze 4.2! [Viz oznámení](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). ## Obsah @@ -62,23 +62,23 @@ Nestačí používat end-to-end šifrovaný messenger, všichni bychom měli pou ### Úplné soukromí vaší identity, profilu, kontaktů a metadat. -**Na rozdíl od všech ostatních existujících platforem pro zasílání zpráv nemá SimpleX přiřazeny žádné identifikátory uživatelů** - dokonce ani náhodná čísla. To chrání soukromí toho, s kým komunikujete, a skrývá to před servery platformy SimpleX i před jakýmikoli pozorovateli. [Více informací](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). +**Na rozdíl od všech ostatních existujících platforem pro zasílání zpráv nemá SimpleX přiřazeny žádné identifikátory uživatelů** - dokonce ani náhodná čísla. To chrání soukromí toho, s kým komunikujete, a skrývá to před servery platformy SimpleX i před jakýmikoli pozorovateli. [Více informací](./SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). ### Nejlepší ochrana proti spamu a zneužití -Protože na platformě SimpleX nemáte žádný identifikátor, nelze vás kontaktovat, pokud nesdílíte odkaz na jednorázovou pozvánku nebo volitelnou dočasnou uživatelskou adresu. [Více informací](./docs/SIMPLEX.md#nejlepší-ochrana-před-spamem-a-zneužitím). +Protože na platformě SimpleX nemáte žádný identifikátor, nelze vás kontaktovat, pokud nesdílíte odkaz na jednorázovou pozvánku nebo volitelnou dočasnou uživatelskou adresu. [Více informací](./SIMPLEX.md#nejlepší-ochrana-proti-spamu-a-zneužití). ### Úplné vlastnictví, kontrola a zabezpečení vašich dat -SimpleX ukládá všechna uživatelská data na klientských zařízeních, zprávy jsou pouze dočasně uchovávány na relay serverech SimpleX, dokud nejsou přijaty. [Více informací](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data). +SimpleX ukládá všechna uživatelská data na klientských zařízeních, zprávy jsou pouze dočasně uchovávány na relay serverech SimpleX, dokud nejsou přijaty. [Více informací](./SIMPLEX.md#complete-ownership-control-and-security-of-your-data). ### Uživatelé vlastní síť SimpleX -Můžete používat SimpleX s vlastními servery a přitom komunikovat s lidmi, kteří používají servery předkonfigurované v aplikacích nebo jakékoli jiné servery SimpleX. [Více informací](./docs/SIMPLEX.md#users-own-simplex-network). +Můžete používat SimpleX s vlastními servery a přitom komunikovat s lidmi, kteří používají servery předkonfigurované v aplikacích nebo jakékoli jiné servery SimpleX. [Více informací](./SIMPLEX.md#users-own-simplex-network). ## Často kladené otázky -1. _Jak může SimpleX doručovat zprávy bez identifikátorů uživatelů?_ Viz [oznámení o vydání v2](./blog/20220511-simplex-chat-v2-images-files.md#prvni-platforma-zasilani-zpráv-bez-identifikátoru-uživatele), kde je vysvětleno, jak SimpleX funguje. +1. _Jak může SimpleX doručovat zprávy bez identifikátorů uživatelů?_ Viz [oznámení o vydání v2](../../../blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers), kde je vysvětleno, jak SimpleX funguje. 2. _Proč bych neměl používat jen Signal?_ Signal je centralizovaná platforma, která k identifikaci svých uživatelů a jejich kontaktů používá telefonní čísla. To znamená, že zatímco obsah vašich zpráv na službě Signal je chráněn robustním šifrováním end-to-end, pro službu Signal je viditelné velké množství metadat - s kým a kdy hovoříte. @@ -88,17 +88,17 @@ Můžete používat SimpleX s vlastními servery a přitom komunikovat s lidmi, Poslední aktualizace: V současné době je k dispozici několik nových aplikací, např: -[Vydání verze 4.5 - s více uživatelskými profily, návrhem zpráv, izolací transportu a italským rozhraním](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md). +[Vydání verze 4.5 - s více uživatelskými profily, návrhem zpráv, izolací transportu a italským rozhraním](../../../blog/20230204-simplex-chat-v4-5-user-chat-profiles.md). -[03. 01. 2023. v4.4 vydána - s mizejícími zprávami, "živými" zprávami, bezpečnostním ověřováním spojení, GIFy a nálepkami a s francouzským jazykem rozhraní](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md). +[03. 01. 2023. v4.4 vydána - s mizejícími zprávami, "živými" zprávami, bezpečnostním ověřováním spojení, GIFy a nálepkami a s francouzským jazykem rozhraní](../../../blog/20230103-simplex-chat-v4.4-disappearing-messages.md). -[prosinec 06, 2022. Listopadové recenze a vydána verze 4.3 - s okamžitými hlasovými zprávami, nevratným mazáním odeslaných zpráv a vylepšenou konfigurací serveru](./blog/20221206-simplex-chat-v4.3-hlasove-zpravy.md). +[prosinec 06, 2022. Listopadové recenze a vydána verze 4.3 - s okamžitými hlasovými zprávami, nevratným mazáním odeslaných zpráv a vylepšenou konfigurací serveru](../../../blog/20221206-simplex-chat-v4.3-voice-messages.md). -[Nov 08, 2022. Bezpečnostní audit Trail of Bits, vydány nové webové stránky a verze 4.2](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +[Nov 08, 2022. Bezpečnostní audit Trail of Bits, vydány nové webové stránky a verze 4.2](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). -[28. 9. 2022. v4.0: šifrovaná lokální databáze chatu a mnoho dalších změn](./blog/20220928-simplex-chat-v4-encrypted-database.md). +[28. 9. 2022. v4.0: šifrovaná lokální databáze chatu a mnoho dalších změn](../../../blog/20220928-simplex-chat-v4-encrypted-database.md). -[Všechny aktualizace](./blog) +[Všechny aktualizace](../../../blog) ## Vytvoření soukromého připojení @@ -118,13 +118,13 @@ Po instalaci chatovacího klienta jednoduše spusťte `simplex-chat` z terminál ![simplex-chat](./images/connection.gif) -Více informací o [instalaci a používání terminálové aplikace](./docs/CLI.md). +Více informací o [instalaci a používání terminálové aplikace](./CLI.md). ## Návrh platformy SimpleX SimpleX je síť klient-server s unikátní topologií sítě, která využívá redundantní, jednorázové uzly pro předávání zpráv (relay nodes) k asynchronnímu předávání zpráv prostřednictvím jednosměrných (simplexních) front zpráv, což zajišťuje anonymitu příjemce i odesílatele. -Na rozdíl od sítí P2P jsou všechny zprávy předávány přes jeden nebo několik serverových uzlů, které ani nemusí mít perzistenci. Současná implementace [SMP serveru](https://github.com/simplex-chat/simplexmq#smp-server) ve skutečnosti používá ukládání zpráv v paměti a uchovává pouze záznamy o frontách. SimpleX poskytuje lepší ochranu metadat než návrhy P2P, protože k doručování zpráv se nepoužívají globální identifikátory účastníků, a vyhýbá se [problémům sítí P2P](./docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols). +Na rozdíl od sítí P2P jsou všechny zprávy předávány přes jeden nebo několik serverových uzlů, které ani nemusí mít perzistenci. Současná implementace [SMP serveru](https://github.com/simplex-chat/simplexmq#smp-server) ve skutečnosti používá ukládání zpráv v paměti a uchovává pouze záznamy o frontách. SimpleX poskytuje lepší ochranu metadat než návrhy P2P, protože k doručování zpráv se nepoužívají globální identifikátory účastníků, a vyhýbá se [problémům sítí P2P](./SIMPLEX.md#comparison-with-p2p-messaging-protocols). Na rozdíl od federativních sítí nemají uzly serveru **záznamy o uživatelích**, **nekomunikují mezi sebou** a **neukládají zprávy** po jejich doručení příjemcům. Neexistuje způsob, jak zjistit úplný seznam serverů účastnících se sítě SimpleX. Tato konstrukce se vyhýbá problému viditelnosti metadat, který mají všechny federované sítě, a lépe chrání před útoky na celou síť. @@ -132,7 +132,7 @@ Informace o uživatelích, jejich kontaktech a skupinách mají pouze klientská Další informace o cílech a technickém návrhu platformy naleznete v dokumentu [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md). -Formát zpráv zasílaných mezi klienty chatu prostřednictvím [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md) viz [SimpleX Chat Protocol](./docs/protocol/simplex-chat.md). +Formát zpráv zasílaných mezi klienty chatu prostřednictvím [SimpleX Messaging Protocol](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md) viz [SimpleX Chat Protocol](../../protocol/simplex-chat.md). ## Soukromí: technické podrobnosti a omezení @@ -149,7 +149,7 @@ Co je již implementováno: 6. Počínaje verzí v2 protokolu SMP (současná verze je v4) jsou všechna metadata zprávy včetně času, kdy byla zpráva přijata serverem (zaokrouhleno na sekundy), odesílána příjemcům uvnitř šifrované obálky, takže ani v případě kompromitace TLS je nelze pozorovat. 7. Pro spojení klient-server je povoleno pouze TLS 1.2/1.3, omezené na kryptografické algoritmy: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 8. Na ochranu proti útokům typu replay vyžadují servery SimpleX [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) jako ID relace v každém klientském příkazu podepsaném efemérním klíčem per-queue. -9. Pro ochranu vaší IP adresy podporují všichni klienti SimpleX Chat přístup k serverům pro zasílání zpráv přes Tor - více informací najdete v [oznámení o vydání v3.1](./blog/20220808-simplex-chat-v3.1-chat-groups.md). +9. Pro ochranu vaší IP adresy podporují všichni klienti SimpleX Chat přístup k serverům pro zasílání zpráv přes Tor - více informací najdete v [oznámení o vydání v3.1](../../../blog/20220808-simplex-chat-v3.1-chat-groups.md). 10. Šifrování místní databáze s přístupovou frází - kontakty, skupiny a všechny odeslané a přijaté zprávy jsou uloženy šifrovaně. Pokud jste používali SimpleX Chat před verzí 4.0, musíte šifrování povolit prostřednictvím nastavení aplikace. 11. Izolace transportu - pro provoz různých uživatelských profilů se používají různá spojení TCP a okruhy Tor, volitelně - pro různá spojení kontaktů a členů skupin. @@ -166,7 +166,7 @@ Můžete: - použít knihovnu SimpleX Chat k integraci funkcí chatu do svých mobilních aplikací. - vytvářet chatovací boty a služby v jazyce Haskell - viz [simple](./apps/simplex-bot/) a více [advanced chat bot example](./apps/simplex-bot-advanced/). - vytvářet chatovací boty a služby v libovolném jazyce se spuštěným terminálem SimpleX Chat CLI jako lokálním serverem WebSocket. Viz [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) a [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). -- spustit [simplex-chat terminal CLI](./docs/CLI.md) pro provádění jednotlivých příkazů chatu, např. pro odesílání zpráv v rámci provádění shellových skriptů. +- spustit [simplex-chat terminal CLI](./CLI.md) pro provádění jednotlivých příkazů chatu, např. pro odesílání zpráv v rámci provádění shellových skriptů. Pokud uvažujete o vývoji s platformou SimpleX, obraťte se na nás pro případné rady a podporu. @@ -253,7 +253,7 @@ Aktuální jazyky rozhraní: - Italština: [@unbranched](https://github.com/unbranched) - Ruština: projektový tým -Jazyky ve vývoji: Čínština, hindština, čeština, japonština, holandština a [mnoho dalších](https://hosted.weblate.org/projects/simplex-chat/#languages). Další jazyky budeme přidávat, jakmile budou některé z již přidaných jazyků dokončeny - navrhněte prosím nové jazyky, projděte si [průvodce překladem](./docs/TRANSLATIONS.md) a kontaktujte nás! +Jazyky ve vývoji: Čínština, hindština, čeština, japonština, holandština a [mnoho dalších](https://hosted.weblate.org/projects/simplex-chat/#languages). Další jazyky budeme přidávat, jakmile budou některé z již přidaných jazyků dokončeny - navrhněte prosím nové jazyky, projděte si [průvodce překladem](./TRANSLATIONS.md) a kontaktujte nás! ## Přispívejte @@ -294,7 +294,7 @@ Zakladatel SimpleX Chat Protokoly a bezpečnostní model [SimpleX](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) byly revidovány a ve verzi 1.0.0 došlo k mnoha zlomovým změnám a vylepšením. -Bezpečnostní audit provedla v říjnu 2022 společnost [Trail of Bits](https://www.trailofbits.com/about) a většina oprav byla vydána ve verzi 4.2.0 - viz [oznámení](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +Bezpečnostní audit provedla v říjnu 2022 společnost [Trail of Bits](https://www.trailofbits.com/about) a většina oprav byla vydána ve verzi 4.2.0 - viz [oznámení](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). SimpleX Chat je stále relativně ranou fází platformy (mobilní aplikace byly vydány v březnu 2022), takže můžete objevit některé chyby a chybějící funkce. Velmi oceníme, pokud nám dáte vědět o všem, co je třeba opravit nebo vylepšit. diff --git a/docs/lang/cs/SERVER.md b/docs/lang/cs/SERVER.md index 3dd2f3780c..f75adeb8cf 100644 --- a/docs/lang/cs/SERVER.md +++ b/docs/lang/cs/SERVER.md @@ -12,7 +12,7 @@ SMP server je relay server používaný k předávání zpráv v síti SimpleX. Klienti SimpleX pouze určují, který server bude použit pro příjem zpráv, a to pro každý kontakt (nebo spojení skupiny s členem skupiny) zvlášť, přičemž tyto servery jsou pouze dočasné, protože adresa pro doručování se může změnit. -_Upozornění_: když změníte servery v konfiguraci aplikace, ovlivní to pouze to, který server bude použit pro nové kontakty, stávající kontakty se na nové servery automaticky nepřesunou, ale můžete je přesunout ručně pomocí tlačítka ["Změnit adresu příjmu"](../blog/20221108-simplex-chat-v4.2-bezpecnostni-audit-novy-website.md#zmeny-dorucovani-adresy-beta) na stránkách s informacemi o kontaktech/členech - brzy bude automatizováno. +_Upozornění_: když změníte servery v konfiguraci aplikace, ovlivní to pouze to, který server bude použit pro nové kontakty, stávající kontakty se na nové servery automaticky nepřesunou, ale můžete je přesunout ručně pomocí tlačítka ["Změnit adresu příjmu"](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) na stránkách s informacemi o kontaktech/členech - brzy bude automatizováno. ## Instalace diff --git a/docs/lang/fr/CLI.md b/docs/lang/fr/CLI.md index bb596491f1..e5093f20c0 100644 --- a/docs/lang/fr/CLI.md +++ b/docs/lang/fr/CLI.md @@ -97,7 +97,7 @@ git checkout stable DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . ``` -> **Veuillez noter** : Si vous rencontrez l'erreur ``version `GLIBC_2.28' non trouvée``, reconstruisez-le avec l'image de base `haskell:8.10.7-stretch`(changez-la dans votre [Dockerfile](Dockerfile) local). +> **Veuillez noter** : Si vous rencontrez l'erreur ``version `GLIBC_2.28' non trouvée``, reconstruisez-le avec l'image de base `haskell:8.10.7-stretch`(changez-la dans votre [Dockerfile](/Dockerfile) local). #### Utiliser Haskell stack diff --git a/docs/lang/pl/CLI.md b/docs/lang/pl/CLI.md index 585eca3e31..0a72b163bb 100644 --- a/docs/lang/pl/CLI.md +++ b/docs/lang/pl/CLI.md @@ -97,7 +97,7 @@ git checkout stable DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . ``` -> **Uwaga:** Jeśli napotkasz błąd `` version `GLIBC_2.28' not found ``, przebuduj go z obrazem bazowym `haskell:8.10.7-stretch` (zmień go w Twoim lokalnym pliku [Dockerfile](Dockerfile)). +> **Uwaga:** Jeśli napotkasz błąd `` version `GLIBC_2.28' not found ``, przebuduj go z obrazem bazowym `haskell:8.10.7-stretch` (zmień go w Twoim lokalnym pliku [Dockerfile](/Dockerfile)). #### Używając Haskella na dowolnym systemie operacyjnym @@ -200,7 +200,7 @@ Po uruchomieniu czatu zostaniesz poproszony o podanie swojej "nazwy wyświetlane Poniższy schemat przedstawia sposób łączenia się z kontaktem i wysyłania do niego wiadomości:
- +
Gdy już skonfigurujesz swój profil lokalny, wpisz `/c` (oznaczające `/connect`), aby utworzyć nowe połączenie i wygenerować zaproszenie. Wyślij to zaproszenie do swojego kontaktu za pośrednictwem dowolnego innego kanału komunikacji. @@ -219,7 +219,7 @@ Użyj `/help` na czacie, by uzyskać listę pozostałych dostępnych komend. Aby utworzyć grupę, użyj `/g `, a następnie dodaj do niej kontakty za pomocą `/a `. Możesz wysyłać wiadomości do grupy wpisując `# `. Użyj `/help groups`, by uzyskać listę pozostałych dostępnych komend. -![simplex-chat](../images/groups.gif) +![simplex-chat](/images/groups.gif) > **Uwaga**: informacje o grupach nie są przechowywane na żadnym serwerze, są one zapisywane jako lista członków w bazie danych aplikacji klientów, do których będą wysyłane wiadomości. @@ -227,7 +227,7 @@ Aby utworzyć grupę, użyj `/g `, a następnie dodaj do niej konta Możesz wysłać plik do kontaktu za pomocą `/f @ <ścieżka_do_pliku>` - odbiorca będzie musiał go zaakceptować przed rozpoczęciem wysyłania. Użyj `/help files`, by uzyskać listę pozostałych dostępnych komend. -![simplex-chat](../images/files.gif) +![simplex-chat](/images/files.gif) Możesz wysyłać pliki do grupy za pomocą `/f # <ścieżka_do_pliku>`. @@ -241,4 +241,4 @@ Prośby o kontakt możesz przyjąć za pomocą komendy `/ac ` oraz odrzuc Użyj `/help address`, by uzyskać listę pozostałych dostępnych komend. -![simplex-chat](../images/user-addresses.gif) +![simplex-chat](/images/user-addresses.gif) diff --git a/docs/lang/pl/README.md b/docs/lang/pl/README.md index ba40a42d74..23ca00c3e6 100644 --- a/docs/lang/pl/README.md +++ b/docs/lang/pl/README.md @@ -50,7 +50,7 @@ Możesz połączyć się z naszym zespołem za pośrednictwem aplikacji, korzyst Odpowiadamy na pytania manualnie, więc nie jest to natychmiastowe - może to potrwać do 24 godzin. -Jeśli jesteś zainteresowany pomocą w integracji otwartoźródłowych modeli językowych i [dołączeniem do naszego zespołu](./docs/lang/pl/JOIN_TEAM.md), skontaktuj się z nami. +Jeśli jesteś zainteresowany pomocą w integracji otwartoźródłowych modeli językowych i [dołączeniem do naszego zespołu](../../JOIN_TEAM.md), skontaktuj się z nami. ## Dołącz do grup użytkowników @@ -62,7 +62,7 @@ Możesz również: - krytykować aplikację i dokonywać porównań z innymi komunikatorami. - udostępniać nowe komunikatory, które Twoim zdaniem mogą być interesujące z punktu widzenia prywatności, o ile nie spamujesz. - udostępniać niektóre publikacje związane z prywatnością, raczej dość rzadko. -- po wstępnym zatwierdzeniu przez administratora w prywatnej wiadomości, udostępnić link do utworzonej grupy, ale tylko raz. Gdy grupa ma więcej niż 10 członków, może zostać przesłana do [SimpleX Directory Service](./docs/DIRECTORY.md), gdzie nowi użytkownicy będą mogli ją odkryć. +- po wstępnym zatwierdzeniu przez administratora w prywatnej wiadomości, udostępnić link do utworzonej grupy, ale tylko raz. Gdy grupa ma więcej niż 10 członków, może zostać przesłana do [SimpleX Directory Service](../../DIRECTORY.md), gdzie nowi użytkownicy będą mogli ją odkryć. Musisz: - być uprzejmym wobec innych użytkowników. @@ -104,11 +104,11 @@ Kanał, za pośrednictwem którego udostępniasz link, nie musi być bezpieczny Wykonaj prywatne połączenie Conversation Połączenie wideo -Po wykonaniu połączenia możesz [zweryfikować kod bezpieczeństwa połączenia](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification). +Po wykonaniu połączenia możesz [zweryfikować kod bezpieczeństwa połączenia](../../../blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification). ## Poradnik dla użytkownika (NOWE) -Przeczytaj o funkcjach i ustawieniach aplikacji w nowym [Przewodniku użytkownika](./docs/guide/README.md). +Przeczytaj o funkcjach i ustawieniach aplikacji w nowym [Przewodniku użytkownika](../../guide/README.md). ## Pomóż nam przetłumaczyć SimpleX Chat @@ -139,13 +139,13 @@ Dołącz do naszych tłumaczy, aby pomóc SimpleX w rozwoju! |🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)|| |🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)

[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)
 |

[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)|| -Trwają prace nad wersjami językowymi: Arabski, japoński, koreański, portugalski i [inne](https://hosted.weblate.org/projects/simplex-chat/#languages). Będziemy dodawać kolejne języki, gdy niektóre z już dodanych zostaną ukończone - zasugeruj nowe języki, przejrzyj [przewodnik po tłumaczeniach](./docs/lang/pl/TRANSLATIONS.md) i skontaktuj się z nami! +Trwają prace nad wersjami językowymi: Arabski, japoński, koreański, portugalski i [inne](https://hosted.weblate.org/projects/simplex-chat/#languages). Będziemy dodawać kolejne języki, gdy niektóre z już dodanych zostaną ukończone - zasugeruj nowe języki, przejrzyj [przewodnik po tłumaczeniach](./TRANSLATIONS.md) i skontaktuj się z nami! ## Kontrybuuj Chcielibyśmy, abyś przyczynił się do naszego rozwoju! Możesz nam pomóc: -- [dzieląc się motywem kolorystycznym](./docs/THEMES.md), którego używasz w aplikacji na Androida! +- [dzieląc się motywem kolorystycznym](../../THEMES.md), którego używasz w aplikacji na Androida! - pisząc samouczki lub poradniki, które dotyczą hostowania serwerów, automatyzacji czatbotów itp. - współtworząc bazy wiedzy SimpleX Chat. - rozwijając funkcje - skontaktuj się z nami za pośrednictwem czatu, abyśmy mogli pomóc Ci zacząć. @@ -208,23 +208,23 @@ Używanie szyfrowanego komunikatora end-to-end nie jest wystarczające. Powinni ### Kompletna prywatność Twojej tożsamości, profilu, kontaktów i metadanych. -**W przeciwieństwie do innych komunikatorów, SimpleX nie posiada żadnych identyfikatorów przypisanych do użytkowników**. Nie posiada nawet numerów generowanych losowo. Zapewnia to prywatność tego, z kim się komunikujesz, ukrywając jego tożsamość oraz fakt komunikacji przed serwerami platformy SimpleX i wszelkimi obserwatorami [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). +**W przeciwieństwie do innych komunikatorów, SimpleX nie posiada żadnych identyfikatorów przypisanych do użytkowników**. Nie posiada nawet numerów generowanych losowo. Zapewnia to prywatność tego, z kim się komunikujesz, ukrywając jego tożsamość oraz fakt komunikacji przed serwerami platformy SimpleX i wszelkimi obserwatorami [Czytaj więcej](./SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). ### Najlepsza ochrona przed spamem i nadużyciami -Ponieważ na platformie SimpleX nie masz identyfikatora ani stałego adresu, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#the-best-protection-against-spam-and-abuse). +Ponieważ na platformie SimpleX nie masz identyfikatora ani stałego adresu, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku. [Czytaj więcej](./SIMPLEX.md#the-best-protection-against-spam-and-abuse). ### Pełna kontrola i bezpieczeństwo Twoich danych -SimpleX przechowuje wszystkie dane użytkownika na urządzeniach klienckich, wiadomości są przechowywane tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#complete-ownership-control-and-security-of-your-data). +SimpleX przechowuje wszystkie dane użytkownika na urządzeniach klienckich, wiadomości są przechowywane tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane. [Czytaj więcej](./SIMPLEX.md#complete-ownership-control-and-security-of-your-data). ### Użytkownicy są właścicielami sieci SimpleX -Możesz używać SimpleX na własnych serwerach i nadal komunikować się z ludźmi za pomocą serwerów, które są wstępnie skonfigurowane w aplikacjach lub z dowolnymi innymi serwerami SimpleX. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#users-own-simplex-network). +Możesz używać SimpleX na własnych serwerach i nadal komunikować się z ludźmi za pomocą serwerów, które są wstępnie skonfigurowane w aplikacjach lub z dowolnymi innymi serwerami SimpleX. [Czytaj więcej](./SIMPLEX.md#users-own-simplex-network). ## Często zadawane pytania -1. _W jaki sposób SimpleX może dostarczać wiadomości bez jakichkolwiek identyfikatorów użytkownika?_ Zobacz [ogłoszenie wydania v2](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) wyjaśniające jak SimpleX działa. +1. _W jaki sposób SimpleX może dostarczać wiadomości bez jakichkolwiek identyfikatorów użytkownika?_ Zobacz [ogłoszenie wydania v2](../../../blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) wyjaśniające jak SimpleX działa. 2. _Dlaczego po prostu nie mogę używać Signal?_ Signal to scentralizowana platforma, która wykorzystuje numery telefonów do identyfikacji użytkowników i ich kontaktów. Oznacza to, że podczas gdy treść wiadomości w Signal jest chroniona solidnym szyfrowaniem end-to-end, istnieje duża ilość metadanych widocznych dla Signal - to, z kim rozmawiasz i kiedy. @@ -234,29 +234,29 @@ Możesz używać SimpleX na własnych serwerach i nadal komunikować się z lud Najnowsze i ważne wiadomości: -[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) +[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](../../../blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) -[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) +[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) +[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). +[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). +[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). -[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). +[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](../../../blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). -[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md). +[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](../../../blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.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). +[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). +[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). +[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). +[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](../../../blog/20220928-simplex-chat-v4-encrypted-database.md). -[All updates](./blog) +[All updates](../../../blog) ## :zap: Szybka instalacja terminalowej wersji aplikacji @@ -268,13 +268,13 @@ Po pobraniu klienta czatu można go uruchomić za pomocą polecenia `simplex-cha ![simplex-chat](./images/connection.gif) -Przeczytaj więcej o [instalowaniu i używaniu terminalowej wersji czatu](./docs/lang/pl/CLI.md). +Przeczytaj więcej o [instalowaniu i używaniu terminalowej wersji czatu](./CLI.md). ## Budowa Platformy SimpleX SimpleX to sieć typu klient-serwer z unikatową topologią sieciową, która wykorzystuje redundantne, jednorazowe węzły przekazywania wiadomości do asynchronicznego przekazywania wiadomości za pośrednictwem jednokierunkowych (simpleksowych) kolejek wiadomości, zapewniając anonimowość odbiorcy i nadawcy. -W przeciwieństwie do sieci P2P, wszystkie wiadomości są przekazywane przez jeden lub kilka węzłów serwera, które nawet nie muszą być trwałe. Obecna implementacja [serwera SMP](https://github.com/simplex-chat/simplexmq#smp-server) wykorzystuje przechowywanie wiadomości w pamięci, utrzymując jedynie rejestr kolejki. SimpleX zapewnia lepszą ochronę metadanych niż projekty P2P, ponieważ żadne globalne identyfikatory uczestników nie są używane do dostarczania wiadomości i pozwala to uniknąć [różnych problemów związanych z sieciami P2P](./docs/lang/pl/SIMPLEX.md#comparison-with-p2p-messaging-protocols). +W przeciwieństwie do sieci P2P, wszystkie wiadomości są przekazywane przez jeden lub kilka węzłów serwera, które nawet nie muszą być trwałe. Obecna implementacja [serwera SMP](https://github.com/simplex-chat/simplexmq#smp-server) wykorzystuje przechowywanie wiadomości w pamięci, utrzymując jedynie rejestr kolejki. SimpleX zapewnia lepszą ochronę metadanych niż projekty P2P, ponieważ żadne globalne identyfikatory uczestników nie są używane do dostarczania wiadomości i pozwala to uniknąć [różnych problemów związanych z sieciami P2P](./SIMPLEX.md#comparison-with-p2p-messaging-protocols). W przeciwieństwie do sieci sfederowanych, węzły serwera **nie posiadają danych użytkowników**, **nie komunikują się ze sobą** i **nie przechowują wiadomości** po ich dostarczeniu do odbiorców. Nie ma możliwości na odkrycie pełnej listy serwerów działających w sieci SimpleX. Taka konstrukcja pozwala uniknąć problemu związanego z widocznością metadanych, z którym borykają się wszystkie sieci sfederowane i pozwala ona na lepszą ochronę przed atakami obejmującymi całą sieć. @@ -282,29 +282,29 @@ Informacje o użytkownikach, ich kontaktach i grupach znajdują się wyłącznie Przeczytaj [whitepaper SimpleX](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md) po więcej informacji o zadaniach platformy oraz by dowiedzieć się jak wygląda koncepcja techniczna modelu. -Zobacz [Protokół Czatu SimpleX](./docs/protocol/simplex-chat.md) by dowiedzieć się o formacie wiadomości wysyłanych między klientem czatu za pośrednictwem [Protokołu Wiadomości SimpleX](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md). +Zobacz [Protokół Czatu SimpleX](../../protocol/simplex-chat.md) by dowiedzieć się o formacie wiadomości wysyłanych między klientem czatu za pośrednictwem [Protokołu Wiadomości SimpleX](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md). ## Prywatność i bezpieczeństwo: szczegóły techniczne i ograniczenia Prace nad SimpleX Chat wciąż trwają - udostępniamy nowe ulepszenia, gdy tylko będą gotowe. To Ty musisz zdecydować, czy obecny stan jest wystarczająco dobry dla Twojego przypadku zastosowania. -Stworzyliśmy [słownik pojęć](./docs/GLOSSARY.md) używany do opisu systemów komunikacyjnych, aby pomóc zrozumieć niektóre z poniższych pojęć oraz aby pomóc Ci w porównaniu zalet i wad różnych systemów komunikacyjnych. +Stworzyliśmy [słownik pojęć](../../GLOSSARY.md) używany do opisu systemów komunikacyjnych, aby pomóc zrozumieć niektóre z poniższych pojęć oraz aby pomóc Ci w porównaniu zalet i wad różnych systemów komunikacyjnych. Co zostało już wprowadzone: -1. Zamiast identyfikatorów użytkownika używanych przez wszystkie inne platformy, nawet te najbardziej prywatne, SimpleX używa [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 adresy dla każdej jednokierunkowej kolejki wiadomości, z opcjonalnym trzecim adresem dla powiadomień push na iOS, 2 kolejki w każdym połączeniu między użytkownikami). Sprawia to, że trudniej jest w ten sposób obserwować przebieg połączeń sieciowych na poziomie aplikacji, ponieważ dla `n` użytkowników może istnieć do `n * (n-1)` kolejek wiadomości. -2. [Szyfrowanie end-to-end](./docs/GLOSSARY.md#end-to-end-encryption) w każdej kolejce wiadomości używając [cryptoboxa NaCl](https://nacl.cr.yp.to/box.html). Zostało to dodane, aby umożliwić redundancję w przyszłości (przekazywanie każdej wiadomości przez kilka serwerów), aby uniknąć posiadania tego samego ciphertext w różnych kolejkach (które byłyby widoczne tylko dla atakującego, w przypadku przejęcia TLS). Klucze szyfrujące używane do tego szyfrowania nie są rotowane, zamiast tego planujemy rotować kolejki. Do negocjacji kluczy używane są klucze Curve25519. -3. Szyfrowanie end-to-end [double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) w każdej rozmowie między dwoma użytkownikami (lub członkami grupy). Jest to ten sam algorytm, który jest używany w Signal i wielu innych komunikatorach; zapewnia on komunikację OTR z [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (każda wiadomość jest szyfrowana własnym kluczem efemerycznym) i [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (klucze są często renegocjowane w ramach wymiany wiadomości). Dwie pary kluczy Curve448 są używane do początkowego [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), strona inicjująca przekazuje te klucze przez link połączenia, a strona akceptująca - w nagłówku wiadomości potwierdzającej. +1. Zamiast identyfikatorów użytkownika używanych przez wszystkie inne platformy, nawet te najbardziej prywatne, SimpleX używa [pairwise per-queue identifiers](../../GLOSSARY.md#pairwise-pseudonymous-identifier) (2 adresy dla każdej jednokierunkowej kolejki wiadomości, z opcjonalnym trzecim adresem dla powiadomień push na iOS, 2 kolejki w każdym połączeniu między użytkownikami). Sprawia to, że trudniej jest w ten sposób obserwować przebieg połączeń sieciowych na poziomie aplikacji, ponieważ dla `n` użytkowników może istnieć do `n * (n-1)` kolejek wiadomości. +2. [Szyfrowanie end-to-end](../../GLOSSARY.md#end-to-end-encryption) w każdej kolejce wiadomości używając [cryptoboxa NaCl](https://nacl.cr.yp.to/box.html). Zostało to dodane, aby umożliwić redundancję w przyszłości (przekazywanie każdej wiadomości przez kilka serwerów), aby uniknąć posiadania tego samego ciphertext w różnych kolejkach (które byłyby widoczne tylko dla atakującego, w przypadku przejęcia TLS). Klucze szyfrujące używane do tego szyfrowania nie są rotowane, zamiast tego planujemy rotować kolejki. Do negocjacji kluczy używane są klucze Curve25519. +3. Szyfrowanie end-to-end [double ratchet](../../GLOSSARY.md#double-ratchet-algorithm) w każdej rozmowie między dwoma użytkownikami (lub członkami grupy). Jest to ten sam algorytm, który jest używany w Signal i wielu innych komunikatorach; zapewnia on komunikację OTR z [forward secrecy](../../GLOSSARY.md#forward-secrecy) (każda wiadomość jest szyfrowana własnym kluczem efemerycznym) i [break-in recovery](../../GLOSSARY.md#post-compromise-security) (klucze są często renegocjowane w ramach wymiany wiadomości). Dwie pary kluczy Curve448 są używane do początkowego [key agreement](../../GLOSSARY.md#key-agreement-protocol), strona inicjująca przekazuje te klucze przez link połączenia, a strona akceptująca - w nagłówku wiadomości potwierdzającej. 4. Dodatkowa warstwa szyfrowania przy użyciu NaCL cryptobox dla wiadomości dostarczanych z serwera do odbiorcy. Warstwa ta pozwala uniknąć wspólnego szyfrogramu między wysyłanym i odbieranym ruchem serwera wewnątrz TLS (i nie ma też wspólnych identyfikatorów). -5. Kilka poziomów [content padding](./docs/GLOSSARY.md#message-padding) w celu utrudnienia ataków na rozmiar wiadomości. +5. Kilka poziomów [content padding](../../GLOSSARY.md#message-padding) w celu utrudnienia ataków na rozmiar wiadomości. 6. Wszystkie metadane wiadomości, w tym czas odebrania wiadomości przez serwer (zaokrąglony do sekundy), są wysyłane do odbiorców w zaszyfrowanej postaci, więc nawet jeśli TLS zostanie przejęty, nie można ich zobaczyć. 7. Dozwolone są tylko TLS 1.2/1.3 dla połączeń klient-serwer, z ograniczeniem do algorytmów kryptograficznych: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 8. Aby zapobiec atakom typu replay, serwery SimpleX wymagają [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) jako identyfikatora sesji w każdym poleceniu klienta podpisanym kluczem efemerycznym dla każdej kolejki. -9. Aby ochronić swój adres IP, wszystkie klienty SimpleX Chat obsługują dostęp do serwerów komunikacyjnych za pośrednictwem Tora - zobacz [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) po więcej szczegółów. +9. Aby ochronić swój adres IP, wszystkie klienty SimpleX Chat obsługują dostęp do serwerów komunikacyjnych za pośrednictwem Tora - zobacz [v3.1 release announcement](../../../blog/20220808-simplex-chat-v3.1-chat-groups.md) po więcej szczegółów. 10. Lokalne szyfrowanie bazy danych z hasłem - kontakty, grupy oraz wszystkie wysłane i odebrane wiadomości są przechowywane w postaci zaszyfrowanej. Jeśli korzystałeś z SimpleX Chat przed wersją v4.0, musisz włączyć szyfrowanie w ustawieniach aplikacji. 11. Izolacja transportu - różne połączenia TCP i obwody Tor używane są dla ruchu różnych profili użytkowników, opcjonalnie - dla różnych kontaktów i połączeń członków grupy. 12. Ręczne obracanie kolejki wiadomości w celu przeniesienia konwersacji do innego przekaźnika SMP. -13. Wysyłanie zaszyfrowanych plików end-to-end przy użyciu [protokołu XFTP](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). +13. Wysyłanie zaszyfrowanych plików end-to-end przy użyciu [protokołu XFTP](../../../blog/20230301-simplex-file-transfer-protocol.md). 14. Szyfrowanie plików lokalnych. Planujemy dodać: @@ -322,7 +322,7 @@ Możesz: - korzystać z biblioteki SimpleX Chat w celu zintegrowania funkcji czatu z aplikacjami mobilnymi. - tworzyć boty i usługi czatu w języku Haskell - zobacz [prosty](./apps/simplex-bot/) i bardziej [zaawansowany przykład bota czatu](./apps/simplex-bot-advanced/). - tworzenie chat botów i usług w dowolnym języku z wykorzystaniem terminala CLI SimpleX Chat jako lokalnego serwera WebSocket. Zobacz [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) i [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). -- uruchomić [simplex-chat w terminal ](./docs/lang/pl/CLI.md), aby wykonywać poszczególne polecenia czatu, np. wysyłać wiadomości w ramach wykonywania skryptu powłoki. +- uruchomić [simplex-chat w terminal ](./CLI.md), aby wykonywać poszczególne polecenia czatu, np. wysyłać wiadomości w ramach wykonywania skryptu powłoki. Jeśli chcesz rozwijać platformę SimpleX, skontaktuj się z nami, aby uzyskać porady i wsparcie. @@ -365,7 +365,7 @@ Dołącz również do grupy [#simplex-devs](https://simplex.chat/contact#/?v=1-2 - ✅ Ulepszone połączenia audio i wideo. - ✅ Obsługa starszego systemu operacyjnego Android i 32-bitowych procesorów. - ✅ Ukryte profile czatu. -- ✅ Wysyłanie i odbieranie dużych plików przez [protokół XFTP](./blog/20230301-simplex-file-transfer-protocol.md). +- ✅ Wysyłanie i odbieranie dużych plików przez [protokół XFTP](../../../blog/20230301-simplex-file-transfer-protocol.md). - ✅ Wiadomości wideo. - ✅ Kod dostępu do aplikacji. - ✅ Ulepszenie interfejsu Androidowej aplikacji. @@ -402,7 +402,7 @@ Dołącz również do grupy [#simplex-devs](https://simplex.chat/contact#/?v=1-2 [Protokoły i model bezpieczeństwa SimpleX](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) zostały poddane przeglądowi i zawierały wiele istotnych zmian i ulepszeń w wersji v1.0.0. -Audyt bezpieczeństwa został przeprowadzony w październiku 2022 r. przez [Trail of Bits](https://www.trailofbits.com/about), a większość poprawek została wydana w wersji 4.2.0 - zobacz [ogłoszenie](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). +Audyt bezpieczeństwa został przeprowadzony w październiku 2022 r. przez [Trail of Bits](https://www.trailofbits.com/about), a większość poprawek została wydana w wersji 4.2.0 - zobacz [ogłoszenie](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). SimpleX Chat jest nadal na stosunkowo wczesnym etapie rozwoju (aplikacje mobilne zostały wydane w marcu 2022 r.), więc możesz odkryć pewne błędy i brakujące funkcje. Będziemy bardzo wdzięczni za poinformowanie nas o wszystkim, co wymaga naprawy lub ulepszenia. diff --git a/docs/lang/pl/SERVER.md b/docs/lang/pl/SERVER.md index 72cb51a4bf..f4bffc1bcc 100644 --- a/docs/lang/pl/SERVER.md +++ b/docs/lang/pl/SERVER.md @@ -13,7 +13,7 @@ Serwer SMP to serwer przekaźnikowy używany do przekazywania wiadomości w siec Klienty SimpleX określają tylko, który serwer jest używany do odbierania wiadomości, oddzielnie dla każdego kontaktu (lub połączenia grupowego z członkiem grupy), a serwery te są tylko tymczasowe, ponieważ adres dostawy może ulec zmianie. -_Uwaga_: gdy zmienisz serwery w ustawieniach aplikacji, wpłynie to tylko na to, który serwer będzie używany dla nowych kontaktów, istniejące kontakty nie zostaną automatycznie przeniesione na nowe serwery, ale możesz przenieść je ręcznie za pomocą przycisku ["Zmień adres odbiorczy"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) na stronie z informacjami kontaktu/członka - wkrótce zostanie to zautomatyzowane. +_Uwaga_: gdy zmienisz serwery w ustawieniach aplikacji, wpłynie to tylko na to, który serwer będzie używany dla nowych kontaktów, istniejące kontakty nie zostaną automatycznie przeniesione na nowe serwery, ale możesz przenieść je ręcznie za pomocą przycisku ["Zmień adres odbiorczy"](../../../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) na stronie z informacjami kontaktu/członka - wkrótce zostanie to zautomatyzowane. ## Instalacja diff --git a/docs/lang/pl/SIMPLEX.md b/docs/lang/pl/SIMPLEX.md index ff7106d84c..a1ceb8c5e4 100644 --- a/docs/lang/pl/SIMPLEX.md +++ b/docs/lang/pl/SIMPLEX.md @@ -11,7 +11,7 @@ revision: 07.02.2023 Istniejące komunikatory oraz protokoły borykają się ze wszystkimi lub kilkoma podanymi problemami: - Brak zachowania prywatności profilu i kontaktów użytkownika (zachowanie poufności metadanych). -- Brak ochrony (lub jedynie opcjonalna ochrona) przed atakami MITM przez dostawcę usług przy użyciu szyfrowania [end to end](1) +- Brak ochrony (lub jedynie opcjonalna ochrona) przed atakami MITM przez dostawcę usług przy użyciu szyfrowania [end to end][1] - Niechciane wiadomości (spam i nadużycia). - Brak własności danych i ich ochrony. - Dla nietechnicznych użytkowników używanie niescentralizowanych protokołów jest skomplikowane. diff --git a/package.yaml b/package.yaml index eba20290ef..64a6c15894 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.7.3.0 +version: 5.8.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 83e2abe3c7..06875457b0 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."4455b8bd0e243aa3bb4dc854037b2e64677963b0" = "11vmr7r9r611lcamf9ay34axw0yz402gif59bhpipjkn95bgjx0p"; + "https://github.com/simplex-chat/simplexmq.git"."1116aeeea1869e0de38e9faccea76b329b549804" = "07ynn7f70hfsdrirmhb9zd257bx90d29l5gjyhh50wd12gaqdm0w"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8f31af7ccc..525dc6295f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.7.3.0 +version: 5.8.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -143,6 +143,7 @@ library Simplex.Chat.Migrations.M20240402_item_forwarded Simplex.Chat.Migrations.M20240430_ui_theme Simplex.Chat.Migrations.M20240501_chat_deleted + Simplex.Chat.Migrations.M20240510_chat_items_via_proxy Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8b333e7a1d..acdfe116ff 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -46,6 +46,7 @@ import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) +import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -99,7 +100,7 @@ import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), Migrati import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Client (defaultNetworkConfig) +import Simplex.Messaging.Client (ProxyClientError (..), defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -112,6 +113,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Transport (TransportError (..)) import Simplex.Messaging.Transport.Client (defaultSocksProxy) import Simplex.Messaging.Util import Simplex.Messaging.Version @@ -698,10 +700,7 @@ processChatCommand' vr = \case (,) <$> getAChatItem db vr user chatRef itemId <*> liftIO (getChatItemVersions db itemId) let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions memberDeliveryStatuses <- case (cType, dir) of - (SCTGroup, SMDSnd) -> do - withStore' (`getGroupSndStatuses` itemId) >>= \case - [] -> pure Nothing - memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses + (SCTGroup, SMDSnd) -> L.nonEmpty <$> withStore' (`getGroupSndStatuses` itemId) _ -> pure Nothing forwardedFromChatItem <- getForwardedFromItem user ci pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} @@ -871,15 +870,15 @@ processChatCommand' vr = \case throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ throwChatError (CECommandError "too many reactions") - APIForwardChatItem (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemId -> withUser $ \user -> case toCType of + APIForwardChatItem (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemId itemTTL -> withUser $ \user -> case toCType of CTDirect -> do (cm, ciff) <- prepareForward user withContactLock "forwardChatItem, to contact" toChatId $ - sendContactContentMessage user toChatId False Nothing cm ciff + sendContactContentMessage user toChatId False itemTTL cm ciff CTGroup -> do (cm, ciff) <- prepareForward user withGroupLock "forwardChatItem, to group" toChatId $ - sendGroupContentMessage user toChatId False Nothing cm ciff + sendGroupContentMessage user toChatId False itemTTL cm ciff CTLocal -> do (cm, ciff) <- prepareForward user createNoteFolderContentItem user toChatId cm ciff @@ -1022,12 +1021,12 @@ processChatCommand' vr = \case liftIO $ updateNoteFolderUnreadChat db user nf unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat cRef@(ChatRef cType chatId) chatDeleteMode -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat cRef@(ChatRef cType chatId) cdm -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withContactLock "deleteChat direct" chatId . procCmd $ - case chatDeleteMode of + case cdm of CDMFull notify -> do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo @@ -1573,7 +1572,7 @@ processChatCommand' vr = \case CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand $ APIConnectContactViaAddress userId incognito contactId _ -> processChatCommand $ APIConnect userId incognito (Just cReqUri) - DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) (CDMFull True) + DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) cdm ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withStore' (\db -> getUserContacts db vr user) @@ -1627,17 +1626,17 @@ processChatCommand' vr = \case contactId <- withStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTDirect contactId) forwardedItemId + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTDirect contactId) forwardedItemId Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTGroup groupId) forwardedItemId + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTGroup groupId) forwardedItemId Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withStore (`getUserNoteFolderId` user) forwardedItemId <- withStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTLocal folderId) forwardedItemId + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTLocal folderId) forwardedItemId Nothing SendMessage (ChatName cType name) msg -> withUser $ \user -> do let mc = MCText msg case cType of @@ -2222,6 +2221,10 @@ processChatCommand' vr = \case stat (AgentStatsKey {host, clientTs, cmd, res}, count) = map B.unpack [host, clientTs, cmd, res, bshow count] ResetAgentStats -> lift (withAgent' resetAgentStats) >> ok_ + GetAgentMsgCounts -> lift $ do + counts <- map (first decodeLatin1) <$> withAgent' getMsgCounts + let allMsgs = foldl' (\(ts, ds) (_, (t, d)) -> (ts + t, ds + d)) (0, 0) counts + pure CRAgentMsgCounts {msgCounts = ("all", allMsgs) : sortOn (Down . snd) (filter (\(_, (_, d)) -> d /= 0) counts)} GetAgentSubs -> lift $ summary <$> withAgent' getAgentSubscriptions where summary SubscriptionsInfo {activeSubscriptions, pendingSubscriptions, removedSubscriptions} = @@ -3891,7 +3894,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage' agentConnId meta $ void $ saveDirectRcvMSG conn meta msgBody - SENT msgId -> + SENT msgId _proxy -> sentMsgDeliveryEvent conn msgId OK -> -- [async agent commands] continuation on receiving OK @@ -4024,10 +4027,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True - SENT msgId -> do + SENT msgId proxy -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId - updateDirectItemStatus ct conn msgId $ CISSndSent SSPComplete + ci_ <- withStore $ \db -> do + ci_ <- updateDirectItemStatus' db ct conn msgId (CISSndSent SSPComplete) + forM ci_ $ \ci -> liftIO $ setDirectSndChatItemViaProxy db user ct ci (isJust proxy) + forM_ ci_ $ \ci -> toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) SWITCH qd phase cStats -> do toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) when (phase `elem` [SPStarted, SPCompleted]) $ case qd of @@ -4062,13 +4068,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + MWARN msgId err -> + updateDirectItemStatus ct conn msgId (CISSndWarning $ agentSndError err) MERR msgId err -> do - updateDirectItemStatus ct conn msgId $ agentErrToItemStatus err + updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here - updateDirectItemsStatus ct conn (L.toList msgIds) $ agentErrToItemStatus err + updateDirectItemsStatus ct conn (L.toList msgIds) (CISSndError $ agentSndError err) toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) ERR err -> do toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) @@ -4406,10 +4414,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RCVD msgMeta msgRcpt -> withAckMessage' agentConnId msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt - SENT msgId -> do + SENT msgId proxy -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId - updateGroupItemStatus gInfo m conn msgId $ CISSndSent SSPComplete + updateGroupItemStatus gInfo m conn msgId (CISSndSent SSPComplete) (Just $ isJust proxy) SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) when (phase `elem` [SPStarted, SPCompleted]) $ case qd of @@ -4445,13 +4453,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = OK -> -- [async agent commands] continuation on receiving OK when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + MWARN msgId err -> + withStore' $ \db -> updateGroupItemErrorStatus db msgId (groupMemberId' m) (CISSndWarning $ agentSndError err) MERR msgId err -> do - withStore' $ \db -> updateGroupItemErrorStatus db msgId (groupMemberId' m) $ agentErrToItemStatus err + withStore' $ \db -> updateGroupItemErrorStatus db msgId (groupMemberId' m) (CISSndError $ agentSndError err) -- group errors are silenced to reduce load on UI event log -- toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err MERRS msgIds err -> do - let newStatus = agentErrToItemStatus err + let newStatus = CISSndError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () @@ -4512,7 +4522,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 toView $ CRSndFileStart user ci ft sendFileChunk user ft - SENT msgId -> do + SENT msgId _proxy -> do withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do @@ -4675,8 +4685,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case err of SMP _ SMP.AUTH -> do authErrCounter' <- withStore' $ \db -> incConnectionAuthErrCounter db user conn - when (authErrCounter' >= authErrDisableCount) $ do - toView $ CRConnectionDisabled connEntity + when (authErrCounter' >= authErrDisableCount) $ case connEntity of + RcvDirectMsgConnection ctConn (Just ct) -> do + toView $ CRContactDisabled user ct {activeConn = Just ctConn {authErrCounter = authErrCounter'}} + _ -> toView $ CRConnectionDisabled connEntity _ -> pure () -- TODO v5.7 / v6.0 - together with deprecating old group protocol establishing direct connections? @@ -4724,9 +4736,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sentMsgDeliveryEvent Connection {connId} msgId = withStore' $ \db -> updateSndMsgDeliveryStatus db connId msgId MDSSndSent - agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd - agentErrToItemStatus (SMP _ AUTH) = CISSndErrorAuth - agentErrToItemStatus err = CISSndError . T.unpack . safeDecodeUtf8 $ strEncode err + agentSndError :: AgentErrorType -> SndError + agentSndError = \case + SMP _ AUTH -> SndErrAuth + SMP _ QUOTA -> SndErrQuota + BROKER _ e -> brokerError SndErrRelay e + SMP proxySrv (SMP.PROXY (SMP.BROKER e)) -> brokerError (SndErrProxy proxySrv) e + AP.PROXY proxySrv _ (ProxyProtocolError (SMP.PROXY (SMP.BROKER e))) -> brokerError (SndErrProxyRelay proxySrv) e + e -> SndErrOther . safeDecodeUtf8 $ strEncode e + where + brokerError srvErr = \case + NETWORK -> SndErrExpired + TIMEOUT -> SndErrExpired + HOST -> srvErr SrvErrHost + SMP.TRANSPORT TEVersion -> srvErr SrvErrVersion + e -> srvErr . SrvErrOther . safeDecodeUtf8 $ strEncode e badRcvFileChunk :: RcvFileTransfer -> String -> CM () badRcvFileChunk ft err = @@ -6055,7 +6079,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus - updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete + updateGroupItemStatus gInfo m conn agentMsgId (CISSndRcvd msgRcptStatus SSPComplete) Nothing updateDirectItemsStatus :: Contact -> Connection -> [AgentMsgId] -> CIStatus 'MDSnd -> CM () updateDirectItemsStatus ct conn msgIds newStatus = do @@ -6092,11 +6116,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise -> updateGroupSndStatus db itemId groupMemberId newStatus $> True _ -> pure False - updateGroupItemStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> CM () - updateGroupItemStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus = + updateGroupItemStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> Maybe Bool -> CM () + updateGroupItemStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = withStore' (\db -> getGroupChatItemByAgentMsgId db user groupId connId msgId) >>= \case Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure () Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) -> do + forM_ viaProxy_ $ \viaProxy -> withStore' $ \db -> setGroupSndViaProxy db itemId groupMemberId viaProxy memStatusChanged <- updateGroupMemSndStatus itemId groupMemberId newMemStatus when memStatusChanged $ do memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId) @@ -6719,7 +6744,7 @@ mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live itemTs forwardedByMember currentTs = let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus Nothing sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs in ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: MsgDirectionI d => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> CM ChatResponse @@ -7118,13 +7143,12 @@ chatCommandP = "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), - "/_forward " *> (APIForwardChatItem <$> chatRefP <* A.space <*> chatRefP <* A.space <*> A.decimal), + "/_forward " *> (APIForwardChatItem <$> chatRefP <* A.space <*> chatRefP <* A.space <*> A.decimal <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), - "/_delete " *> (APIDeleteChat <$> chatRefP <* A.space <*> jsonP), - "/_delete " *> (APIDeleteChat <$> chatRefP <*> (CDMFull <$> (A.space *> "notify=" *> onOffP <|> pure True))), + "/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode), "/_clear chat " *> (APIClearChat <$> chatRefP), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), @@ -7230,7 +7254,7 @@ chatCommandP = ("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMember <$> displayName <* A.space <* char_ '@' <*> displayName), ("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayName), ("/delete #" <|> "/d #") *> (DeleteGroup <$> displayName), - ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName), + ("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayName <*> chatDeleteMode), "/clear *" $> ClearNoteFolder, "/clear #" *> (ClearGroup <$> displayName), "/clear " *> char_ '@' *> (ClearContact <$> displayName), @@ -7361,6 +7385,7 @@ chatCommandP = "/get subs details" $> GetAgentSubsDetails, "/get workers" $> GetAgentWorkers, "/get workers details" $> GetAgentWorkersDetails, + "/get msgs" $> GetAgentMsgCounts, "//" *> (CustomChatCommand <$> A.takeByteString) ] where @@ -7381,6 +7406,15 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal + chatDeleteMode = + A.choice + [ " full" *> (CDMFull <$> notifyP), + " entity" *> (CDMEntity <$> notifyP), + " messages" $> CDMMessages, + CDMFull <$> notifyP -- backwards compatible + ] + where + notifyP = " notify=" *> onOffP <|> pure True displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) where takeNameTill p = diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 3d63cb2109..366f6236f5 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -83,7 +83,7 @@ defaultAppSettings = uiDarkColorScheme = Just DCSSimplex, uiCurrentThemeIds = Nothing, uiThemes = Nothing, - oneHandUI = Just True + oneHandUI = Just False } defaultParseAppSettings :: AppSettings diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 5f71657ed8..267298f188 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -291,7 +291,7 @@ data ChatCommand | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} - | APIForwardChatItem {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemId :: ChatItemId} + | APIForwardChatItem {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemId :: ChatItemId, ttl :: Maybe Int} | APIUserRead UserId | UserRead | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) @@ -395,7 +395,7 @@ data ChatCommand | Connect IncognitoEnabled (Maybe AConnectionRequestUri) | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) - | DeleteContact ContactName + | DeleteContact ContactName ChatDeleteMode | ClearContact ContactName | APIListContacts UserId | ListContacts @@ -501,6 +501,7 @@ data ChatCommand | GetAgentSubsDetails | GetAgentWorkers | GetAgentWorkersDetails + | GetAgentMsgCounts | -- The parser will return this command for strings that start from "//". -- This command should be processed in preCmdHook CustomChatCommand ByteString @@ -746,6 +747,8 @@ data ChatResponse | CRAgentWorkersSummary {agentWorkersSummary :: AgentWorkersSummary} | CRAgentSubs {activeSubs :: Map Text Int, pendingSubs :: Map Text Int, removedSubs :: Map Text [String]} | CRAgentSubsDetails {agentSubs :: SubscriptionsInfo} + | CRAgentMsgCounts {msgCounts :: [(Text, (Int, Int))]} + | CRContactDisabled {user :: User, contact :: Contact} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} | CRAgentConnDeleted {agentConnId :: AgentConnId} @@ -825,9 +828,9 @@ clqNoFilters :: ChatListQuery clqNoFilters = CLQFilters {favorite = False, unread = False} data ChatDeleteMode - = CDMFull {notify :: Bool} -- delete both contact and conversation + = CDMFull {notify :: Bool} -- delete both contact and conversation | CDMEntity {notify :: Bool} -- delete contact (connection), keep conversation - | CDMMessages -- delete conversation, keep contact - can be re-opened from Contacts view + | CDMMessages -- delete conversation, keep contact - can be re-opened from Contacts view deriving (Show) data ConnectionPlan @@ -1398,8 +1401,6 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CLQ") ''ChatListQuery) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CDM") ''ChatDeleteMode) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 9ca191d3f9..83417efa59 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -29,6 +29,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -345,6 +346,7 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemTs :: ChatItemTs, itemText :: Text, itemStatus :: CIStatus d, + sentViaProxy :: Maybe Bool, itemSharedMsgId :: Maybe SharedMsgId, itemForwarded :: Maybe CIForwardedFrom, itemDeleted :: Maybe (CIDeleted c), @@ -359,8 +361,8 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta } deriving (Show) -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe Bool -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus sentViaProxy itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = let deletable = case itemContent of CISndMsgContent _ -> case chatTypeI @c of @@ -368,7 +370,7 @@ mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemForwarded it _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False editable = deletable && isNothing itemForwarded - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} + in CIMeta {itemId, itemTs, itemText, itemStatus, sentViaProxy, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} dummyMeta :: ChatItemId -> UTCTime -> Text -> CIMeta c 'MDSnd dummyMeta itemId ts itemText = @@ -377,6 +379,7 @@ dummyMeta itemId ts itemText = itemTs = ts, itemText, itemStatus = CISSndNew, + sentViaProxy = Nothing, itemSharedMsgId = Nothing, itemForwarded = Nothing, itemDeleted = Nothing, @@ -683,8 +686,9 @@ data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: SndCIStatusProgress -> CIStatus 'MDSnd CISSndRcvd :: MsgReceiptStatus -> SndCIStatusProgress -> CIStatus 'MDSnd - CISSndErrorAuth :: CIStatus 'MDSnd - CISSndError :: String -> CIStatus 'MDSnd + CISSndErrorAuth :: CIStatus 'MDSnd -- deprecated + CISSndError :: SndError -> CIStatus 'MDSnd + CISSndWarning :: SndError -> CIStatus 'MDSnd CISRcvNew :: CIStatus 'MDRcv CISRcvRead :: CIStatus 'MDRcv CISInvalid :: Text -> CIStatus 'MDSnd @@ -703,7 +707,8 @@ instance MsgDirectionI d => StrEncoding (CIStatus d) where CISSndSent sndProgress -> "snd_sent " <> strEncode sndProgress CISSndRcvd msgRcptStatus sndProgress -> "snd_rcvd " <> strEncode msgRcptStatus <> " " <> strEncode sndProgress CISSndErrorAuth -> "snd_error_auth" - CISSndError e -> "snd_error " <> encodeUtf8 (T.pack e) + CISSndError sndErr -> "snd_error " <> strEncode sndErr + CISSndWarning sndErr -> "snd_warning " <> strEncode sndErr CISRcvNew -> "rcv_new" CISRcvRead -> "rcv_read" CISInvalid {} -> "invalid" @@ -721,17 +726,68 @@ instance StrEncoding ACIStatus where "snd_sent" -> ACIStatus SMDSnd . CISSndSent <$> ((A.space *> strP) <|> pure SSPComplete) "snd_rcvd" -> ACIStatus SMDSnd <$> (CISSndRcvd <$> (A.space *> strP) <*> ((A.space *> strP) <|> pure SSPComplete)) "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth - "snd_error" -> ACIStatus SMDSnd . CISSndError . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString) + "snd_error" -> ACIStatus SMDSnd . CISSndError <$> (A.space *> strP) + "snd_warning" -> ACIStatus SMDSnd . CISSndWarning <$> (A.space *> strP) "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew "rcv_read" -> pure $ ACIStatus SMDRcv CISRcvRead _ -> fail "bad status" +-- see serverHostError in agent +data SndError + = SndErrAuth + | SndErrQuota + | SndErrExpired -- TIMEOUT/NETWORK errors + | SndErrRelay {srvError :: SrvError} -- BROKER errors (other than TIMEOUT/NETWORK) + | SndErrProxy {proxyServer :: String, srvError :: SrvError} -- SMP PROXY errors + | SndErrProxyRelay {proxyServer :: String, srvError :: SrvError} -- PROXY BROKER errors + | SndErrOther {sndError :: Text} -- other errors + deriving (Eq, Show) + +data SrvError + = SrvErrHost + | SrvErrVersion + | SrvErrOther {srvError :: Text} + deriving (Eq, Show) + +instance StrEncoding SndError where + strEncode = \case + SndErrAuth -> "auth" + SndErrQuota -> "quota" + SndErrExpired -> "expired" + SndErrRelay srvErr -> "relay " <> strEncode srvErr + SndErrProxy proxy srvErr -> "proxy " <> encodeUtf8 (T.pack proxy) <> " " <> strEncode srvErr + SndErrProxyRelay proxy srvErr -> "proxy_relay " <> encodeUtf8 (T.pack proxy) <> " " <> strEncode srvErr + SndErrOther e -> "other " <> encodeUtf8 e + strP = + A.takeWhile1 (/= ' ') >>= \case + "auth" -> pure SndErrAuth + "quota" -> pure SndErrQuota + "expired" -> pure SndErrExpired + "relay" -> SndErrRelay <$> (A.space *> strP) + "proxy" -> SndErrProxy . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeWhile1 (/= ' ') <* A.space) <*> strP + "proxy_relay" -> SndErrProxyRelay . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeWhile1 (/= ' ') <* A.space) <*> strP + "other" -> SndErrOther . safeDecodeUtf8 <$> (A.space *> A.takeByteString) + s -> SndErrOther . safeDecodeUtf8 . (s <>) <$> A.takeByteString -- for backward compatibility with `CISSndError String` + +instance StrEncoding SrvError where + strEncode = \case + SrvErrHost -> "host" + SrvErrVersion -> "version" + SrvErrOther e -> "other " <> encodeUtf8 e + strP = + A.takeWhile1 (/= ' ') >>= \case + "host" -> pure SrvErrHost + "version" -> pure SrvErrVersion + "other" -> SrvErrOther . safeDecodeUtf8 <$> (A.space *> A.takeByteString) + _ -> fail "bad SrvError" + data JSONCIStatus = JCISSndNew | JCISSndSent {sndProgress :: SndCIStatusProgress} | JCISSndRcvd {msgRcptStatus :: MsgReceiptStatus, sndProgress :: SndCIStatusProgress} - | JCISSndErrorAuth - | JCISSndError {agentError :: String} + | JCISSndErrorAuth -- deprecated + | JCISSndError {agentError :: SndError} + | JCISSndWarning {agentError :: SndError} | JCISRcvNew | JCISRcvRead | JCISInvalid {text :: Text} @@ -743,7 +799,8 @@ jsonCIStatus = \case CISSndSent sndProgress -> JCISSndSent sndProgress CISSndRcvd msgRcptStatus sndProgress -> JCISSndRcvd msgRcptStatus sndProgress CISSndErrorAuth -> JCISSndErrorAuth - CISSndError e -> JCISSndError e + CISSndError sndErr -> JCISSndError sndErr + CISSndWarning sndErr -> JCISSndWarning sndErr CISRcvNew -> JCISRcvNew CISRcvRead -> JCISRcvRead CISInvalid text -> JCISInvalid text @@ -754,7 +811,8 @@ jsonACIStatus = \case JCISSndSent sndProgress -> ACIStatus SMDSnd $ CISSndSent sndProgress JCISSndRcvd msgRcptStatus sndProgress -> ACIStatus SMDSnd $ CISSndRcvd msgRcptStatus sndProgress JCISSndErrorAuth -> ACIStatus SMDSnd CISSndErrorAuth - JCISSndError e -> ACIStatus SMDSnd $ CISSndError e + JCISSndError sndErr -> ACIStatus SMDSnd $ CISSndError sndErr + JCISSndWarning sndErr -> ACIStatus SMDSnd $ CISSndWarning sndErr JCISRcvNew -> ACIStatus SMDRcv CISRcvNew JCISRcvRead -> ACIStatus SMDRcv CISRcvRead JCISInvalid text -> ACIStatus SMDSnd $ CISInvalid text @@ -1041,7 +1099,7 @@ instance TextEncoding CIForwardedFromTag where data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], - memberDeliveryStatuses :: Maybe [MemberDeliveryStatus], + memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), forwardedFromChatItem :: Maybe AChatItem } deriving (Show) @@ -1070,7 +1128,8 @@ mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content data MemberDeliveryStatus = MemberDeliveryStatus { groupMemberId :: GroupMemberId, - memberDeliveryStatus :: CIStatus 'MDSnd + memberDeliveryStatus :: CIStatus 'MDSnd, + sentViaProxy :: Maybe Bool } deriving (Eq, Show) @@ -1108,6 +1167,10 @@ $(JQ.deriveJSON defaultJSON ''CITimed) $(JQ.deriveJSON (enumJSON $ dropPrefix "SSP") ''SndCIStatusProgress) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SrvErr") ''SrvError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SndErr") ''SndError) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "JCIS") ''JSONCIStatus) instance MsgDirectionI d => FromJSON (CIStatus d) where diff --git a/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs b/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs new file mode 100644 index 0000000000..3c32034344 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240510_chat_items_via_proxy.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240510_chat_items_via_proxy where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240510_chat_items_via_proxy :: Query +m20240510_chat_items_via_proxy = + [sql| +ALTER TABLE chat_items ADD COLUMN via_proxy INTEGER; +ALTER TABLE group_snd_item_statuses ADD COLUMN via_proxy INTEGER; +|] + +down_m20240510_chat_items_via_proxy :: Query +down_m20240510_chat_items_via_proxy = + [sql| +ALTER TABLE chat_items DROP COLUMN via_proxy; +ALTER TABLE group_snd_item_statuses DROP COLUMN via_proxy; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index e700acd4d4..3ac9b9a98e 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -392,7 +392,8 @@ CREATE TABLE chat_items( fwd_from_msg_dir INTEGER, fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, - fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL + fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL, + via_proxy INTEGER ); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, @@ -503,6 +504,8 @@ CREATE TABLE group_snd_item_statuses( group_snd_item_status TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + via_proxy INTEGER ); CREATE TABLE IF NOT EXISTS "sent_probes"( sent_probe_id INTEGER PRIMARY KEY, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 35e515cc2a..b0a0495c16 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -95,6 +95,7 @@ module Simplex.Chat.Store.Messages lookupChatItemByFileId, getChatItemByGroupId, updateDirectChatItemStatus, + setDirectSndChatItemViaProxy, getTimedItems, getChatItemTTL, setChatItemTTL, @@ -108,6 +109,7 @@ module Simplex.Chat.Store.Messages createGroupSndStatus, getGroupSndStatus, updateGroupSndStatus, + setGroupSndViaProxy, getGroupSndStatuses, getGroupSndStatusCounts, getGroupHistoryItems, @@ -806,7 +808,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -839,7 +841,7 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex _ -> Just (CIDeleted @CTLocal deletedTs) itemEdited' = fromMaybe False itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1407,7 +1409,7 @@ type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) type ChatItemRow = - (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) + (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe Bool, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemForwardedFromRow :. ChatItemModeRow @@ -1426,7 +1428,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1459,7 +1461,7 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT _ -> Just (CIDeleted @CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1483,7 +1485,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1521,7 +1523,7 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, _ -> Just (maybe (CIDeleted @CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited itemForwarded = toCIForwardedFrom forwardedFromRow - in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt + in mkCIMeta itemId content itemText status sentViaProxy sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1600,6 +1602,11 @@ updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId i liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) pure ci {meta = (meta ci) {itemStatus}} +setDirectSndChatItemViaProxy :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect 'MDSnd -> Bool -> IO (ChatItem 'CTDirect 'MDSnd) +setDirectSndChatItemViaProxy db User {userId} Contact {contactId} ci viaProxy = do + DB.execute db "UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (viaProxy, userId, contactId, chatItemId' ci) + pure ci {meta = (meta ci) {sentViaProxy = Just viaProxy}} + updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Bool -> Maybe CITimed -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) updateDirectChatItem db user ct@Contact {contactId} itemId newContent edited live timed_ msgId_ = do ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId @@ -1758,7 +1765,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, i.timed_ttl, i.timed_delete_at, i.item_live, @@ -2001,7 +2008,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, i.timed_ttl, i.timed_delete_at, i.item_live, @@ -2105,7 +2112,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, i.timed_ttl, i.timed_delete_at, i.item_live, @@ -2538,16 +2545,31 @@ updateGroupSndStatus db itemId memberId status = do |] (status, currentTs, itemId, memberId) -getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [(GroupMemberId, CIStatus 'MDSnd)] -getGroupSndStatuses db itemId = - DB.query +setGroupSndViaProxy :: DB.Connection -> ChatItemId -> GroupMemberId -> Bool -> IO () +setGroupSndViaProxy db itemId memberId viaProxy = + DB.execute db [sql| - SELECT group_member_id, group_snd_item_status - FROM group_snd_item_statuses - WHERE chat_item_id = ? + UPDATE group_snd_item_statuses + SET via_proxy = ? + WHERE chat_item_id = ? AND group_member_id = ? |] - (Only itemId) + (viaProxy, itemId, memberId) + +getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [MemberDeliveryStatus] +getGroupSndStatuses db itemId = + map memStatus + <$> DB.query + db + [sql| + SELECT group_member_id, group_snd_item_status, via_proxy + FROM group_snd_item_statuses + WHERE chat_item_id = ? + |] + (Only itemId) + where + memStatus (groupMemberId, memberDeliveryStatus, sentViaProxy) = + MemberDeliveryStatus {groupMemberId, memberDeliveryStatus, sentViaProxy} getGroupSndStatusCounts :: DB.Connection -> ChatItemId -> IO [(CIStatus 'MDSnd, Int)] getGroupSndStatusCounts db itemId = diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index e25f255bd8..ccc69d100e 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -107,6 +107,7 @@ import Simplex.Chat.Migrations.M20240324_custom_data import Simplex.Chat.Migrations.M20240402_item_forwarded import Simplex.Chat.Migrations.M20240430_ui_theme import Simplex.Chat.Migrations.M20240501_chat_deleted +import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -213,7 +214,8 @@ schemaMigrations = ("20240324_custom_data", m20240324_custom_data, Just down_m20240324_custom_data), ("20240402_item_forwarded", m20240402_item_forwarded, Just down_m20240402_item_forwarded), ("20240430_ui_theme", m20240430_ui_theme, Just down_m20240430_ui_theme), - ("20240501_chat_deleted", m20240501_chat_deleted, Just down_m20240501_chat_deleted) + ("20240501_chat_deleted", m20240501_chat_deleted, Just down_m20240501_chat_deleted), + ("20240510_chat_items_via_proxy", m20240510_chat_items_via_proxy, Just down_m20240510_chat_items_via_proxy) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ba3e468b1a..4b3240fe46 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -375,6 +375,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe [ "agent workers details:", plain . LB.unpack $ J.encode agentWorkersDetails -- this would be huge, but copypastable when has its own line ] + CRAgentMsgCounts {msgCounts} -> ["received messages (total, duplicates):", plain . LB.unpack $ J.encode msgCounts] + CRContactDisabled u c -> ttyUser u ["[" <> ttyContact' c <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)] CRConnectionDisabled entity -> viewConnectionEntityDisabled entity CRAgentRcvQueueDeleted acId srv aqId err_ -> [ ("completed deleting rcv queue, agent connection id: " <> sShow acId) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 4897a3b3d3..2bcd52ab3f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -37,6 +37,7 @@ import Simplex.Chat.Types import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) +import Simplex.FileTransfer.Transport (supportedFileServerVRange) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) @@ -44,6 +45,7 @@ import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), closeSQLiteStore) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) +import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Server (runSMPServerBlocking) @@ -427,7 +429,9 @@ serverCfg = smpServerVRange = supportedServerSMPRelayVRange, transportConfig = defaultTransportServerConfig, smpHandshakeTimeout = 1000000, - controlPort = Nothing + controlPort = Nothing, + smpAgentCfg = defaultSMPClientAgentConfig, + allowSMPProxy = False } withSmpServer :: IO () -> IO () @@ -458,6 +462,7 @@ xftpServerConfig = caCertificateFile = "tests/fixtures/tls/ca.crt", privateKeyFile = "tests/fixtures/tls/server.key", certificateFile = "tests/fixtures/tls/server.crt", + xftpServerVRange = supportedFileServerVRange, logStatsInterval = Nothing, logStatsStartTime = 0, serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log", diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 16082cb30b..2e549d9dd1 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -298,6 +298,7 @@ testPlanInvitationLinkOwn tmp = alice ##> ("/_connect plan 1 " <> inv) alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + threadDelay 100000 alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] alice `send` "@alice_2 hi" alice @@ -346,7 +347,7 @@ testDeleteContactDeletesProfile = connectUsers alice bob alice <##> bob -- alice deletes contact, profile is deleted - alice ##> "/_delete @2 {\"type\": \"full\", \"notify\": true}" + alice ##> "/_delete @2 full notify=on" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" alice ##> "/_contacts 1" @@ -366,7 +367,7 @@ testDeleteContactKeepConversation = connectUsers alice bob alice <##> bob - alice ##> "/_delete @2 {\"type\": \"entity\", \"notify\": true}" + alice ##> "/_delete @2 entity notify=on" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" @@ -386,7 +387,7 @@ testDeleteConversationKeepContact = alice @@@ [("@bob", "hey")] - alice ##> "/_delete @2 {\"type\": \"messages\", \"notify\": true}" + alice ##> "/_delete @2 messages" alice <## "bob: contact is deleted" alice @@@ [("@bob", "")] -- UI would filter @@ -1068,20 +1069,25 @@ testNegotiateCall = -- alice confirms call by sending WebRTC answer alice ##> ("/_call answer @2 " <> serialize testWebRTCSession) alice <## "ok" + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: connecting...")]) bob <## "alice continued the WebRTC call" repeatM_ 3 $ getTermLine bob + threadDelay 100000 bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: connecting...")]) -- participants can update calls as connected alice ##> "/_call status @2 connected" alice <## "ok" + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: in progress (00:00)")]) bob ##> "/_call status @2 connected" bob <## "ok" + threadDelay 100000 bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: in progress (00:00)")]) -- either party can end the call bob ##> "/_call end @2" bob <## "ok" + threadDelay 100000 bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "incoming call: ended (00:00)")]) alice <## "call with bob ended" alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")]) @@ -2290,6 +2296,7 @@ testSwitchContact = alice <## "bob: you started changing address" bob <## "alice changed address for you" alice <## "bob: you changed address" + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "started changing address..."), (1, "you changed address")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob @@ -2317,6 +2324,7 @@ testAbortSwitchContact tmp = do bob <## "alice started changing address for you" bob <## "alice changed address for you" alice <## "bob: you changed address" + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "started changing address..."), (1, "started changing address..."), (1, "you changed address")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice <##> bob @@ -2331,6 +2339,7 @@ testSwitchGroupMember = alice <## "#team: you started changing address for bob" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" + threadDelay 100000 alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" @@ -2362,6 +2371,7 @@ testAbortSwitchGroupMember tmp = do bob <## "#team: alice started changing address for you" bob <## "#team: alice changed address for you" alice <## "#team: you changed address for bob" + threadDelay 100000 alice #$> ("/_get chat #1 count=100", chat, [(1, e2eeInfoNoPQStr), (0, "connected"), (1, "started changing address for bob..."), (1, "started changing address for bob..."), (1, "you changed address for bob")]) bob #$> ("/_get chat #1 count=100", chat, groupFeatures <> [(0, "connected"), (0, "started changing address for you..."), (0, "started changing address for you..."), (0, "changed address for you")]) alice #> "#team hey" @@ -2511,6 +2521,7 @@ testSyncRatchet tmp = alice <## "bob: connection synchronized" bob <## "alice: connection synchronized" + threadDelay 100000 bob #$> ("/_get chat @2 count=3", chat, [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) alice #$> ("/_get chat @2 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) @@ -2550,6 +2561,7 @@ testSyncRatchetCodeReset tmp = alice <## "bob: connection synchronized" bob <## "alice: connection synchronized" + threadDelay 100000 bob #$> ("/_get chat @2 count=4", chat, [(1, "connection synchronization started"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) alice #$> ("/_get chat @2 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 46fedd1b48..3948168dae 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -69,7 +69,7 @@ chatGroupTests = do it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown it "own group link" testPlanGroupLinkOwn it "connecting via group link" testPlanGroupLinkConnecting - xit "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin + it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin describe "group links without contact" $ do it "join via group link without creating contact" testGroupLinkNoContact it "invitees were previously connected as contacts" testGroupLinkNoContactInviteesWereConnected @@ -2578,13 +2578,15 @@ testPlanGroupLinkOwn tmp = testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () testPlanGroupLinkConnecting tmp = do - gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + -- gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" alice ##> "/create link #team" getGroupLink alice "team" GRMember True - withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + -- withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do threadDelay 100000 bob ##> ("/c " <> gLink) @@ -2598,13 +2600,15 @@ testPlanGroupLinkConnecting tmp = do bob <## "group link: connecting, allowed to reconnect" threadDelay 100000 - withTestChatCfg tmp cfg "alice" $ \alice -> do + -- withTestChatCfg tmp cfg "alice" $ \alice -> do + withTestChatCfg tmp cfg "alice" $ \a -> withTestOutput a $ \alice -> do alice <### [ "1 group links active", "#team: group is empty", "bob (Bob): accepting request to join group #team..." ] - withTestChatCfg tmp cfg "bob" $ \bob -> do + -- withTestChatCfg tmp cfg "bob" $ \bob -> do + withTestChatCfg tmp cfg "bob" $ \b -> withTestOutput b $ \bob -> do threadDelay 500000 bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: connecting" @@ -2621,9 +2625,8 @@ testPlanGroupLinkConnecting tmp = do testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () testPlanGroupLinkLeaveRejoin = testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ - \a b -> do - let alice = a {printOutput = True} - bob = b {printOutput = True} + -- \alice bob -> do + \a b -> withTestOutput a $ \alice -> withTestOutput b $ \bob -> do alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" @@ -2643,6 +2646,8 @@ testPlanGroupLinkLeaveRejoin = bob <## "#team: you joined the group" ] + threadDelay 100000 + bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: known group #team" bob <## "use #team to send messages" @@ -2651,6 +2656,8 @@ testPlanGroupLinkLeaveRejoin = bob <## "group link: known group #team" bob <## "use #team to send messages" + threadDelay 100000 + bob ##> "/leave #team" concurrentlyN_ [ do @@ -2659,6 +2666,8 @@ testPlanGroupLinkLeaveRejoin = alice <## "#team: bob left the group" ] + threadDelay 100000 + bob ##> ("/_connect plan 1 " <> gLink) bob <## "group link: ok to connect" @@ -3296,6 +3305,7 @@ testGroupSyncRatchet tmp = alice <## "#team bob: connection synchronized" bob <## "#team alice: connection synchronized" + threadDelay 100000 bob #$> ("/_get chat #1 count=3", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) @@ -3336,6 +3346,7 @@ testGroupSyncRatchetCodeReset tmp = alice <## "#team bob: connection synchronized" bob <## "#team alice: connection synchronized" + threadDelay 100000 bob #$> ("/_get chat #1 count=4", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index a8afa05af3..3d92a73313 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -397,6 +397,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice" bob <## "use @alice to send messages" + threadDelay 100000 alice @@@ [("@bob", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -470,6 +471,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob ##> ("/c " <> cLink) bob <## "contact address: known contact alice" bob <## "use @alice to send messages" + threadDelay 100000 alice @@@ [("@robert", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":3", ""), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -488,6 +490,7 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob <## "use @alice to send messages" alice <##> bob + threadDelay 100000 alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "hi"), (1, "hey"), (0, "hi"), (1, "hey")]) @@ -655,6 +658,7 @@ testPlanAddressOwn tmp = "alice_2 (Alice): contact is connected" ] + threadDelay 100000 alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] alice `send` "@alice_2 hi" alice @@ -1948,21 +1952,29 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP bob <## "#team: cath added dan (Daniel) to the group (connecting...)" bob <## "#team: new member dan is connected" ] - -- dan cannot send direct messages to alice (owner) + + -- dan cannot send direct messages to alice dan ##> "@alice hello alice" dan <## "bad chat command: direct messages not allowed" (alice hello dan" - dan <## "alice (Alice): contact is connected" - -- and now dan can too + alice + <### [ "member #team dan does not have direct connection, creating", + "contact for member #team dan is created", + "sent invitation to connect directly to member #team dan", + WithTime "@dan hello dan" + ] + dan + <### [ "#team alice is creating direct contact alice with you", + WithTime "alice> hello dan" + ] + concurrently_ + (alice <## "dan (Daniel): contact is connected") + (dan <## "alice (Alice): contact is connected") + + -- now dan can send messages to alice dan #> "@alice hi alice" alice <# "dan> hi alice" where diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index a3ee0d5ca8..8c022d5bfd 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -298,7 +298,7 @@ itemId i = show $ length chatFeatures + i (@@@) :: HasCallStack => TestCC -> [(String, String)] -> Expectation (@@@) cc res = do - threadDelay 10000 + threadDelay 100000 getChats mapChats cc res mapChats :: [(String, String, Maybe ConnStatus)] -> [(String, String)] diff --git a/website/.eleventy.js b/website/.eleventy.js index 64aedbc04c..a0a35f3366 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -6,6 +6,7 @@ const uri = require('fast-uri') const i18n = require('eleventy-plugin-i18n') const fs = require("fs") const path = require("path") +const matter = require('gray-matter') const pluginRss = require('@11ty/eleventy-plugin-rss') const { JSDOM } = require('jsdom') @@ -388,16 +389,36 @@ module.exports = function (ty) { linkify: true, replaceLink: function (link, _env) { let parsed = uri.parse(link) - if (parsed.scheme || parsed.host || !parsed.path.endsWith(".md")) { - return link + if (parsed.scheme || parsed.host) return link + + let hostFile = path.resolve(_env.page.inputPath) + let linkFile = path.resolve(hostFile, '..', parsed.path) + if (parsed.path.startsWith('/')) { + let srcIndex = hostFile.indexOf("/src") + if (srcIndex !== -1) { + linkFile = path.join(hostFile.slice(0, srcIndex + 4), parsed.path) + } } - if (parsed.path.startsWith("../../blog")) { - parsed.path = parsed.path.replace("../../blog", "/blog") + + if (fs.existsSync(linkFile) && fs.statSync(linkFile).isFile()) { + // this condition works if the link is a valid website file + const fileContent = fs.readFileSync(linkFile, 'utf8') + parsed.path = (matter(fileContent).data?.permalink || parsed.path).replace(/\.md$/, ".html").toLowerCase() + } else if (!fs.existsSync(linkFile)) { + linkFile = linkFile.replace('/website/src', '') + if (fs.existsSync(linkFile)) { + // this condition works if the link is a valid project file + const githubUrl = "https://github.com/simplex-chat/simplex-chat/blob/stable" + const keyword = "/simplex-chat" + index = linkFile.indexOf(keyword) + linkFile = linkFile.substring(index + keyword.length) + parsed.path = `${githubUrl}${linkFile}` + } else { + // if the link is not a valid website file or project file + throw new Error(`Broken link: ${parsed.path} in ${hostFile}`) + } } - if (parsed.path.startsWith("../PRIVACY.md")) { - parsed.path = parsed.path.replace("../PRIVACY.md", "/privacy") - } - parsed.path = parsed.path.replace(/\.md$/, ".html").toLowerCase() + return uri.serialize(parsed) } }).use(markdownItAnchor, { @@ -422,4 +443,4 @@ module.exports = function (ty) { htmlTemplateEngine: 'njk', dataTemplateEngine: 'njk', } -} +} \ No newline at end of file diff --git a/website/src/_includes/blog_previews/20240516.html b/website/src/_includes/blog_previews/20240516.html new file mode 100644 index 0000000000..0d434eac36 --- /dev/null +++ b/website/src/_includes/blog_previews/20240516.html @@ -0,0 +1,17 @@ +

When it comes to open source privacy tools, the status quo often dictates the limitations of + existing protocols and + structures. However, these norms need to be challenged to radically shift how we approach genuinely + private communication. This requires doing some uncomfortable things, like making hard choices as it relates to + funding, alternative decentralization models, doubling down on privacy over convenience, and more. +

+ +

In this post we explain a bit more about why SimpleX operates and makes decisions the way it does: +

+ +
    +
  • No user accounts.
  • +
  • Privacy over convenience.
  • +
  • Network decentralization.
  • +
  • Funding and profitability.
  • +
  • Company jurisdiction.
  • +
\ No newline at end of file diff --git a/website/src/call/call.js b/website/src/call/call.js index 8104470686..b247431f4b 100644 --- a/website/src/call/call.js +++ b/website/src/call/call.js @@ -24,9 +24,8 @@ var TransformOperation; let activeCall; const processCommand = (function () { const defaultIceServers = [ - { urls: ["stuns:stun.simplex.im:443"] }, { urls: ["stun:stun.simplex.im:443"] }, - { urls: ["turns:turn.simplex.im:443"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj" }, + { urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, ]; function getCallConfig(encodedInsertableStreams, iceServers, relay) { return {