diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a3e19535d..0c7a55b148 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,7 @@ on: - users tags: - "v*" + - "!*-fdroid" pull_request: jobs: diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 4525f3c5dc..039c136464 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -9,6 +9,7 @@ on: - website/** - images/** - blog/** + - docs/** - .github/workflows/web.yml jobs: diff --git a/README.md b/README.md index b0631cba4e..66465926dd 100644 --- a/README.md +++ b/README.md @@ -230,24 +230,18 @@ You can use SimpleX with your own servers and still communicate with people usin ## News and updates -Recent updates: +Recent and important updates: -[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.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). [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). -[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.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). -[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md). - -[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md). - -[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.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). @@ -293,18 +287,20 @@ What is already implemented: 3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message. 4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well). 5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks. -6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed. +6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed. 7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key. 9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details. 10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings. 11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections. 12. Manual messaging queue rotations to move conversation to another SMP relay. +13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). +14. Local files encryption, except videos (to be added later). We plan to add: -1. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`. This is currently in progress. -2. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. +1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. +2. Post-quantum resistant key exchange in double ratchet protocol. 3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days). 4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code. @@ -368,22 +364,26 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A - ✅ Message editing history - ✅ Reduced battery and traffic usage in large groups. - ✅ Message delivery confirmation (with sender opt-out per contact). -- 🏗 Desktop client. +- ✅ Desktop client. +- ✅ Encryption of local files stored in the app. +- 🏗 Using mobile profiles from the desktop app. +- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). +- Post-quantum resistant key exchange in double ratchet protocol. +- Large groups, communities and public channels. +- Privacy & security slider - a simple way to set all settings at once. +- Improve sending videos (including encryption of locally stored videos). +- Improve experience for the new users. - SMP queue redundancy and rotation (manual is supported). - Include optional message into connection request sent via contact address. -- Local app files encryption. - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). -- Large groups, communities and public channels. - Feeds/broadcasts. - Ephemeral/disappearing/OTR conversations with the existing contacts. - Privately share your location. - Web widgets for custom interactivity in the chats. - Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.). -- Supporting the same profile on multiple devices. - Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages: - keep all your contacts and groups even if you lose the domain. - the server doesn't have information about your contacts and groups. -- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic). - High capacity multi-node SMP relays. ## Disclaimers diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index aef8711f34..85e66e893d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(connection.id) } } + case let .contactDeletedByContact(user, contact): + if active(user) && contact.directOrUsed { + await MainActor.run { + m.updateContact(contact) + } + } case let .contactConnected(user, contact, _): if active(user) && contact.directOrUsed { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cb90097..81412bf310 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -164,7 +164,7 @@ struct ChatInfoView: View { // synchronizeConnectionButtonForce() // } } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) if let contactLink = contact.contactLink { Section { @@ -181,7 +181,7 @@ struct ChatInfoView: View { } } - if contact.ready { + if contact.ready && contact.active { Section("Servers") { networkStatusRow() .onTapGesture { @@ -192,8 +192,7 @@ struct ChatInfoView: View { alert = .switchAddressAlert } .disabled( - !contact.ready - || connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5ec61a8c2b..31fe19c39a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -60,6 +60,7 @@ struct ChatItemContentView: View { var chatInfo: ChatInfo var chatItem: ChatItem var msgContentView: () -> Content + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false var body: some View { switch chatItem.content { @@ -69,10 +70,16 @@ struct ChatItemContentView: View { case .rcvDeleted: deletedItemView() case let .sndCall(status, duration): callItemView(status, duration) case let .rcvCall(status, duration): callItemView(status, duration) - case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) + case let .rcvIntegrityError(msgError): + if developerTools { + IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) + } else { + ZStack {} + } case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81a063dcfc..389080efc5 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -150,7 +150,7 @@ struct ChatView: View { HStack { if contact.allowsFeature(.calls) { callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } Menu { if contact.allowsFeature(.calls) { @@ -159,11 +159,11 @@ struct ChatView: View { } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } searchButton() toggleNtfsButton(chat) - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") } @@ -321,6 +321,7 @@ struct ChatView: View { @ViewBuilder private func connectingText() -> some View { if case let .direct(contact) = chat.chatInfo, !contact.ready, + contact.active, !contact.nextSendGrpInv { Text("connecting…") .font(.caption) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e7580530b6..f445ae4b5d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -65,7 +65,7 @@ struct ChatListNavLink: View { } Button { AlertManager.shared.showAlert( - contact.ready + contact.ready || !contact.active ? deleteContactAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada74..2eb6d9f6bd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -57,19 +57,26 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { - if case let .group(groupInfo) = chat.chatInfo { + switch chat.chatInfo { + case let .direct(contact): + if !contact.active { + inactiveIcon() + } else { + EmptyView() + } + case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { - case .memLeft: groupInactiveIcon() - case .memRemoved: groupInactiveIcon() - case .memGroupDeleted: groupInactiveIcon() + case .memLeft: inactiveIcon() + case .memRemoved: inactiveIcon() + case .memGroupDeleted: inactiveIcon() default: EmptyView() } - } else { + default: EmptyView() } } - @ViewBuilder private func groupInactiveIcon() -> some View { + @ViewBuilder private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) @@ -80,7 +87,6 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t) - .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) case let .group(groupInfo): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { @@ -183,7 +189,7 @@ struct ChatPreviewView: View { if !contact.ready { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") - } else { + } else if contact.active { chatPreviewInfoText("connecting…") } } @@ -228,16 +234,20 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - switch (chatModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(chat.chatInfo.incognito) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) - default: - ProgressView() + if contact.active { + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: incognitoIcon(chat.chatInfo.incognito) + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } + } else { + incognitoIcon(chat.chatInfo.incognito) } default: incognitoIcon(chat.chatInfo.incognito) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index d786184b68..8b45e45f95 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -48,11 +48,6 @@ 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; - 5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250B2ABDFA8900A21210 /* libffi.a */; }; - 5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250C2ABDFA8900A21210 /* libgmp.a */; }; - 5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */; }; - 5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250E2ABDFA8900A21210 /* libgmpxx.a */; }; - 5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; @@ -119,6 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; + 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; }; + 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; }; + 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; }; + 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; }; + 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -293,11 +293,6 @@ 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; - 5C56250B2ABDFA8900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C56250C2ABDFA8900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a"; sourceTree = ""; }; - 5C56250E2ABDFA8900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a"; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = ""; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -400,6 +395,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = ""; }; + 5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -507,12 +507,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */, - 5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */, + 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */, - 5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */, - 5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */, + 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */, + 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */, + 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */, + 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C56250B2ABDFA8900A21210 /* libffi.a */, - 5C56250C2ABDFA8900A21210 /* libgmp.a */, - 5C56250E2ABDFA8900A21210 /* libgmpxx.a */, - 5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */, - 5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */, + 5CC7398A2AC9D168009470A9 /* libffi.a */, + 5CC739892AC9D168009470A9 /* libgmp.a */, + 5CC7398B2AC9D168009470A9 /* libgmpxx.a */, + 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */, + 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */, ); path = Libraries; sourceTree = ""; @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1507,7 +1507,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1549,7 +1549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1621,7 +1621,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1653,7 +1653,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1696,7 +1696,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 176; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1742,7 +1742,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.3; + MARKETING_VERSION = 5.4; 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 b0834f5715..951f726be9 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error { case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) + case contactDeletedByContact(user: UserRef, contact: Contact) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error { case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" + case .contactDeletedByContact: return "contactDeletedByContact" case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" @@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error { case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) @@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable { case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) + case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) case connectionDisabled(connection: Connection) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0ec048572..f9996d8400 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var activeConn: Connection public var viaGroup: Int64? public var contactUsed: Bool + public var contactStatus: ContactStatus public var chatSettings: ChatSettings public var userPreferences: Preferences public var mergedPreferences: ContactUserPreferences @@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } + public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { profile: LocalProfile.sampleData, activeConn: Connection.sampleData, contactUsed: true, + contactStatus: .active, chatSettings: ChatSettings.defaults, userPreferences: Preferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData, @@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat { ) } +public enum ContactStatus: String, Decodable { + case active = "active" + case deleted = "deleted" +} + public struct ContactRef: Decodable, Equatable { var contactId: Int64 public var agentConnId: String @@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDecryptionError: return showNtfDir case .rcvGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir + case .rcvDirectEvent: return false case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { case .groupUpdated: return false @@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent { case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent) @@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent { case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text @@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public enum RcvDirectEvent: Decodable { + case contactDeleted + + var text: String { + switch self { + case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item") + } + } +} + public enum RcvGroupEvent: Decodable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 6a5fd1d0fe..9fb40c93d8 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -97,7 +97,7 @@ kotlin { implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") - implementation("uk.co.caprica:vlcj:4.7.0") + implementation("uk.co.caprica:vlcj:4.7.3") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt new file mode 100644 index 0000000000..3f33913e54 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -0,0 +1,39 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.Divider +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.helpers.* + +@Composable +actual fun ChatListNavLinkLayout( + chatLinkPreview: @Composable () -> Unit, + click: () -> Unit, + dropdownMenuItems: (@Composable () -> Unit)?, + showMenu: MutableState, + stopped: Boolean, + selectedChat: State +) { + var modifier = Modifier.fillMaxWidth() + if (!stopped) modifier = modifier + .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true } + Box(modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + chatLinkPreview() + } + if (dropdownMenuItems != null) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) + } + } + Divider(Modifier.padding(horizontal = 8.dp)) +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index eb4714710c..dbfee70257 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -60,7 +60,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); - (*env)->ReleaseStringUTFChars(env, dbKey, _confirm); + (*env)->ReleaseStringUTFChars(env, confirm, _confirm); // Creating array of Object's (boxed values can be passed, eg. Long instead of long) jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt index cfbc1ed320..eb4794dd6a 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/CMakeLists.txt @@ -31,9 +31,9 @@ else() set(CMAKE_BUILD_RPATH "@loader_path") endif() -if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64") +if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64") set(OS_LIB_ARCH "x86_64") -elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64") +elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64") set(OS_LIB_ARCH "aarch64") else() set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}") @@ -55,8 +55,13 @@ add_library( # Sets the name of the library. add_library( simplex SHARED IMPORTED ) # Lib has different name because of version, find it -FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT}) -set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) +FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT}) + +if(WIN32) + set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB}) +else() + set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) +endif() # Specifies libraries CMake should link to your target library. You @@ -73,6 +78,11 @@ else() target_link_libraries(app-lib rts simplex) endif() +if(APPLE) + add_custom_command(TARGET app-lib POST_BUILD + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh +) +endif() # Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh new file mode 100755 index 0000000000..c44df8f1f0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +lib=libapp-lib.dylib +RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11) +for RPATH in $RPATHS; do + install_name_tool -delete_rpath $RPATH $lib +done 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 887abe756b..8687ac390a 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 @@ -224,6 +224,7 @@ object ChatModel { } // add to current chat if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { // Prevent situation when chat item already in the list received from backend if (chatItems.none { it.id == cItem.id }) { @@ -231,6 +232,7 @@ object ChatModel { chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) } else { chatItems.add(cItem) + Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") } } } @@ -259,13 +261,16 @@ object ChatModel { } // update current chat return if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem + Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") false } else { chatItems.add(cItem) + Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } } @@ -374,6 +379,7 @@ object ChatModel { var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 + Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}") while (i < chatItems.count()) { val item = chatItems[i] if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { @@ -388,6 +394,7 @@ object ChatModel { } i += 1 } + Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}") } return markedRead } @@ -797,6 +804,7 @@ data class Contact( val activeConn: Connection, val viaGroup: Long? = null, val contactUsed: Boolean, + val contactStatus: ContactStatus, val chatSettings: ChatSettings, val userPreferences: ChatPreferences, val mergedPreferences: ContactUserPreferences, @@ -809,8 +817,9 @@ data class Contact( override val id get() = "@$contactId" override val apiId get() = contactId override val ready get() = activeConn.connStatus == ConnStatus.Ready + val active get() = contactStatus == ContactStatus.Active override val sendMsgEnabled get() = - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent override val ntfsEnabled get() = chatSettings.enableNtfs @@ -859,6 +868,7 @@ data class Contact( profile = LocalProfile.sampleData, activeConn = Connection.sampleData, contactUsed = true, + contactStatus = ContactStatus.Active, chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, @@ -869,6 +879,12 @@ data class Contact( } } +@Serializable +enum class ContactStatus { + @SerialName("active") Active, + @SerialName("deleted") Deleted; +} + @Serializable class ContactRef( val contactId: Long, @@ -1471,6 +1487,7 @@ data class ChatItem ( is CIContent.RcvDecryptionError -> showNtfDir is CIContent.RcvGroupInvitation -> showNtfDir is CIContent.SndGroupInvitation -> showNtfDir + is CIContent.RcvDirectEventContent -> false is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberConnected -> false @@ -1854,6 +1871,7 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @@ -1881,6 +1899,7 @@ sealed class CIContent: ItemContent { is RcvDecryptionError -> msgDecryptError.text is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text + is RcvDirectEventContent -> rcvDirectEvent.text is RcvGroupEventContent -> rcvGroupEvent.text is SndGroupEventContent -> sndGroupEvent.text is RcvConnEventContent -> rcvConnEvent.text @@ -2487,6 +2506,15 @@ sealed class MsgErrorType() { } } +@Serializable +sealed class RcvDirectEvent() { + @Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent() + + val text: String get() = when (this) { + is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted) + } +} + @Serializable sealed class RcvGroupEvent() { @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() 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 060738bc18..43043d65fe 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 @@ -106,6 +106,7 @@ class AppPreferences { val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) + val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) @@ -265,6 +266,7 @@ class AppPreferences { private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" + private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible" 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" @@ -1366,6 +1368,11 @@ object ChatController { chatModel.removeChat(r.connection.id) } } + is CR.ContactDeletedByContact -> { + if (active(r.user) && r.contact.directOrUsed) { + chatModel.updateContact(r.contact) + } + } is CR.ContactConnected -> { if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) @@ -3295,6 +3302,7 @@ sealed class CR { @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -3426,6 +3434,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactDeleted -> "contactDeleted" + is ContactDeletedByContact -> "contactDeletedByContact" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -3554,6 +3563,7 @@ sealed class CR { is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) + is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) @@ -3822,6 +3832,7 @@ sealed class ChatErrorType { is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" + is ContactNotActive -> "contactNotActive" is ContactDisabled -> "contactDisabled" is ConnectionDisabled -> "connectionDisabled" is GroupUserRole -> "groupUserRole" @@ -3897,6 +3908,7 @@ sealed class ChatErrorType { @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() + @Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() @Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType() @Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index e471341669..c4f2a2cbde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -123,7 +123,18 @@ fun TerminalLog(terminalItems: List) { DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } } + val reversedTerminalItems by remember { + derivedStateOf { + // Such logic prevents concurrent modification + val res = ArrayList() + var i = 0 + while (i < terminalItems.size) { + res.add(terminalItems[i]) + i++ + } + res.asReversed() + } + } val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 5fcb90c1c9..1ba742b034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -291,7 +290,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView { if (connectionCode != null) { VerifyCodeButton(contact.verified, verifyClicked) @@ -318,7 +317,7 @@ fun ChatInfoLayout( SectionDividerSpaced() } - if (contact.ready) { + if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SectionItemView({ AlertManager.shared.showAlertMsg( 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 e13439e18b..ee9e109a7b 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 @@ -66,11 +66,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() + .onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") } .filter { it != null && activeChat.value?.id != it } .collect { chatId -> // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Also for situation when chatId changes after clicking in notification, etc activeChat.value = chatModel.getChat(chatId!!) + Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}") markUnreadChatAsRead(activeChat, chatModel) } } @@ -89,9 +91,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } .distinctUntilChanged() + .onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") } // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } - .collect { activeChat.value = it } + .collect { + activeChat.value = it + Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}") + } } } val view = LocalMultiplatformView() @@ -118,7 +124,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) { + if ( + chat.chatInfo is ChatInfo.Direct + && !chat.chatInfo.contact.ready + && chat.chatInfo.contact.active + && !chat.chatInfo.contact.nextSendGrpInv + ) { Text( generalGetString(MR.strings.contact_connection_pending), Modifier.padding(top = 4.dp), @@ -213,7 +224,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val firstId = chatModel.chatItems.firstOrNull()?.id if (c != null && firstId != null) { withApi { + Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } }, @@ -394,6 +407,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } }, onComposed, + developerTools = chatModel.controller.appPrefs.developerTools.get(), ) } } @@ -435,6 +449,7 @@ fun ChatLayout( changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, onComposed: suspend (chatId: String) -> Unit, + developerTools: Boolean, ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -500,7 +515,7 @@ fun ChatLayout( useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markRead, setFloatingButton, onComposed, + setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, ) } } @@ -548,15 +563,15 @@ fun ChatInfoToolbar( showMenu.value = false startCall(CallMediaType.Audio) }, - enabled = chat.chatInfo.contact.ready) { + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { Icon( painterResource(MR.images.ic_call_500), stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) } } - if (chat.chatInfo.contact.ready) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -574,7 +589,7 @@ fun ChatInfoToolbar( } } } - if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) { + if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) { val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } menuItems.add { ItemAction( @@ -695,6 +710,7 @@ fun BoxWithConstraintsScope.ChatItemsList( markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit, onComposed: suspend (chatId: String) -> Unit, + developerTools: Boolean, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -831,7 +847,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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, getConnectedMemberNames = ::getConnectedMemberNames) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } else { @@ -840,7 +856,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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, getConnectedMemberNames = ::getConnectedMemberNames) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } @@ -850,7 +866,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, 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) } } } else { // direct message @@ -861,7 +877,7 @@ fun BoxWithConstraintsScope.ChatItemsList( end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, 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) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, 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) } } @@ -1300,6 +1316,7 @@ fun PreviewChatLayout() { changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, onComposed = {}, + developerTools = false, ) } } @@ -1368,6 +1385,7 @@ fun PreviewGroupChatLayout() { changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, onComposed = {}, + developerTools = false, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index 78bdf53d14..996dc819fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau ) if (showPreview.value) { VideoPreviewImageView(preview, onClick, onLongClick) - PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick) + PlayButton(brokenVideo, onLongClick = onLongClick, play) } DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) } 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 98811260d9..b5236e2491 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 @@ -64,6 +64,7 @@ fun ChatItemView( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, getConnectedMemberNames: (() -> List)? = null, + developerTools: Boolean, ) { val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent @@ -343,10 +344,15 @@ fun ChatItemView( is CIContent.RcvDeleted -> DeletedItem() is CIContent.SndCall -> CallItem(c.status, c.duration) is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL) + } else { + Box(Modifier.size(0.dp)) {} + } is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.RcvDirectEventContent -> EventItemView() is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) @@ -583,6 +589,7 @@ fun PreviewChatItemView() { findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, + developerTools = false, ) } } @@ -613,6 +620,7 @@ fun PreviewChatItemViewDeletedContent() { findModelMember = { null }, setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, + developerTools = false, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 05d11208fb..4d4d847cc7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, player.stop() } LaunchedEffect(Unit) { - player.enableSound(true) snapshotFlow { isCurrentPage.value } .distinctUntilChanged() .collect { - // Do not autoplay on desktop because it needs workaround - if (it && appPlatform.isAndroid) play() else if (!it) stop() + if (it) play() else stop() + player.enableSound(true) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 7a0fc12786..41c94f21d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -1,7 +1,6 @@ package chat.simplex.common.views.chatlist import SectionItemView -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -14,6 +13,10 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,6 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { showMenu.value = false delay(500L) } + val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value when (chat.chatInfo) { is ChatInfo.Direct -> { @@ -53,7 +57,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, - stopped + stopped, + selectedChat ) } is ChatInfo.Group -> @@ -62,7 +67,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( @@ -70,7 +76,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( @@ -84,7 +91,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, - stopped + stopped, + selectedChat ) is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( @@ -97,7 +105,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { }, dropdownMenuItems = null, showMenu, - stopped + stopped, + selectedChat ) } } @@ -122,9 +131,11 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { } suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { + Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { openChat(chat, chatModel) + Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } @@ -628,32 +639,14 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo } @Composable -fun ChatListNavLinkLayout( +expect fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, dropdownMenuItems: (@Composable () -> Unit)?, showMenu: MutableState, - stopped: Boolean -) { - var modifier = Modifier.fillMaxWidth() - if (!stopped) modifier = modifier - .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) - .onRightClick { showMenu.value = true } - Box(modifier) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), - verticalAlignment = Alignment.Top - ) { - chatLinkPreview() - } - if (dropdownMenuItems != null) { - DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) - } - } - Divider(Modifier.padding(horizontal = 8.dp)) -} + stopped: Boolean, + selectedChat: State +) @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -690,7 +683,8 @@ fun PreviewChatListNavLinkDirect() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } @@ -730,7 +724,8 @@ fun PreviewChatListNavLinkGroup() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } @@ -750,7 +745,8 @@ fun PreviewChatListNavLinkContactRequest() { click = {}, dropdownMenuItems = null, showMenu = remember { mutableStateOf(false) }, - stopped = false + stopped = false, + selectedChat = remember { mutableStateOf(false) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 780e3515df..a5775d369b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,7 @@ fun ChatPreviewView( val cInfo = chat.chatInfo @Composable - fun groupInactiveIcon() { + fun inactiveIcon() { Icon( painterResource(MR.images.ic_cancel_filled), stringResource(MR.strings.icon_descr_group_inactive), @@ -53,13 +53,19 @@ fun ChatPreviewView( @Composable fun chatPreviewImageOverlayIcon() { - if (cInfo is ChatInfo.Group) { - when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemLeft -> groupInactiveIcon() - GroupMemberStatus.MemRemoved -> groupInactiveIcon() - GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon() - else -> {} + when (cInfo) { + is ChatInfo.Direct -> + if (!cInfo.contact.active) { + inactiveIcon() + } + is ChatInfo.Group -> + when (cInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemLeft -> inactiveIcon() + GroupMemberStatus.MemRemoved -> inactiveIcon() + GroupMemberStatus.MemGroupDeleted -> inactiveIcon() + else -> {} } + else -> {} } } @@ -125,7 +131,7 @@ fun ChatPreviewView( if (cInfo.contact.verified) { VerifiedIcon() } - chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary) + chatPreviewTitleText() } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { @@ -174,7 +180,7 @@ fun ChatPreviewView( is ChatInfo.Direct -> if (cInfo.contact.nextSendGrpInv) { Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) - } else if (!cInfo.ready) { + } else if (!cInfo.ready && cInfo.contact.active) { Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) } is ChatInfo.Group -> @@ -191,28 +197,32 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) + if (cInfo.contact.active) { + val descr = contactNetworkStatus?.statusString + when (contactNetworkStatus) { + is NetworkStatus.Connected -> + IncognitoIcon(chat.chatInfo.incognito) - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.dp) - ) + is NetworkStatus.Error -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = descr, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.dp) + ) - else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + else -> + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + } else { + IncognitoIcon(chat.chatInfo.incognito) } } else { IncognitoIcon(chat.chatInfo.incognito) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 68aa1fff9e..a6ac8c14ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -34,6 +34,17 @@ fun DeveloperView( ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(it, close) })} SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades) SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) + if (appPlatform.isDesktop && devTools.value) { + TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked -> + if (checked) { + withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { + m.controller.appPrefs.terminalAlwaysVisible.set(true) + } + } else { + m.controller.appPrefs.terminalAlwaysVisible.set(false) + } + } + } } SectionTextFooter( generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 8969e48b2c..eebe7b3f49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -322,6 +322,15 @@ fun ChatLockItem( } } +@Composable fun TerminalAlwaysVisibleItem(pref: SharedPreference, onChange: (Boolean) -> Unit) { + SettingsActionItemWithContent(painterResource(MR.images.ic_engineering), stringResource(MR.strings.terminal_always_visible), extraPadding = false) { + DefaultSwitch( + checked = remember { pref.state }.value, + onCheckedChange = onChange, + ) + } +} + @Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { Icon( 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 ab0d943f33..d57211f4ac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1056,6 +1056,7 @@ Database downgrade Incompatible database version Confirm database upgrades + Show console in new window Invalid migration confirmation Upgrade and open chat Downgrade and open chat @@ -1105,6 +1106,9 @@ You rejected group invitation Group invitation expired + + deleted contact + invited %1$s connected diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg new file mode 100644 index 0000000000..0848537041 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 62a4d9e214..6b81209d4c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,10 +14,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatModel -import chat.simplex.common.platform.defaultLocale import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.FileDialogChooser +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener @@ -118,6 +121,18 @@ fun showApp() = application { } } } + // Reload all strings in all @Composable's after language change at runtime + if (remember { ChatController.appPrefs.terminalAlwaysVisible.state }.value && remember { ChatController.appPrefs.appLanguage.state }.value != "") { + var hiddenUntilRestart by remember { mutableStateOf(false) } + if (!hiddenUntilRestart) { + val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp) + Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) { + SimpleXTheme { + TerminalView(ChatModel) { hiddenUntilRestart = true } + } + } + } + } } class SimplexWindowState { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt new file mode 100644 index 0000000000..c2f37fd5d9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -0,0 +1,79 @@ +package org.jetbrains.compose.videoplayer + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface +import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback +import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat +import java.nio.ByteBuffer +import javax.swing.SwingUtilities + +// https://github.com/JetBrains/compose-multiplatform/pull/3336/files +internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { + + private val videoSurface = SkiaBitmapVideoSurface() + private lateinit var imageInfo: ImageInfo + private lateinit var frameBytes: ByteArray + private val skiaBitmap: Bitmap = Bitmap() + private val composeBitmap = mutableStateOf(null) + + val bitmap: State = composeBitmap + + override fun attach(mediaPlayer: MediaPlayer) { + videoSurface.attach(mediaPlayer) + } + + private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback { + private var sourceWidth: Int = 0 + private var sourceHeight: Int = 0 + + override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { + this.sourceWidth = sourceWidth + this.sourceHeight = sourceHeight + return RV32BufferFormat(sourceWidth, sourceHeight) + } + + override fun allocatedBuffers(buffers: Array) { + frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) } + imageInfo = ImageInfo( + sourceWidth, + sourceHeight, + ColorType.BGRA_8888, + ColorAlphaType.PREMUL, + ) + } + } + + private inner class SkiaBitmapRenderCallback : RenderCallback { + override fun display( + mediaPlayer: MediaPlayer, + nativeBuffers: Array, + bufferFormat: BufferFormat, + ) { + SwingUtilities.invokeLater { + nativeBuffers[0].rewind() + nativeBuffers[0].get(frameBytes) + skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4) + composeBitmap.value = skiaBitmap.asComposeImageBitmap() + } + } + } + + private inner class SkiaBitmapVideoSurface : CallbackVideoSurface( + SkiaBitmapBufferFormatCallback(), + SkiaBitmapRenderCallback(), + true, + videoSurfaceAdapter, + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index b7341cb4a8..c04587656f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8 import chat.simplex.res.MR import org.jetbrains.skia.Image import java.awt.RenderingHints +import java.awt.geom.AffineTransform +import java.awt.image.AffineTransformOp import java.awt.image.BufferedImage import java.io.* import java.net.URI @@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { @Suppress("NewApi") actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() + +// https://stackoverflow.com/a/68926993 +fun BufferedImage.rotate(angle: Double): BufferedImage { + val sin = Math.abs(Math.sin(Math.toRadians(angle))) + val cos = Math.abs(Math.cos(Math.toRadians(angle))) + val w = width + val h = height + val neww = Math.floor(w * cos + h * sin).toInt() + val newh = Math.floor(h * cos + w * sin).toInt() + val rotated = BufferedImage(neww, newh, type) + val graphic = rotated.createGraphics() + graphic.translate((neww - w) / 2, (newh - h) / 2) + graphic.rotate(Math.toRadians(angle), (w / 2).toDouble(), (h / 2).toDouble()) + graphic.drawRenderedImage(this, null) + graphic.dispose() + return rotated +} + +// https://stackoverflow.com/a/9559043 +fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImage { + if (!vertically && !horizontally) return this + val tx: AffineTransform + if (vertically && horizontally) { + tx = AffineTransform.getScaleInstance(-1.0, -1.0) + tx.translate(-width.toDouble(), -height.toDouble()) + } else if (vertically) { + tx = AffineTransform.getScaleInstance(1.0, -1.0) + tx.translate(0.0, -height.toDouble()) + } else { + tx = AffineTransform.getScaleInstance(-1.0, 1.0) + tx.translate(-width.toDouble(), 0.0) + } + return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 2121b9cfdf..cb4e3acdb4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -16,6 +16,7 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 + fun isWindows() = this == WINDOWS_X86_64 fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64 } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index ed8efcd57f..4439680c66 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -54,9 +54,9 @@ actual object AudioPlayer: AudioPlayerInterface { if (fileSource.cryptoArgs != null) { val tmpFile = fileSource.createTmpFileIfNeeded() decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath) - player.media().prepare("file://${tmpFile.absolutePath}") + player.media().prepare(tmpFile.toURI().toString().replaceFirst("file:", "file://")) } else { - player.media().prepare("file://$absoluteFilePath") + player.media().prepare(File(absoluteFilePath).toURI().toString().replaceFirst("file:", "file://")) } }.onFailure { Log.e(TAG, it.stackTraceToString()) @@ -171,7 +171,7 @@ actual object AudioPlayer: AudioPlayerInterface { var res: Int? = null try { val helperPlayer = AudioPlayerComponent().mediaPlayer() - helperPlayer.media().startPaused("file://$unencryptedFilePath") + helperPlayer.media().startPaused(File(unencryptedFilePath).toURI().toString().replaceFirst("file:", "file://")) res = helperPlayer.duration helperPlayer.stop() helperPlayer.release() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index e590c2e204..9f8c1884eb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -2,17 +2,20 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* -import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.media.VideoOrientation +import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent import java.awt.Component +import java.awt.image.BufferedImage import java.io.File import java.net.URI +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.max actual class VideoPlayer actual constructor( @@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(0L) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent = initializeMediaPlayerComponent() + val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { - withBGApi { - setPreviewAndDuration() - } + setPreviewAndDuration() } - private val currentVolume: Int by lazy { player.audio().volume() } - private var isReleased: Boolean = false + private var isReleased: AtomicBoolean = AtomicBoolean(false) private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) private var progressJob: Job? = null @@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor( PLAYING, PAUSED, STOPPED } + /** Should be called in [playerThread]. Otherwise, it creates deadlocks in [player.stop] and [player.release] calls */ private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { val filepath = getAppFilePath(uri) if (filepath == null || !File(filepath).exists()) { @@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor( // Player can only be accessed in one specific thread progressJob = CoroutineScope(Dispatchers.Main).launch { onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) - while (isActive && !isReleased && player.isPlaying) { + while (isActive && !isReleased.get() && player.isPlaying) { // Even when current position is equal to duration, the player has isPlaying == true for some time, // so help to make the playback stopped in UI immediately if (player.currentPosition == player.duration) { @@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor( delay(50) onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) } - if (isActive && !isReleased) { + if (isActive && !isReleased.get()) { onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED) } onProgressUpdate(null, TrackState.PAUSED) @@ -107,9 +108,11 @@ actual class VideoPlayer actual constructor( } override fun stop() { - if (isReleased || !videoPlaying.value) return - player.controls().stop() - stopListener() + if (isReleased.get() || !videoPlaying.value) return + playerThread.execute { + player.stop() + stopListener() + } } private fun stopListener() { @@ -133,45 +136,57 @@ actual class VideoPlayer actual constructor( if (progress.value == duration.value) { progress.value = 0 } - videoPlaying.value = start(progress.value) { pro, _ -> - if (pro != null) { - progress.value = pro - } - if ((pro == null || pro == duration.value) && duration.value != 0L) { - videoPlaying.value = false - if (pro == duration.value) { - progress.value = if (resetOnEnd) 0 else duration.value - }/* else if (state == TrackState.STOPPED) { + playerThread.execute { + videoPlaying.value = start(progress.value) { pro, _ -> + if (pro != null) { + progress.value = pro + } + if ((pro == null || pro == duration.value) && duration.value != 0L) { + videoPlaying.value = false + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + }/* else if (state == TrackState.STOPPED) { progress.value = 0 // }*/ + } } } } override fun enableSound(enable: Boolean): Boolean { - if (isReleased) return false - if (soundEnabled.value == enable) return false + // Impossible to change volume for only one player. It changes for every player + // https://github.com/caprica/vlcj/issues/985 + return false + /*if (isReleased.get() || soundEnabled.value == enable) return false soundEnabled.value = enable - player.audio().setVolume(if (enable) currentVolume else 0) - return true + playerThread.execute { + player.audio().isMute = !enable + } + return true*/ } - override fun release(remove: Boolean) { withApi { - if (isReleased) return@withApi - isReleased = true - // TODO - /** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */ - if (player.isPlaying) player.stop() - CoroutineScope(Dispatchers.IO).launch { player.release() } - if (remove) { - VideoPlayerHolder.players.remove(uri to gallery) + override fun release(remove: Boolean) { + CoroutineScope(playerThread.asCoroutineDispatcher()).launch { + if (isReleased.get()) return@launch + isReleased.set(true) + if (player.isPlaying) { + player.stop() + } + if (usePool) { + putPlayer(mediaPlayerComponent) + } else { + player.release() + } + if (remove) { + VideoPlayerHolder.players.remove(uri to gallery) + } } - }} + } private val MediaPlayer.currentPosition: Int - get() = if (isReleased) 0 else max(0, player.status().time().toInt()) + get() = if (isReleased.get()) 0 else max(0, status().time().toInt()) - private suspend fun setPreviewAndDuration() { + private fun setPreviewAndDuration() { // It freezes main thread, doing it in IO thread CoroutineScope(Dispatchers.IO).launch { val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) } @@ -182,35 +197,79 @@ actual class VideoPlayer actual constructor( } } - private fun initializeMediaPlayerComponent(): Component { - return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() - } else { - EmbeddedMediaPlayerComponent() - } - } - - private fun Component.mediaPlayer() = when (this) { - is CallbackMediaPlayerComponent -> mediaPlayer() - is EmbeddedMediaPlayerComponent -> mediaPlayer() - else -> error("mediaPlayer() can only be called on vlcj player components") - } - companion object { - suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration { - val player = CallbackMediaPlayerComponent().mediaPlayer() + private val usePool = false + + private fun Component.mediaPlayer() = when (this) { + is CallbackMediaPlayerComponent -> mediaPlayer() + is EmbeddedMediaPlayerComponent -> mediaPlayer() + else -> error("mediaPlayer() can only be called on vlcj player components") + } + + private fun initializeMediaPlayerComponent(): Component { + return if (desktopPlatform.isMac()) { + CallbackMediaPlayerComponent() + } else { + EmbeddedMediaPlayerComponent() + } + } + + suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) { + val mediaComponent = getOrCreateHelperPlayer() + val player = mediaComponent.mediaPlayer() if (uri == null || !File(uri.rawPath).exists()) { - return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) + return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) } player.media().startPaused(uri.toString().replaceFirst("file:", "file://")) val start = System.currentTimeMillis() - while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) { + var snap: BufferedImage? = null + while (snap == null && start + 5000 > System.currentTimeMillis()) { + snap = player.snapshots()?.get() delay(10) } - val preview = player.snapshots()?.get()?.toComposeImageBitmap() + val orientation = player.media().info().videoTracks().first().orientation() + val preview: ImageBitmap? = when (orientation) { + VideoOrientation.TOP_LEFT -> snap + VideoOrientation.TOP_RIGHT -> snap?.flip(false, true) + VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false) + VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0) + VideoOrientation.LEFT_TOP -> snap /* Transposed */ + VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0) + VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0) + VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */ + else -> snap + }?.toComposeImageBitmap() val duration = player.duration.toLong() - CoroutineScope(Dispatchers.IO).launch { player.release() } - return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) + player.stop() + putHelperPlayer(mediaComponent) + return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) } + + val playerThread = Executors.newSingleThreadExecutor() + private val playersPool: ArrayList = ArrayList() + private val helperPlayersPool: ArrayList = ArrayList() + + private fun getOrCreatePlayer(): Component = playersPool.removeFirstOrNull() ?: createNew() + + private fun createNew(): Component = + initializeMediaPlayerComponent().apply { + mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() { + override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { + playerThread.execute { + mediaPlayer?.audio()?.setVolume(100) + mediaPlayer?.audio()?.isMute = false + } + } + + override fun stopped(mediaPlayer: MediaPlayer?) { + //playerThread.execute { mediaPlayer().videoSurface().set(null) } + } + }) + } + + private fun putPlayer(player: Component) = playersPool.add(player) + + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index c85057b47e..7a46873f7d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -1,12 +1,29 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import chat.simplex.common.platform.VideoPlayer +import chat.simplex.common.platform.isPlaying +import chat.simplex.common.views.helpers.onRightClick @Composable -actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {} +actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { + Box { + SurfaceFromPlayer(player, + Modifier + .width(width) + .combinedClickable( + onLongClick = onLongClick, + onClick = { if (player.player.isPlaying) stop() else onClick() } + ) + .onRightClick(onLongClick) + ) + } +} @Composable actual fun LocalWindowWidth(): Dp { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9aafc83d23..bd395c2c97 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -6,17 +6,15 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* -import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.delay +import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import kotlin.math.max @Composable @@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: modifier = modifier, ) } + @Composable actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { - // Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed - LaunchedEffect(Unit) { - simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp) - delay(50) - player.play(true) - simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp) - } Box { Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) { - val factory = remember { { player.mediaPlayerComponent } } - SwingPanel( - background = Color.Transparent, - modifier = Modifier, - factory = factory - ) + SurfaceFromPlayer(player, modifier) + IconButton(onClick = close, Modifier.padding(top = 5.dp)) { + Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) + } } - Controls(player, close) + Controls(player) } } @Composable -private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { +fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) { + val surface = remember { + SkiaBitmapVideoSurface().also { + player.player.videoSurface().set(it) + } + } + surface.bitmap.value?.let { bitmap -> + Image( + bitmap, + modifier = modifier.align(Alignment.Center), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + ) + } +} + +@Composable +private fun BoxScope.Controls(player: VideoPlayer) { val playing = remember(player) { player.videoPlaying } val progress = remember(player) { player.progress } val duration = remember(player) { player.duration } @@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { Slider( value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()), onValueChange = { player.player.seekTo((it * duration.value).toInt()) }, - modifier = Modifier.fillMaxWidth().weight(1f) + modifier = Modifier.fillMaxWidth() ) - IconButton(onClick = close,) { - Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) - } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt new file mode 100644 index 0000000000..0ad69d1c04 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -0,0 +1,60 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.helpers.* + +private object NoIndication : Indication { + private object NoIndicationInstance : IndicationInstance { + override fun ContentDrawScope.drawIndication() { + drawContent() + } + } + @Composable + override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance { + return NoIndicationInstance + } +} + +@Composable +actual fun ChatListNavLinkLayout( + chatLinkPreview: @Composable () -> Unit, + click: () -> Unit, + dropdownMenuItems: (@Composable () -> Unit)?, + showMenu: MutableState, + stopped: Boolean, + selectedChat: State +) { + var modifier = Modifier.fillMaxWidth() + if (!stopped) modifier = modifier + .background(color = if (selectedChat.value) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) + .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true } + CompositionLocalProvider( + LocalIndication provides if (selectedChat.value && !stopped) NoIndication else LocalIndication.current + ) { + Box(modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.Top + ) { + chatLinkPreview() + } + if (dropdownMenuItems != null) { + DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems) + } + } + } + Divider() +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 20675dc74e..79fcda7a50 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -10,12 +10,12 @@ import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams +import chat.simplex.common.platform.desktopPlatform import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog import java.io.File -import java.util.* import javax.swing.JFileChooser import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter @@ -53,7 +53,7 @@ fun FrameWindowScope.FileDialogChooser( params: DialogParams, onResult: (result: List) -> Unit ) { - if (isLinux()) { + if (desktopPlatform.isLinux() || desktopPlatform.isWindows()) { FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult) } else { FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult) @@ -121,7 +121,7 @@ fun FrameWindowScope.FileDialogChooserMultiple( } /* -* Has graphic glitches on many Linux distributions, so use only on non-Linux systems +* Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows * */ @Composable private fun FrameWindowScope.FileDialogAwt( @@ -159,5 +159,3 @@ private fun FrameWindowScope.FileDialogAwt( }, dispose = FileDialog::dispose ) - -fun isLinux(): Boolean = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) == "linux" diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 21b2cfa6e0..2a9042c433 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -88,7 +88,7 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat } actual fun getAppFileUri(fileName: String): URI = - URI("file:" + appFilesDir.absolutePath + File.separator + fileName) + URI(appFilesDir.toURI().toString() + "/" + fileName) actual fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 39d08e046b..3da87850d5 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -63,9 +63,10 @@ compose { windows { packageName = "SimpleX" iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico")) - console = true - perUserInstall = true + console = false + perUserInstall = false dirChooser = true + shortcut = true } macOS { packageName = "SimpleX" @@ -119,9 +120,9 @@ cmake { /*machines.customMachines.register("linux-aarch64") { toolchainFile.set(project.file("$cppPath/toolchains/aarch64-linux-gnu-gcc.cmake")) }*/ - machines.customMachines.register("win-amd64") { + /*machines.customMachines.register("win-amd64") { toolchainFile.set(project.file("$cppPath/toolchains/x86_64-windows-mingw32-gcc.cmake")) - } + }*/ if (machines.host.name == "mac-amd64") { machines.customMachines.register("mac-amd64") { toolchainFile.set(project.file("$cppPath/toolchains/x86_64-mac-apple-darwin-gcc.cmake")) @@ -139,6 +140,9 @@ cmake { val main by creating { cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt")) targetMachines.addAll(compileMachineTargets.toSet()) + if (machines.host.name.contains("win")) { + cmakeArgs.add("-G MinGW Makefiles") + } } } } @@ -153,102 +157,106 @@ afterEvaluate { tasks.create("cmakeBuildAndCopy") { dependsOn("cmakeBuild") val copyDetails = mutableMapOf>() - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") - into("src/jvmMain/resources/libs/linux-x86_64") - include("*.so*") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc" - from("$cppPath/desktop/libs/linux-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") - into("src/jvmMain/resources/libs/linux-aarch64") - include("*.so*") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc" - from("$cppPath/desktop/libs/linux-aarch64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") - into("src/jvmMain/resources/libs/windows-x86_64") - include("*.dll") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc" - from("$cppPath/desktop/libs/windows-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") - into("src/jvmMain/resources/libs/mac-x86_64") - include("*.dylib") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc" - from("$cppPath/desktop/libs/mac-x86_64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") - into("src/jvmMain/resources/libs/mac-aarch64") - include("*.dylib") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc" - from("$cppPath/desktop/libs/mac-aarch64/deps/vlc") - into(destinationDir) - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - copyIfNeeded(destinationDir, copyDetails) - } doLast { - copyDetails.forEach { (destinationDir, details) -> - details.forEach { detail -> - val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path) - if (target.exists()) { - target.setLastModified(detail.lastModified) + copy { + from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") + into("src/jvmMain/resources/libs/linux-x86_64") + include("*.so*") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc" + from("$cppPath/desktop/libs/linux-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") + into("src/jvmMain/resources/libs/linux-aarch64") + include("*.so*") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc" + from("$cppPath/desktop/libs/linux-aarch64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") + into("src/jvmMain/resources/libs/windows-x86_64") + include("*.dll") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc" + from("$cppPath/desktop/libs/windows-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") + into("src/jvmMain/resources/libs/mac-x86_64") + include("*.dylib") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc" + from("$cppPath/desktop/libs/mac-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") + into("src/jvmMain/resources/libs/mac-aarch64") + include("*.dylib") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc" + from("$cppPath/desktop/libs/mac-aarch64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + } + afterEvaluate { + doLast { + copyDetails.forEach { (destinationDir, details) -> + details.forEach { detail -> + val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path) + if (target.exists()) { + target.setLastModified(detail.lastModified) + } } } } diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 72c41a665b..a0be877329 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -18,13 +18,15 @@ fun main() { @Suppress("UnsafeDynamicallyLoadedCode") private fun initHaskell() { - val libApp = "libapp-lib.${desktopPlatform.libExtension}" val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) - System.load(File(libsTmpDir, libApp).absolutePath) - vlcDir.deleteRecursively() Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) + if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) { + windowsLoadRequiredLibs(libsTmpDir) + } else { + System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath) + } // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading //val libXcb = "libvlc_xcb_events.so.0.0.0" //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) @@ -55,3 +57,25 @@ private fun copyResources(from: String, to: Path) { } }) } + +private fun windowsLoadRequiredLibs(libsTmpDir: File) { + val mainLibs = arrayOf( + "libcrypto-3-x64.dll", + "libffi-8.dll", + "libgmp-10.dll", + "libsimplex.dll", + "libapp-lib.dll" + ) + mainLibs.forEach { + System.load(File(libsTmpDir, it).absolutePath) + } + val vlcLibs = arrayOf( + "libvlccore.dll", + "libvlc.dll", + "axvlc.dll", + "npvlc.dll" + ) + vlcLibs.forEach { + System.load(File(vlcDir, it).absolutePath) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 5a7cef30bb..e37dbca8eb 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.3 -android.version_code=152 +android.version_name=5.4-beta.0 +android.version_code=156 -desktop.version_name=5.3 -desktop.version_code=10 +desktop.version_name=5.4-beta.0 +desktop.version_code=12 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/blog/20230301-simplex-file-transfer-protocol.md b/blog/20230301-simplex-file-transfer-protocol.md index 08fec86adf..0008dd6b9b 100644 --- a/blog/20230301-simplex-file-transfer-protocol.md +++ b/blog/20230301-simplex-file-transfer-protocol.md @@ -4,6 +4,7 @@ title: "SimpleX File Transfer Protocol - a new protocol for sending large files date: 2023-03-01 preview: CLI and relays implementing the new XFTP protocol are released - you can use them now! image: images/20230301-xftp.jpg +imageWide: true permalink: "/blog/20230301-simplex-file-transfer-protocol.html" --- 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 93572dba33..0a66124934 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 @@ -63,7 +63,7 @@ To accelerate product development and growth we will be raising a seed funding t ### Send videos and files up to 1gb! - + In the beginning of March [we released servers and command-line utility to send and receive files via XFTP protocol](./20230301-simplex-file-transfer-protocol.md) - a very private and secure protocol that sends end-to-end encrypted files in chunks, protecting meta-data better than any alternatives we know of. @@ -88,7 +88,7 @@ Now you can choose whether to use faster and more convenient system biometric au ### Networking improvements - + Two small improvements to the app networking capabilities were added in this version. diff --git a/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md b/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md index e898763e1b..0cdbe2831f 100644 --- a/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md +++ b/blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md @@ -36,7 +36,7 @@ Also, we added Japanese and Portuguese (Brazil)* interface languages, ## Message reactions - + No idea why it took us so long to add them – finally we have them, and they are great. @@ -50,7 +50,7 @@ The next app version will allow prohibiting the reactions per conversation, as y ### Voice messages: up to 5 minutes, better quality, playback control - + Since [v4.3](./20221206-simplex-chat-v4.3-voice-messages.md#instant-voice-messages) voice messages were sent in small 16kb chunks, so we had to limit them to 30-40 seconds for better user experience, as sending larger files would require the sender to be online. @@ -66,7 +66,7 @@ This version allows to configure the time for messages to disappear more granula ### Message editing history - + I [wrote previously](./20221206-simplex-chat-v4.3-voice-messages.md#irreversible-message-deletion) why we decided to require the recipient concent before the messages can be fully deleted by the sender - in short, it is to support recipient's data sovereignty and prevent the possibility of offensive messages being removed without any trace. By default, when the sender deletes the message it is marked as deleted, rather than fully deleted, and you can reveal the original message. @@ -74,7 +74,7 @@ You've found the workaround for it of course - it's enough to simply edit the me ## Customize and share color themes - + Android app now allows choosing between three color themes - Light, Dark and SimpleX (a dark blue theme). You can customize any theme by setting 9 different colors used in the app, including titles, menus, accent colors and colors for sent and received messages. @@ -82,7 +82,7 @@ You can share your theme with other users by exporting it to a file and sending ## Self-destruct passcode - + This is something many of you asked before - when asked to enter the app passcode under duress, to be able to enter a special self-destruct code that would remove the app data. This feature is offered in many security tools, and now you can configure it in SimpleX Chat as well. diff --git a/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md b/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md index 42c08245f8..759587821b 100644 --- a/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md +++ b/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md @@ -38,7 +38,7 @@ permalink: "/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html" ### Message delivery receipts - + Most messaging apps add two ticks to sent messages – the first one to show that the message is accepted by the server, and the second – that it is delivered to the recipient's device. It confirms that the network is functioning, and that the message is not lost or delayed. SimpleX Chat now has this feature too! @@ -48,7 +48,7 @@ To avoid compromising your privacy, sending delivery receipts is disabled for al ### Filter favorite and unread chats - + You can now mark your contacts and groups as _favorite_, to be able to find them faster. With filter enabled, you will only see favorite chats, chats that contain unread messages and also any unaccepted group invitations and contact requests. @@ -58,13 +58,13 @@ Active SimpleX Chat users know how broken the current group experience is, and t #### What is this in reply to? - + A major problem is that you can see replies to the messages you've not seen before - this would happen both when you just join the group, and didn't connect to most other members, and also when other new members join the group and they didn't yet connect to you – so literally all the time, and the bigger the group gets, the worse it becomes. While this problem cannot be solved without major group protocol changes, at least there is now ability to see the original message that was replied to via the message information. #### How to connect to this member? - + To simplify direct connections with other group members, you can now share your SimpleX address via your chat profile, and group members can send you a contact request even if the group does not allow direct messages. 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 ba076295c0..8928e9724d 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 @@ -2,14 +2,132 @@ layout: layouts/article.html title: "SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups with directory service" date: 2023-09-25 -# image: images/20230925-desktop-app.png -# previewBody: blog_previews/20230722.html +image: images/simplex-desktop-light.png +imageWide: true +previewBody: blog_previews/20230925.html permalink: "/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html" -draft: true --- # SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups **Published:** September 25, 2023 -This is a placeholder for the release announcement +**What's new in v5.3:** +- [new desktop app!](#multiplatform-desktop-app)! +- [directory service and other group improvements](#group-directory-service-and-other-group-improvements). +- [encrypted local files and media with forward secrecy](#encrypted-local-files-and-media-with-forward-secrecy). +- [simplified incognito mode](#simplified-incognito-mode). + +There are a lot of other improvements and fixes in this release: +- improved app responsiveness and stability. +- app memory usage is reduced by 40%. +- new privacy settings: show last messages & save draft. +- fixes: + - bug preventing group members connecting (it will only help the new connections). + - playing videos on full screen**. + - screen reader for messages**. + - reduced background crashes**. + +Also, we added 6 new interface languages: Arabic*, Bulgarian, Finnish, Hebrew*, Thai and Ukrainian - thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat). + +\* Android app. + +\*\* iOS app. + +## Multiplatform desktop app + + + +Thanks a lot to everybody who was testing the desktop app since July – it really helped to make it stable! + +To use desktop app you need to **create a new profile**. As SimpleX platform has no user accounts, it's not as simple as for centralized apps to access the same profile from two devices. + +The next app version will allow using your mobile profile from desktop app. For now, as a workaround, you can join groups from both mobile and desktop devices, and use small groups instead of direct conversations. + +When you start the app first time, you will be offered to **set database passphrase** – you have to memorize it, as there is no way to recover it. If you skip it, a random passphrase will be generated and stored on your desktop device as plaintext (unencrypted) – you can change it later. + +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. + +## Group directory service and other group improvements + + + +Directory service provides a way to search for public groups submitted by the users. To use it, you need to connect to it via SimpleX Chat, as you would connect to any other contact, and type some words to search. + +You can also create and register your group, with some limitations explained [here](../docs/DIRECTORY.md). + +Other group improvements in this release: + +- you can send delivery receipts to the groups up to 20 members. + +- if the group settings allow it, you can send direct messages to group members even after you deleted the contact. + +- connections between members are made faster, and the bug that prevented the connections in some cases is fixed in this release. + +The next release will reduce the time it takes to send messages to the group, especially when there are many members or when you have a slow device storage. + +## Encrypted local files and media with forward secrecy + + + +All messages, files and media sent via SimpleX Chat were always end-to-end encrypted from the very beginning. SimpleX Chat uses double-ratchet algorithm with encrypted message headers, for the best possible meta-data protection. + +You contacts, groups and messages are stored in the local database on your device, and this database was encrypted from [v4.0 released a year ago](./20220928-simplex-chat-v4-encrypted-database.md). + +But until this version all files and media in the app storage were not encrypted, and when you exported the chat archive, they were unencrypted there as well. + +From v5.3 all files and media (except videos, for now) are encrypted with a random symmetric key - in many cases they are encrypted before they are written to the storage. Local file encryption can be disabled via Privacy & Security settings, for example, if you need to access the files from the storage outside of the app. + +In addition to the videos that are stored unencrypted, there are other rare scenarios when the received files may be unencrypted in this release. Files have an open or closed lock icons to indicate whether they were encrypted locally. These limitations will be addressed in the next release. In any case, all files and media are always sent end-to-end encrypted, without any exceptions. + +The keys used to encrypt files locally are associated with the messages and stored in the encrypted database. If you delete a message with the attached file or media, the key will be irreversibly deleted as well. Even if an attacker gains access to your database passphrase later and to the copy of the encrypted file, they won't be able to decrypt the file. + +This approach provides forward secrecy for locally stored files, unlike file encryption schemes used in some other apps when the same passphrase is used for all files. + +## Simplified incognito mode + + + +Incognito mode was [added a year ago](./20220901-simplex-chat-v3.2-incognito-mode.md) to improve anonymity of your profile, but it was confusing for some users - it was a global setting, but it only affected the new connections. + +It is now simpler to use - you can decide whether to connect to a contact or join a group using your main profile at a point when you create an invitation link or connect via a link or QR code. + +When you are connecting to people your know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile. + +## SimpleX platform + +Some links to answer the most common questions: + +[SimpleX Chat security assessment](./20221108-simplex-chat-v4.2-security-audit-new-website.md). + +[How can SimpleX deliver messages without user identifiers](https://simplex.chat/#how-simplex-works). + +[What are the risks to have identifiers assigned to the users](https://simplex.chat/#why-ids-bad-for-privacy). + +[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations). + +[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions). + +Visit our [website](https://simplex.chat) to learn more. + +## Help us with donations + +Huge thank you to everybody who donated to SimpleX Chat! + +We are prioritizing users privacy and security - it would be impossible without your support. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us. + +See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate. + +Thank you, + +Evgeny + +SimpleX Chat founder diff --git a/blog/images/20230925-directory.png b/blog/images/20230925-directory.png new file mode 100644 index 0000000000..d5a2f26683 Binary files /dev/null and b/blog/images/20230925-directory.png differ diff --git a/blog/images/20230925-encrypted.png b/blog/images/20230925-encrypted.png new file mode 100644 index 0000000000..0c482264bc Binary files /dev/null and b/blog/images/20230925-encrypted.png differ diff --git a/blog/images/20230925-incognito.png b/blog/images/20230925-incognito.png new file mode 100644 index 0000000000..949f36e747 Binary files /dev/null and b/blog/images/20230925-incognito.png differ diff --git a/blog/images/simplex-desktop-light.png b/blog/images/simplex-desktop-light.png new file mode 100644 index 0000000000..d28b7b88ca Binary files /dev/null and b/blog/images/simplex-desktop-light.png differ diff --git a/cabal.project b/cabal.project index b4024f088c..af664652db 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + tag: ec1b72cb8013a65a5d9783104a47ae44f5730089 source-repository-package type: git diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index dc18bebb02..0aa09c5166 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -38,9 +38,15 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t - `master` - branch for beta version releases (GHC 9.6.2). -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7). +- `master-ghc8107` - branch for beta version releases (GHC 8.10.7). -- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7) – this branch should be the same as `master-android` except Nix configuration files. +- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107` + +- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7). + +- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7). + +`master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files. **In simplexmq repo** @@ -54,24 +60,30 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t 2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch. -3. To build Android core library: -- merge `master` branch to `master-android` branch. +3. To build core libraries for Android, iOS and windows: +- merge `master` branch to `master-ghc8107` branch. +- update `simplexmq` commit in `master-ghc8107` branch to the commit in `master-ghc8107` branch (probably, when resolving merge conflicts). - update code to be compatible with GHC 8.10.7 (see below). -- update `simplexmq` commit in `master-android` branch to the commit in `master-ghc8107` branch. - push to GitHub. -4. To build iOS core library, merge `master-android` branch to `master-ios` branch, and push to GitHub. +4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub. -5. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. +5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` branch, and push to GitHub. -6. After the public release to App Store and Play Store, merge: +6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub. + +7. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. + +8. After the public release to App Store and Play Store, merge: - `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `master-ios` +- `master` to `master-ghc8107` (and compile/update code) +- `master-ghc8107` to `master-android` +- `master-ghc8107` to `master-ios` +- `master-ghc8107` to `windows-ghc8107` - `master-android` to `stable-android` - `master-ios` to `stable-ios` -7. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. +9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. ## Differences between GHC 8.10.7 and GHC 9.6.2 diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 83fa18f97d..64b76e7fec 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,13 +1,15 @@ --- title: Download SimpleX apps permalink: /downloads/index.html -revision: 20.09.2023 +revision: 01.10.2023 --- -| Updated 20.09.2023 | Languages: EN | +| Updated 01.10.2023 | Languages: EN | # Download SimpleX apps -The latest version is v5.3. +The latest stable version is v5.3.1. + +You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). - [desktop](#desktop-app) - [mobile](#mobile-apps) @@ -19,24 +21,24 @@ The latest version is v5.3. Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: coming soon. +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). ## Mobile apps -**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.9). +**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64). diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 5a31b3e058..cf33df1ee7 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -15,31 +15,30 @@ We want to add up to 3 people to the team. ## Who we are looking for -### Systems Haskell engineer +### Application Haskell engineer -You are a servers/network/Haskell expert: -- network libraries. +You are an expert in language models, databases and Haskell: +- expert knowledge of SQL. - exception handling, concurrency, STM. - type systems - we use ad hoc dependent types a lot. -- strictness. -- has some expertise in network protocols, cryptography and general information security principles and approaches. +- experience integrating open-source language models. +- experience developing community-centric applications. - interested to build the next generation of messaging network. -You will be focussed mostly on our servers code, and will also contribute to the core client code written in Haskell. +You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell. +### iOS / Mac engineer -### Product engineer (iOS) - -You are a product UX expert who designs great user experiences directly in iOS code: -- iOS and Mac platforms, including: - - SwiftUI and UIKit. - - extensions, including notification service extension and sharing extension. - - low level inter-process communication primitives for concurrency. +You are an expert in Apple platforms, including: +- iOS and Mac platform architecture. +- Swift and Objective-C. +- SwiftUI and UIKit. +- extensions, including notification service extension and sharing extension. +- low level inter-process communication primitives for concurrency. - interested about creating the next generation of UX for a communication/social network. Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps. - ## About you - **Passionate about joining SimpleX Chat team**: diff --git a/package.yaml b/package.yaml index abc30115ee..b80a587b2e 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.3.0.10 +version: 5.4.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/android/download-libs.sh b/scripts/android/download-libs.sh index f67cc397a7..4702f03600 100755 --- a/scripts/android/download-libs.sh +++ b/scripts/android/download-libs.sh @@ -7,7 +7,7 @@ function readlink() { } if [ -z "${1}" ]; then - echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download_libs.sh https://something.com/job/something/{master,stable}" + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-libs.sh https://something.com/job/something/{master,stable}" exit 1 fi diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 50bc09df57..303e331549 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + OS=mac ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" GHC_VERSION=9.6.2 @@ -18,7 +20,7 @@ rm -rf $BUILD_DIR cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" cd $BUILD_DIR/build -mkdir deps 2> /dev/null +mkdir deps 2> /dev/null || true # It's not included by default for some reason. Compiled lib tries to find system one but it's not always available #cp $GHC_LIBS_DIR/libffi.dylib ./deps @@ -54,7 +56,7 @@ function copy_deps() { cp $LIB ./deps if [[ "$NON_FINAL_RPATHS" == *"@loader_path/.."* ]]; then - # Need to point the lib to @loader_path instead + # Need to point the lib to @loader_path instead install_name_tool -add_rpath @loader_path ./deps/`basename $LIB` fi #echo LIB $LIB @@ -79,13 +81,6 @@ copy_deps $LIB cp $(ghc --print-libdir)/$ARCH-osx-ghc-$GHC_VERSION/libHSghc-boot-th-$GHC_VERSION-ghc$GHC_VERSION.dylib deps rm deps/`basename $LIB` -if [ -e deps/libHSdrct-*.$LIB_EXT ]; then - LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) - install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT - cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT - chmod 755 deps/libcrypto.1.1.$LIB_EXT -fi - cd - rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ @@ -95,4 +90,39 @@ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ + +cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ + +LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT +cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT +chmod 755 deps/libcrypto.1.1.$LIB_EXT +install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT +install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT + +LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB +fi + +LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$LIB_EXT +fi + +for lib in $(find . -type f -name "*.$LIB_EXT"); do + RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11` + for RPATH in $RPATHS; do + install_name_tool -delete_rpath $RPATH $lib + done +done + +LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib || true; done` +if [ -n "$LOCAL_DIRS" ]; then + echo These libs still point to local directories: + echo $LOCAL_DIRS + exit 1 +fi + +cd - scripts/desktop/prepare-vlc-mac.sh diff --git a/scripts/desktop/build-lib-windows.sh b/scripts/desktop/build-lib-windows.sh new file mode 100755 index 0000000000..658324baa8 --- /dev/null +++ b/scripts/desktop/build-lib-windows.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" + +OS=windows +ARCH=`uname -a | rev | cut -d' ' -f2 | rev` +JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL} + +cd $root_dir + +rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/build/cmake + +mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +scripts/desktop/download-lib-windows.sh $JOB_REPO +scripts/desktop/prepare-vlc-windows.sh diff --git a/scripts/desktop/download-lib-windows.sh b/scripts/desktop/download-lib-windows.sh new file mode 100644 index 0000000000..14439274c0 --- /dev/null +++ b/scripts/desktop/download-lib-windows.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} + +if [ -z "${1}" ]; then + echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}" + exit 1 +fi + +job_repo=$1 +arch=x86_64 +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/" + +mkdir -p "$output_dir"/deps 2> /dev/null + +curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \ +$WINDIR\\System32\\tar.exe -xf libsimplex.zip && \ +mv libsimplex.dll "$output_dir" && \ +mv libcrypto*.dll "$output_dir/deps" && \ +mv libffi*.dll "$output_dir/deps" && \ +mv libgmp*.dll "$output_dir/deps" && \ +rm libsimplex.zip diff --git a/scripts/desktop/prepare-vlc-windows.sh b/scripts/desktop/prepare-vlc-windows.sh new file mode 100644 index 0000000000..bdb492344a --- /dev/null +++ b/scripts/desktop/prepare-vlc-windows.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc +rm -rf $vlc_dir +mkdir -p $vlc_dir/vlc || exit 0 + +cd /tmp +mkdir tmp 2>/dev/null || true +cd tmp +curl https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc +$WINDIR\\System32\\tar.exe -xf vlc +cd vlc-* +# Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy +find plugins | grep ".dll" | xargs touch -m -d "1970-01-01T00:00:00Z" +./vlc-cache-gen plugins +cp *.dll $vlc_dir/ +cp -r -p plugins/ $vlc_dir/vlc/plugins +cd ../../ +rm -rf tmp diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 26f4ea1122..b6ca36e313 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; + "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 64806c8260..7361d5d168 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.3.0.10 +version: 5.4.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -114,6 +114,7 @@ library Simplex.Chat.Migrations.M20230903_connections_to_subscribe Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes + Simplex.Chat.Migrations.M20230926_contact_status Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e74eaa0f5c..5b56c0ac84 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -19,7 +19,6 @@ module Simplex.Chat where import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except @@ -356,11 +355,6 @@ execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processCha parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -toView :: ChatMonad' m => ChatResponse -> m () -toView event = do - q <- asks outputQ - atomically $ writeTBQueue q (Nothing, event) - processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser @@ -897,14 +891,16 @@ processChatCommand = \case liftIO $ updateGroupUnreadChat db user groupInfo unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of + APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) withChatLock "deleteChat direct" . procCmd $ do - fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) - deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds + deleteFilesAndConns user filesInfo + when (isReady ct && contactActive ct && notify) $ + void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) + contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) + deleteAgentConnectionsAsync user contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct @@ -1327,7 +1323,7 @@ processChatCommand = \case ConnectSimplex incognito -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user incognito adminContactReq - DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect + DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> CRContactsList user <$> withStore' (`getUserContacts` user) @@ -1422,7 +1418,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && directOrUsed ct) contacts + let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1590,7 +1586,7 @@ processChatCommand = \case processChatCommand $ APILeaveGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName - processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) + processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) True ClearGroup gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIClearChat (ChatRef CTGroup groupId) @@ -1972,7 +1968,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && not (contactConnIncognito ct)) + filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -2558,7 +2554,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts - let connIds = map contactConnId cts + let connIds = map contactConnId (filter contactActive cts) pure (connIds, M.fromList $ zip connIds cts) getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do @@ -2568,7 +2564,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns = do gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups - let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) ms) gs + let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs pure (gs, map fst mPairs, M.fromList mPairs) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns = do @@ -3034,6 +3030,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta XInfo p -> xInfo ct' p + XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta XInfoProbe probe -> xInfoProbe (CGMContact ct') probe XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash @@ -4238,6 +4235,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xInfo :: Contact -> Profile -> m () xInfo c p' = void $ processContactProfileUpdate c p' True + xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m () + xDirectDel c msg msgMeta = + if directOrUsed c + then do + checkIntegrityCreateItem (CDDirectRcv c) msgMeta + ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted + contactConns <- withStore $ \db -> getContactConnections db userId ct' + deleteAgentConnectionsAsync user $ map aConnId contactConns + forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact + ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci) + toView $ CRContactDeletedByContact user ct'' + else do + contactConns <- withStore $ \db -> getContactConnections db userId c + deleteAgentConnectionsAsync user $ map aConnId contactConns + withStore' $ \db -> deleteContact db user c + processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate c@Contact {profile = p} p' createItems | fromLocalProfile p /= p' = do @@ -4921,8 +4936,9 @@ deleteOrUpdateMemberRecord user@User {userId} member = Nothing -> deleteGroupMember db user member sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) -sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent +sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent | connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct + | contactStatus /= CSActive = throwChatError $ CEContactNotActive ct | connDisabled conn = throwChatError $ CEContactDisabled ct | otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId) @@ -5325,33 +5341,6 @@ withAgent action = >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) -withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a -withStore' action = withStore $ liftIO . action - -withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a -withStore = withStoreCtx Nothing - -withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a -withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action - -withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do - ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal "" - where - handleInternal :: String -> E.SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr - chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5411,7 +5400,7 @@ chatCommandP = "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_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), + "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)), "/_clear chat " *> (APIClearChat <$> chatRefP), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index f8fa0d152a..e0de971bda 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -21,7 +21,7 @@ import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -42,9 +42,9 @@ archiveFilesFolder = "simplex_v1_files" exportArchive :: ChatMonad m => ArchiveConfig -> m () exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = withTempDir cfg "simplex-chat." $ \dir -> do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - copyFile chatDb $ dir archiveChatDbFile - copyFile agentDb $ dir archiveAgentDbFile + StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + copyFile (dbFilePath chatStore) $ dir archiveChatDbFile + copyFile (dbFilePath agentStore) $ dir archiveAgentDbFile forM_ filesPath $ \fp -> copyDirectoryFiles fp $ dir archiveFilesFolder let method = if disableCompression == Just True then Z.Store else Z.Deflate @@ -54,11 +54,11 @@ importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] importArchive cfg@ArchiveConfig {archivePath} = withTempDir cfg "simplex-chat." $ \dir -> do Z.withArchive archivePath $ Z.unpackInto dir - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - backup chatDb - backup agentDb - copyFile (dir archiveChatDbFile) chatDb - copyFile (dir archiveAgentDbFile) agentDb + fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + backup `withDBs` fs + copyFile (dir archiveChatDbFile) $ dbFilePath chatStore + copyFile (dir archiveAgentDbFile) $ dbFilePath agentStore copyFiles dir filesPath `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] where @@ -94,53 +94,60 @@ copyDirectoryFiles fromDir toDir = do deleteStorage :: ChatMonad m => m () deleteStorage = do - StorageFiles {chatDb, agentDb, filesPath} <- storageFiles - removeFile chatDb - removeFile agentDb - mapM_ removePathForcibly filesPath - tmpPath <- readTVarIO =<< asks tempDirectory - mapM_ removePathForcibly tmpPath + fs <- storageFiles + liftIO $ closeSQLiteStore `withStores` fs + remove `withDBs` fs + mapM_ removeDir $ filesPath fs + mapM_ removeDir =<< chatReadVar tempDirectory + where + remove f = whenM (doesFileExist f) $ removeFile f + removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d data StorageFiles = StorageFiles - { chatDb :: FilePath, - chatEncrypted :: TVar Bool, - agentDb :: FilePath, - agentEncrypted :: TVar Bool, + { chatStore :: SQLiteStore, + agentStore :: SQLiteStore, filesPath :: Maybe FilePath } storageFiles :: ChatMonad m => m StorageFiles storageFiles = do ChatController {chatStore, filesFolder, smpAgent} <- ask - let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore - SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent + let agentStore = agentClientStore smpAgent filesPath <- readTVarIO filesFolder - pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} + pure StorageFiles {chatStore, agentStore, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = when (key /= key') $ do - fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles - checkFile `with` fs - backup `with` fs - (export chatDb chatEncrypted >> export agentDb agentEncrypted) - `catchChatError` \e -> (restore `with` fs) >> throwError e + fs <- storageFiles + checkFile `withDBs` fs + backup `withDBs` fs + checkEncryption `withStores` fs + removeExported `withDBs` fs + export `withDBs` fs + -- closing after encryption prevents closing in case wrong encryption key was passed + liftIO $ closeSQLiteStore `withStores` fs + (moveExported `withStores` fs) + `catchChatError` \e -> (restore `withDBs` fs) >> throwError e where - action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - export f dbEnc = do - enc <- readTVarIO dbEnc + checkEncryption SQLiteStore {dbEncrypted} = do + enc <- readTVarIO dbEncrypted when (enc && null key) $ throwDBError DBErrorEncrypted when (not enc && not (null key)) $ throwDBError DBErrorPlaintext - withDB (`SQL.exec` exportSQL) DBErrorExport - renameFile (f <> ".exported") f - withDB (`SQL.exec` testSQL) DBErrorOpen - atomically $ writeTVar dbEnc $ not (null key') + exported = (<> ".exported") + removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) + moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do + renameFile (exported f) f + atomically $ writeTVar dbEncrypted $ not (null key') + export f = do + withDB f (`SQL.exec` exportSQL) DBErrorExport + withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen where - withDB a err = - liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) + withDB f' a err = + liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing) `catch` checkSQLError `catch` (\(e :: SomeException) -> sqliteError' e) >>= mapM_ (throwDBError . err) @@ -162,7 +169,12 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D keySQL key' <> [ "PRAGMA foreign_keys = ON;", "PRAGMA secure_delete = ON;", - "PRAGMA auto_vacuum = FULL;", "SELECT count(*) FROM sqlite_master;" ] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + +withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b +action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) + +withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b +action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2c829e4a95..15c06cba94 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,7 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol -import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) @@ -54,8 +54,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) +import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -66,7 +67,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId, import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) import Simplex.Messaging.Version import System.IO (Handle) import System.Mem.Weak (Weak) @@ -248,7 +249,7 @@ data ChatCommand | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool - | APIDeleteChat ChatRef + | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats | APIClearChat ChatRef | APIAcceptContact IncognitoEnabled Int64 | APIRejectContact Int64 @@ -491,6 +492,7 @@ data ChatResponse | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} + | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} | CRUserContactLinkDeleted {user :: User} @@ -887,6 +889,7 @@ data ChatErrorType | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotReady {contact :: Contact} + | CEContactNotActive {contact :: Contact} | CEContactDisabled {contact :: Contact} | CEConnectionDisabled {connection :: Connection} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} @@ -967,6 +970,15 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase +data ArchiveError + = AEImport {chatError :: ChatError} + | AEImportFile {file :: String, chatError :: ChatError} + deriving (Show, Exception, Generic) + +instance ToJSON ArchiveError where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" + type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m) @@ -1006,11 +1018,34 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) where unset a' = if a == a' then ActiveNone else a' -data ArchiveError - = AEImport {chatError :: ChatError} - | AEImportFile {file :: String, chatError :: ChatError} - deriving (Show, Exception, Generic) +toView :: ChatMonad' m => ChatResponse -> m () +toView event = do + q <- asks outputQ + atomically $ writeTBQueue q (Nothing, event) -instance ToJSON ArchiveError where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE" +withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a +withStore' action = withStore $ liftIO . action + +withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a +withStore = withStoreCtx Nothing + +withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a +withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action + +withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a +withStoreCtx ctx_ action = do + ChatController {chatStore} <- ask + liftEitherError ChatErrorStore $ case ctx_ of + Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + -- uncomment to debug store performance + -- Just ctx -> do + -- t1 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx + -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") + -- t2 <- liftIO getCurrentTime + -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) + -- pure r + Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" + where + handleInternal :: String -> SomeException -> IO (Either StoreError a) + handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index df22c2684c..9abc8e4644 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -132,6 +132,7 @@ data CIContent (d :: MsgDirection) where CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd + CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv @@ -179,6 +180,7 @@ ciRequiresAttention content = case msgDirection @d of CIRcvIntegrityError _ -> True CIRcvDecryptionError {} -> True CIRcvGroupInvitation {} -> True + CIRcvDirectEvent _ -> False CIRcvGroupEvent rge -> case rge of RGEMemberAdded {} -> False RGEMemberConnected -> False @@ -300,6 +302,27 @@ instance ToJSON DBSndConnEvent where toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v +data RcvDirectEvent = + -- RDEProfileChanged {...} + RDEContactDeleted + deriving (Show, Generic) + +instance FromJSON RcvDirectEvent where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE" + +instance ToJSON RcvDirectEvent where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE" + +newtype DBRcvDirectEvent = RDE RcvDirectEvent + +instance FromJSON DBRcvDirectEvent where + parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v + +instance ToJSON DBRcvDirectEvent where + toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v + toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v + newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where @@ -348,6 +371,7 @@ ciContentToText = \case CIRcvDecryptionError err n -> msgDecryptErrorText err n CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole + CIRcvDirectEvent event -> rcvDirectEventToText event CIRcvGroupEvent event -> rcvGroupEventToText event CISndGroupEvent event -> sndGroupEventToText event CIRcvConnEvent event -> rcvConnEventToText event @@ -368,6 +392,10 @@ ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) +rcvDirectEventToText :: RcvDirectEvent -> Text +rcvDirectEventToText = \case + RDEContactDeleted -> "contact deleted" + rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText = \case RGEMemberAdded _ p -> "added " <> profileToText p @@ -486,6 +514,7 @@ data JSONCIContent | JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent} | JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent} | JCISndGroupEvent {sndGroupEvent :: SndGroupEvent} | JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent} @@ -522,6 +551,7 @@ jsonCIContent = \case CIRcvDecryptionError err n -> JCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent} CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent} CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent} CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent} @@ -550,6 +580,7 @@ aciContentJSON = \case JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent @@ -579,6 +610,7 @@ data DBJSONCIContent | DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} + | DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent} | DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent} | DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent} | DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent} @@ -615,6 +647,7 @@ dbJsonCIContent = \case CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} + CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce @@ -643,6 +676,7 @@ aciContentDBJSON = \case DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole + DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce diff --git a/src/Simplex/Chat/Migrations/M20230926_contact_status.hs b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs new file mode 100644 index 0000000000..b6c5dd9557 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230926_contact_status.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230926_contact_status where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230926_contact_status :: Query +m20230926_contact_status = + [sql| +ALTER TABLE contacts ADD COLUMN contact_status TEXT NOT NULL DEFAULT 'active'; +|] + +down_m20230926_contact_status :: Query +down_m20230926_contact_status = + [sql| +ALTER TABLE contacts DROP COLUMN contact_status; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 141247e590..65ceb7d19b 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -71,6 +71,7 @@ CREATE TABLE contacts( contact_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0, + contact_status TEXT NOT NULL DEFAULT 'active', FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 6e725e6c26..bbdddf8ce0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -215,6 +215,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json + XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json @@ -550,6 +551,7 @@ data CMEventTag (e :: MsgEncoding) where XFileCancel_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json + XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json @@ -596,6 +598,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" + XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" XGrpMemNew_ -> "x.grp.mem.new" @@ -643,6 +646,7 @@ instance StrEncoding ACMEventTag where "x.file.cancel" -> XFileCancel_ "x.info" -> XInfo_ "x.contact" -> XContact_ + "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ "x.grp.mem.new" -> XGrpMemNew_ @@ -686,6 +690,7 @@ toCMEventTag msg = case msg of XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ + XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ XGrpMemNew _ -> XGrpMemNew_ @@ -782,6 +787,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XFileCancel_ -> XFileCancel <$> p "msgId" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" + XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" @@ -839,6 +845,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] + XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 7da0d1ca85..93f3349ca2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -71,19 +71,19 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT - c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent FROM contacts c JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact - toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = ExceptT $ do diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7e8cee0e74..886c735053 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Direct deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, + updateContactStatus, updateGroupUnreadChat, setConnectionVerified, incConnectionAuthErrCounter, @@ -147,7 +148,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -206,7 +207,7 @@ createDirectContact db user@User {userId} activeConn@Connection {connId, localAl let profile = toLocalProfile profileId p localAlias userPreferences = emptyChatPrefs mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles db userId Contact {contactId} = do @@ -387,6 +388,19 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do updatedAt <- getCurrentTime DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) +updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact +updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE contacts + SET contact_status = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (contactStatus, currentTs, userId, contactId) + pure ct {contactStatus} + updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updatedAt <- getCurrentTime @@ -491,7 +505,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -637,7 +651,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences} contactId <- insertedRowId db activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} + pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName db User {userId} cName = @@ -655,7 +669,7 @@ getContact_ db user@User {userId} contactId deleted = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 6656208aba..e72ca8e8ca 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -700,7 +700,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} = [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, @@ -1044,7 +1044,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = db [sql| SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, @@ -1062,13 +1062,13 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = + toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact + toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} activeConn = toConnection connRow mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1160,8 +1160,8 @@ getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact] getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where -- this query is different from one in getMatchingMemberContacts @@ -1172,7 +1172,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? AND ct.contact_id != ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] @@ -1521,7 +1521,7 @@ createMemberContact connId <- insertedRowId db let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db user contactId = do @@ -1558,7 +1558,7 @@ createMemberContactInvited contactId <- createContactUpdateMember currentTs userPreferences ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} + mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} m' = m {memberContactId = Just contactId} pure (mCt', m') where @@ -1586,8 +1586,9 @@ updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> Gr updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do updateConnectionStatus db oldContactConn ConnDeleted activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode - ct' <- resetMemberContactFields db ct - pure (ct' :: Contact) {activeConn} + ct' <- updateContactStatus db user ct CSActive + ct'' <- resetMemberContactFields db ct' + pure (ct'' :: Contact) {activeConn} resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields db ct@Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index c08e6b11d3..458944b6e5 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -478,7 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index d8bab817e3..2f5bfec9ea 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes +import Simplex.Chat.Migrations.M20230926_contact_status import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -161,7 +162,8 @@ schemaMigrations = ("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), - ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes) + ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index e979c90067..4dc4f6e82d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -241,24 +241,24 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact -toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact -toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = +toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} + in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} _ -> Left $ SEContactNotReady localDisplayName getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 93964316cc..43265671b6 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -169,6 +169,7 @@ data Contact = Contact activeConn :: Connection, viaGroup :: Maybe Int64, contactUsed :: Bool, + contactStatus :: ContactStatus, chatSettings :: ChatSettings, userPreferences :: Preferences, mergedPreferences :: ContactUserPreferences, @@ -185,7 +186,7 @@ instance ToJSON Contact where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} contactConn :: Contact -> Connection -contactConn Contact{activeConn} = activeConn +contactConn Contact {activeConn} = activeConn contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -205,9 +206,34 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactActive :: Contact -> Bool +contactActive Contact {contactStatus} = contactStatus == CSActive + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn +data ContactStatus + = CSActive + | CSDeleted -- contact deleted by contact + deriving (Eq, Show, Ord) + +instance FromField ContactStatus where fromField = fromTextField_ textDecode + +instance ToField ContactStatus where toField = toField . textEncode + +instance ToJSON ContactStatus where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance TextEncoding ContactStatus where + textDecode = \case + "active" -> Just CSActive + "deleted" -> Just CSDeleted + _ -> Nothing + textEncode = \case + CSActive -> "active" + CSDeleted -> "deleted" + data ContactRef = ContactRef { contactId :: ContactId, connId :: Int64, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5db0c317e8..01bdfba95a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -151,6 +151,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] + CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] @@ -1567,6 +1568,7 @@ viewChatError logLevel = \case ] CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotReady c -> [ttyContact' c <> ": not ready"] + CEContactNotActive c -> [ttyContact' c <> ": not active"] CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] diff --git a/stack.yaml b/stack.yaml index 0840970e49..bce5dd3a68 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 + commit: ec1b72cb8013a65a5d9783104a47ae44f5730089 - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 3db4052226..5c4d96cc94 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -31,6 +31,7 @@ chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive message" testAddContact it "deleting contact deletes profile" testDeleteContactDeletesProfile + it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply it "direct message update" testDirectMessageUpdate it "direct message edit history" testDirectMessageEditHistory @@ -156,11 +157,12 @@ testAddContact = versionTestMatrix2 runTestAddContact -- test deleting contact alice ##> "/d bob_1" alice <## "bob_1: contact is deleted" + bob <## "alice_1 (Alice) deleted contact with you" alice ##> "@bob_1 hey" alice <## "no contact bob_1" alice @@@ [("@bob", "how are you?")] alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] + bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] bob `hasContactProfiles` ["alice", "alice", "bob"] -- test clearing chat alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") @@ -202,6 +204,7 @@ testDeleteContactDeletesProfile = -- alice deletes contact, profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "/_contacts 1" (alice FilePath -> IO () +testDeleteUnusedContactSilent = + testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + bob `hasContactProfiles` ["bob", "alice", "cath"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + -- bob deletes cath, cath's bob contact is deleted silently + bob ##> "/d cath" + bob <## "cath: contact is deleted" + bob ##> "/contacts" + bob <## "alice (Alice)" + threadDelay 50000 + cath ##> "/contacts" + cath <## "alice (Alice)" + -- group messages work + alice #> "#team hello" + concurrentlyN_ + [ bob <# "#team alice> hello", + cath <# "#team alice> hello" + ] + bob #> "#team hi there" + concurrentlyN_ + [ alice <# "#team bob> hi there", + cath <# "#team bob> hi there" + ] + cath #> "#team hey" + concurrentlyN_ + [ alice <# "#team cath> hey", + bob <# "#team cath> hey" + ] + testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () testDirectMessageQuotedReply = testChat2 aliceProfile bobProfile $ @@ -514,7 +553,7 @@ testRepeatAuthErrorsDisableContact = connectUsers alice bob alice <##> bob threadDelay 500000 - bob ##> "/d alice" + bob ##> "/_delete @2 notify=off" bob <## "alice: contact is deleted" forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob" diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index f84d4dcb40..50f86d8e09 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -575,6 +575,7 @@ testSendImage = -- deleting contact without files folder set should not remove file bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True @@ -637,6 +638,7 @@ testFilesFoldersSendImage = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete = @@ -660,6 +662,7 @@ testFilesFoldersImageSndDelete = checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/fs 1" bob <##. "receiving file 1 (test_1MB.pdf) progress" -- deleting contact should remove cancelled file @@ -689,7 +692,10 @@ testFilesFoldersImageRcvDelete = checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do bob ##> "/d alice" bob <## "alice: contact is deleted" - alice <## "bob cancelled receiving file 1 (test.jpg)" + alice + <### [ "bob (Bob) deleted contact with you", + "bob cancelled receiving file 1 (test.jpg)" + ] alice ##> "/fs 1" alice <## "sending file 1 (test.jpg) cancelled: bob" alice <## "file transfer cancelled" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bf740a960f..4280810ca6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -220,6 +220,7 @@ testGroupShared alice bob cath checkMessages = do -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice `send` "@bob hey" alice <### [ "@bob hey", @@ -234,7 +235,7 @@ testGroupShared alice bob cath checkMessages = do alice <# "#team bob> received" when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] - bob @@@ [("@alice", "received invitation to join group team as admin"), ("@cath", "hey"), ("#team", "received")] + bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")] -- test clearing chat threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -629,6 +630,7 @@ testGroupDeleteInvitedContact = threadDelay 500000 alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" bob ##> "/j team" concurrently_ (alice <## "#team: bob joined the group") @@ -700,10 +702,11 @@ testDeleteGroupMemberProfileKept = -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@bob hey" alice <## "no contact bob, use @#club bob " - bob #> "@alice hey" - bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + bob ##> "@alice hey" + bob <## "alice: not ready" (alice "/d #team" @@ -2785,6 +2788,8 @@ testMemberContactMessage = -- alice and bob delete contacts, connect alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + bob ##> "/d alice" bob <## "alice: contact is deleted" @@ -2893,6 +2898,7 @@ testMemberContactInvitedConnectionReplaced tmp = do alice ##> "/d bob" alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" alice ##> "@#team bob hi" alice @@ -2910,7 +2916,7 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 1a2b74f76e..44af70a65e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -558,6 +558,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi -- alice deletes contact, incognito profile is deleted alice ##> ("/d " <> bobIncognito) alice <## (bobIncognito <> ": contact is deleted") + bob <## (aliceIncognito <> " deleted contact with you") alice ##> "/contacts" alice <## "cath (Catherine)" alice `hasContactProfiles` ["alice", "cath"] @@ -601,6 +602,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ -- delete contact, incognito profile is deleted bob ##> "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d bob" alice <## "bob: contact is deleted" + bob <## (aliceIncognitoBob <> " deleted contact with you") alice ##> "/contacts" (alice "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob "/d alice" bob <## "alice: contact is deleted" + alice <## (bobIncognito <> " deleted contact with you") bob ##> "/contacts" (bob v5.3 is released:

+ +
    +
  • new desktop app! 💻
  • +
  • encrypt locally stored files & media
  • +
  • directory service and other group improvements
  • +
  • simplified incognito mode
  • +
  • better app responsiveness, stability and 40% reduced memory usage.
  • +
  • new privacy settings: show last messages & save draft.
  • +
+ +

Also, our users added 6 new interface languages - Arabic*, Bulgarian, Finnish, Hebrew*, Thai and Ukrainian.

+ +

* Android app only.

diff --git a/website/src/blog.html b/website/src/blog.html index d0d4e6cba0..11f057ce70 100644 --- a/website/src/blog.html +++ b/website/src/blog.html @@ -44,7 +44,9 @@ active_blog: true
{% if blog.data.image %} {% if blog.data.imageBottom %} - + + {% elif blog.data.imageWide %} + {% else %} {% endif %} diff --git a/website/src/css/blog.css b/website/src/css/blog.css index e7b90b485f..adec9ab04e 100644 --- a/website/src/css/blog.css +++ b/website/src/css/blog.css @@ -188,29 +188,26 @@ h3::before { outline: none; } -p, -a, h1, h2, h3, h4, h5, -h6, -li, -ul, -ol, -span, -div, -blockquote, -pre, -code { +h6{ clear: both; } #article p img { - float: left; + display: inline-block; } #article img { - margin-bottom: 1.5rem; + margin: 0.5rem 0 1rem 0; +} + +@media (min-width: 1024px) { + #article .float-to-left{ + float: left; + margin-right: 3rem; + } } \ No newline at end of file