Compare commits

..

49 Commits

Author SHA1 Message Date
Evgeny Poberezkin ef02b27bca simplify types, make changed char have new format 2023-10-02 21:54:56 +01:00
Evgeny Poberezkin 7c73a44a51 format with ormolu 2023-10-02 20:55:36 +01:00
Evgeny Poberezkin b3e9c7f7dc Merge branch 'master' into pdavidow-show-edits-readyToMerge 2023-10-02 20:47:44 +01:00
Stanislav Dmitrenko 77db70139b windows: shortcut for installator (#3156)
Co-authored-by: avently <avently@local>
2023-10-02 17:25:49 +01:00
Stanislav Dmitrenko fdf3da73aa desktop: making chat list item to have a hover effect (#3162)
* desktop: making chat list item to have a hover effect

* changes

* fix

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-02 17:23:38 +01:00
Stanislav Dmitrenko 0d93dab692 android, desktop: added useful logs (#3163) 2023-10-02 15:46:30 +01:00
spaced4ndy d4cbef1ba1 core: notify about contact deletion only if contact is ready, catch errors (#3160) 2023-10-02 16:29:13 +04:00
Evgeny Poberezkin 8545a1e8f9 ci: make docs update rebuild website 2023-10-01 20:46:30 +01:00
Evgeny Poberezkin 157ea59ebb docs: update downloads page 2023-10-01 18:53:58 +01:00
Evgeny Poberezkin 7231201c3c v5.4-beta.0: ios 176, android 156, desktop 12
* desktop: v5.4-beta.0 build 12

* v5.4-beta.0: ios 176, android 156, desktop 12
2023-10-01 18:31:52 +01:00
Stanislav Dmitrenko 695d47da2d desktop: Windows build (#3143)
* desktop: Windows build

* temp

* temp

* new way of libs loading

* new way of libs loading

* Revert "new way of libs loading"

This reverts commit 8632f8a8f7.

* made VLC working on Windows

* unused lib

* scripts

* updated script

* fix path

* fix lib loading

* fix lib loading

* packaging options

* different file manager implementation on Windows

---------

Co-authored-by: Avently <avently.local>
Co-authored-by: avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-01 13:33:15 +01:00
Evgeny Poberezkin 968d8e9c34 core: 5.4.0.0 2023-10-01 13:19:32 +01:00
Stanislav Dmitrenko d72c9a6de0 desktop: ability to always show terminal view (#3074)
* desktop: ability to always show terminal view

* only show toggle with dev tools enabled

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-01 12:12:17 +01:00
Alexander Bondarenko 5ce388522e Move toView and withStore* to a common module (#3147) 2023-09-29 15:50:20 +01:00
Evgeny Poberezkin 70a65e8969 core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient (#3146)
* core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient

* remove file names

* do not remove files if already removed
2023-09-29 13:09:48 +01:00
Evgeny Poberezkin 1d34500fba core: revert stop/close changes made for Windows (#3145)
* Revert "core: return error response when wrong passphrase is passed to start"

This reverts commit ea319313f1.

* Revert "core: support closing/re-opening store on chat stop/start (#3132)"

This reverts commit 3c7fc6b0ee.
2023-09-29 11:14:10 +01:00
spaced4ndy bc7baf560b core: filter out connections of deleted contacts and group members on subscribe (#3144) 2023-09-29 11:24:16 +04:00
Stanislav Dmitrenko c1854b7d50 desktop: fix script for building the lib (#3141) 2023-09-28 11:39:43 +01:00
spaced4ndy 682dfe503c android, desktop: notify contact about contact deletion (#3139)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-09-28 13:52:43 +04:00
spaced4ndy 957f3b3eb0 core: delete unused contact silently (#3140) 2023-09-28 13:16:03 +04:00
Evgeny Poberezkin dea96df27b docs: update join team 2023-09-28 09:26:54 +01:00
Evgeny Poberezkin 942e5eb8c4 docs: update branches 2023-09-27 22:19:20 +01:00
Evgeny Poberezkin ea319313f1 core: return error response when wrong passphrase is passed to start 2023-09-27 21:15:19 +01:00
spaced4ndy bbe329072e ios: notify contact about contact deletion (#3135) 2023-09-27 20:07:32 +04:00
spaced4ndy c64d1e8361 core: notify contact about contact deletion (#3131) 2023-09-27 19:36:13 +04:00
Stanislav Dmitrenko 7e17ed7b1b desktop (mac): removing rpaths (#3136)
* desktop (mac): removing rpaths

* one more lib

* added check for dir existence in linking

* new line

* patching libapp on mac
2023-09-27 15:34:46 +01:00
Evgeny Poberezkin 3c7fc6b0ee core: support closing/re-opening store on chat stop/start (#3132)
* core: support closing/re-opening store on chat stop/start

* update .nix refs

* kotlin: types

* add test

* update simplexmq
2023-09-27 15:26:03 +01:00
Stanislav Dmitrenko 8709ad6ff3 desktop: enhanced video player + inline player (#3130)
* desktop: enhanced video player + inline player

* simplify

* simplify

* removed unused code

* follow up

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-09-27 10:19:48 +01:00
Evgeny Poberezkin 50d624ef6b blog: move image 2023-09-25 20:09:08 +01:00
Evgeny Poberezkin 11e448267d website: update post, downloads page 2023-09-25 18:12:47 +01:00
Evgeny Poberezkin aacf741ef5 ci: exclude -fdroid tags from github builds 2023-09-25 16:51:10 +01:00
Evgeny Poberezkin 5c0d162a1a format with ormolu 2023-09-24 23:37:09 +01:00
Evgeny Poberezkin 1df330d3c5 Merge branch 'show-edits-readyToMerge' of github.com:pdavidow/simplex-chat into pdavidow-show-edits-readyToMerge 2023-09-24 23:29:18 +01:00
pdavidow 3e182dbca5 cleanup 2023-08-19 23:59:08 -04:00
pdavidow ea8f1ee9a4 cleanup 2023-08-19 23:51:36 -04:00
pdavidow c6db756b68 rename to Diff motif 2023-08-19 23:46:11 -04:00
pdavidow faf09acf65 cleanup 2023-08-18 11:59:35 -04:00
pdavidow d005d79d54 cleanup 2023-08-17 11:35:57 -04:00
pdavidow 12a1b083c0 cleanup 2023-08-17 02:19:43 -04:00
pdavidow 0ce77987e3 cleanup 2023-08-17 00:32:44 -04:00
pdavidow 62b3044001 cleanup 2023-08-16 23:59:27 -04:00
pdavidow 827bff3cb4 cleanup 2023-08-16 23:40:24 -04:00
pdavidow f859696b05 cleanup 2023-08-16 21:28:23 -04:00
pdavidow 67bac7c7f9 cleanup 2023-08-16 11:39:04 -04:00
pdavidow bd4077f04c cleanup 2023-08-16 10:55:17 -04:00
pdavidow a9048e7270 "SimplexLink 2" "SimplexLink 3" 2023-08-16 10:25:19 -04:00
pdavidow bb8a9f4b1e "findPlainDiffs 1" 2023-08-16 10:09:42 -04:00
pdavidow 4d99921bde cleanup 2023-08-16 09:50:13 -04:00
pdavidow 32a0e6359c FINAL
need to uncomment tests
2023-08-15 22:46:16 -04:00
93 changed files with 1743 additions and 498 deletions
+1
View File
@@ -8,6 +8,7 @@ on:
- users
tags:
- "v*"
- "!*-fdroid"
pull_request:
jobs:
+1
View File
@@ -9,6 +9,7 @@ on:
- website/**
- images/**
- blog/**
- docs/**
- .github/workflows/web.yml
jobs:
+6
View File
@@ -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 {
+5 -23
View File
@@ -19,11 +19,7 @@ let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds
private func _suspendChat(timeout: Int) {
// this is a redundant check to prevent logical errors, like the one fixed in this PR
let state = appStateGroupDefault.get()
if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
} else if ChatModel.ok {
if ChatModel.ok {
appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended)
@@ -35,7 +31,9 @@ private func _suspendChat(timeout: Int) {
func suspendChat() {
suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout)
if appStateGroupDefault.get() != .stopped {
_suspendChat(timeout: appSuspendTimeout)
}
}
}
@@ -47,25 +45,15 @@ func suspendBgRefresh() {
}
}
private var terminating = false
func terminateChat() {
logger.debug("terminateChat")
suspendLockQueue.sync {
switch appStateGroupDefault.get() {
case .suspending:
// suspend instantly if already suspending
_chatSuspended()
// when apiSuspendChat is called with timeout 0, it won't send any events on suspension
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
chatCloseStore()
case .suspended:
chatCloseStore()
case .stopped:
chatCloseStore()
case .stopped: ()
default:
terminating = true
// the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout)
}
}
@@ -85,14 +73,10 @@ private func _chatSuspended() {
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop()
}
if terminating {
chatCloseStore()
}
}
func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync {
appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() }
@@ -101,7 +85,6 @@ func activateChat(appState: AppState = .active) {
}
func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared
if (!m.chatInitialized) {
do {
@@ -114,7 +97,6 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
}
func startChatAndActivate() {
terminating = false
logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start()
@@ -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 }) {
@@ -79,6 +79,7 @@ struct ChatItemContentView<Content: View>: View {
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()
+4 -3
View File
@@ -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)
@@ -65,7 +65,7 @@ struct ChatListNavLink: View {
}
Button {
AlertManager.shared.showAlert(
contact.ready
contact.ready || !contact.active
? deleteContactAlert(chat.chatInfo)
: deletePendingContactAlert(chat, contact)
)
@@ -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)
+32 -32
View File
@@ -114,11 +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 */; };
5CC739A12AD468E4009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399C2AD468E4009470A9 /* libgmpxx.a */; };
5CC739A22AD468E4009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399D2AD468E4009470A9 /* libffi.a */; };
5CC739A32AD468E4009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399E2AD468E4009470A9 /* libgmp.a */; };
5CC739A42AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */; };
5CC739A52AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a */; };
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 */; };
@@ -395,11 +395,11 @@
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5CC7399C2AD468E4009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CC7399D2AD468E4009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CC7399E2AD468E4009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a"; sourceTree = "<group>"; };
5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a"; sourceTree = "<group>"; };
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 = "<group>"; };
5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = "<group>"; };
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; };
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
@@ -507,13 +507,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5CC739A12AD468E4009470A9 /* libgmpxx.a in Frameworks */,
5CC739A32AD468E4009470A9 /* libgmp.a in Frameworks */,
5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd 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 */,
5CC739A42AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a in Frameworks */,
5CC739A22AD468E4009470A9 /* libffi.a in Frameworks */,
5CC739A52AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -574,11 +574,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5CC7399D2AD468E4009470A9 /* libffi.a */,
5CC7399E2AD468E4009470A9 /* libgmp.a */,
5CC7399C2AD468E4009470A9 /* libgmpxx.a */,
5CC7399F2AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI-ghc8.10.7.a */,
5CC739A02AD468E4009470A9 /* libHSsimplex-chat-5.3.2.0-CqvLUli0CbhHnscdGdNqYI.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 = "<group>";
@@ -1486,7 +1486,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 178;
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.2;
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 = 178;
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.2;
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 = 178;
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.2;
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 = 178;
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.2;
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 = 178;
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.2;
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 = 178;
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.2;
MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
-7
View File
@@ -50,13 +50,6 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
return result
}
public func chatCloseStore() {
let err = fromCString(chat_close_store(getChatCtrl()))
if err != "" {
logger.error("chatCloseStore error: \(err)")
}
}
public func resetChatCtrl() {
chatController = nil
migrationResult = nil
+4
View File
@@ -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)
-8
View File
@@ -80,14 +80,6 @@ public enum AppState: String {
default: return false
}
}
public var canSuspend: Bool {
switch self {
case .active: true
case .bgRefresh: true
default: false
}
}
}
public enum DBContainer: String {
+22 -1
View File
@@ -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
-1
View File
@@ -17,7 +17,6 @@ typedef void* chat_ctrl;
// the last parameter is used to return the pointer to chat controller
extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl);
extern char *chat_close_store(chat_ctrl ctl);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
@@ -91,11 +91,11 @@ class MainActivity: FragmentActivity() {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
AppLock.clearAuthState()
AppLock.laFailed.value = true
AppLock.destroyedAfterBackPress.value = true
}
if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
// Drop shared content
SimplexApp.context.chatModel.sharedContent.value = null
finish()
}
}
}
+1 -1
View File
@@ -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
@@ -69,5 +69,3 @@ actual fun hideKeyboard(view: Any?) {
(androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
}
}
actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true)
@@ -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<Boolean>,
stopped: Boolean,
selectedChat: State<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))
}
@@ -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
@@ -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
@@ -149,7 +149,7 @@ fun MainScreen() {
LaunchedEffect(Unit) {
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
// while the screen moves to a launcher. Detect it and prevent showing the auth
if (!(androidIsFinishingMainActivity() && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
AppLock.runAuthenticate()
}
}
@@ -24,6 +24,7 @@ object AppLock {
// Remember result and show it after orientation change
val laFailed = mutableStateOf(false)
val destroyedAfterBackPress = mutableStateOf(false)
fun clearAuthState() {
userAuthorized.value = null
@@ -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()
@@ -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()
@@ -14,5 +14,3 @@ expect fun LocalMultiplatformView(): Any?
@Composable
expect fun getKeyboardState(): State<KeyboardState>
expect fun hideKeyboard(view: Any?)
expect fun androidIsFinishingMainActivity(): Boolean
@@ -123,7 +123,18 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
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<TerminalItem>()
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 ->
@@ -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(
@@ -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}")
}
}
},
@@ -550,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
@@ -576,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(
@@ -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*/)
}
@@ -352,6 +352,7 @@ fun ChatItemView(
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)
@@ -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)
}
}
@@ -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<Boolean>,
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<Boolean>
)
@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) }
)
}
}
@@ -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)
@@ -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) + " " +
@@ -322,6 +322,15 @@ fun ChatLockItem(
}
}
@Composable fun TerminalAlwaysVisibleItem(pref: SharedPreference<Boolean>, 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(
@@ -1056,6 +1056,7 @@
<string name="database_downgrade">Database downgrade</string>
<string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
<string name="upgrade_and_open_chat">Upgrade and open chat</string>
<string name="downgrade_and_open_chat">Downgrade and open chat</string>
@@ -1105,6 +1106,9 @@
<string name="you_rejected_group_invitation">You rejected group invitation</string>
<string name="group_invitation_expired">Group invitation expired</string>
<!-- Direct event chat items -->
<string name="rcv_direct_event_contact_deleted">deleted contact</string>
<!-- Group event chat items -->
<string name="rcv_group_event_member_added">invited %1$s</string>
<string name="rcv_group_event_member_connected">connected</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M46-124v-90q0-33.5 15.516-55.596Q77.03-291.692 106-305.5q54-26 114.624-42.75Q281.249-365 359-365t138.376 16.75Q558-331.5 612-305.5q28.969 13.808 44.484 35.904Q672-247.5 672-214v90H46Zm57.5-57.5h511v-32.744q0-15.256-8.75-25.006t-20.65-14.792q-42.6-19.153-95.35-36.305Q437-307.5 359-307.5t-130.75 17.153q-52.75 17.152-95.35 36.305Q121-249 112.25-239.25q-8.75 9.75-8.75 25.006v32.744Zm255.456-243q-64.456 0-105.86-41.984Q211.692-508.469 211.692-572H201.5q-8 0-14-6t-6-14q0-8 6-14t14-6h10.185q0-39.154 19.158-70.077Q250-713 282.143-733v38.459q0 6.244 4.098 10.392Q290.339-680 296.5-680q7.225 0 10.862-4.138Q311-688.275 311-694.364v-51.631q7.845-1.98 22.172-3.492Q347.5-751 360-751t26.828 1.511q14.327 1.511 22.172 3.487v51.573q0 5.929 3.638 10.179Q416.275-680 423.5-680q6.161 0 10.259-4.149 4.098-4.148 4.098-10.2V-733q32.143 20 50.3 50.923 18.158 30.923 18.158 70.077H516.5q8 0 14 6t6 14q0 8-6 14t-14 6h-10.192q0 63.531-41.448 105.516Q423.411-424.5 358.956-424.5ZM359-482q42 0 66-25t24-65H269q0 40 24 65t66 25Zm300 119.5-1.885-29q-7.115-4-14.615-9t-13.5-10l-26 14-21.5-31 26-19q-2-4-2-7.5v-15q0-3.5 2-7.5l-26-19 21.5-31 26 14q7-5.5 13.97-10.312 6.969-4.813 13.939-8.688l1.906-29h39.37l1.906 29q6.97 3.875 13.939 8.688Q721-518 728-512.5l26-14 21.5 31-26 19q2 4 2 7.5v15q0 3.5-2 7.5l26 19-21.5 31-26-14q-6 5-13.5 10t-14.5 9l-1.769 29H659Zm19.5-61q16 0 27-11t11-27q0-16-11-27t-27-11q-16 0-27 11t-11 27q0 16 11 27t27 11ZM766.349-579l-8.164-33.933Q748-617 737.179-624q-10.822-7-17.554-15L676-624l-19-33 34-27.5q-2-5-3.5-11.25T686-708q0-6 1.5-12.25t3.5-11.25L657-759l19-33 43.562 15q6.938-8 17.782-15.5Q748.188-800 758-803l8.5-34h37.151l8.164 33.933Q822-800 832.821-792.5q10.822 7.5 17.554 15.5L894-792l19 33-34 27.5q2 5 3.5 11.25T884-708q0 6-1.5 12.25T879-684.5l34 27.5-19 33-43.562-15q-6.938 8-17.782 15-10.844 7-20.656 11l-8.5 34h-37.151ZM785-650q25 0 41.5-16.5T843-708q0-25-16.5-41.5T785-766q-25 0-41.5 16.5T727-708q0 25 16.5 41.5T785-650ZM103.5-181.5h511-511Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -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 {
@@ -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<ImageBitmap?>(null)
val bitmap: State<ImageBitmap?> = 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<ByteBuffer>) {
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<ByteBuffer>,
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,
)
}
@@ -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)
}
@@ -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
}
@@ -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()
@@ -17,5 +17,3 @@ actual fun LocalMultiplatformView(): Any? = null
@Composable
actual fun getKeyboardState(): State<KeyboardState> = remember { mutableStateOf(KeyboardState.Opened) }
actual fun hideKeyboard(view: Any?) {}
actual fun androidIsFinishingMainActivity(): Boolean = false
@@ -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<Long> = mutableStateOf(0L)
override val preview: MutableState<ImageBitmap> = 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<Component> = ArrayList()
private val helperPlayersPool: ArrayList<CallbackMediaPlayerComponent> = 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)
}
}
@@ -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 {
@@ -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)
}
}
}
@@ -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<Boolean>,
stopped: Boolean,
selectedChat: State<Boolean>
) {
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()
}
@@ -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<File>) -> 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"
@@ -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<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file)
+9 -5
View File
@@ -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")
}
}
}
}
@@ -191,7 +195,7 @@ afterEvaluate {
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")
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 {
@@ -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)
}
}
+4 -4
View File
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.3.2
android.version_code=157
android.version_name=5.4-beta.0
android.version_code=156
desktop.version_name=5.3.2
desktop.version_code=14
desktop.version_name=5.4-beta.0
desktop.version_code=12
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
@@ -2,7 +2,7 @@
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: /docs/images/simplex-desktop-light.png
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"
@@ -36,7 +36,7 @@ Also, we added 6 new interface languages: Arabic<sup>*</sup>, Bulgarian, Finnish
## Multiplatform desktop app
<img src="/docs/images/simplex-desktop-light.png" width="640">
<img src="./images/simplex-desktop-light.png" width="640">
Thanks a lot to everybody who was testing the desktop app since July it really helped to make it stable!
@@ -50,6 +50,8 @@ 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
<img src="./images/20230925-directory.png" width="330" class="float-to-left">
Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

+1 -1
View File
@@ -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
+23 -11
View File
@@ -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
+13 -11
View File
@@ -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).
+13 -14
View File
@@ -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**:
+2 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 5.3.2.0
version: 5.4.0.0
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
@@ -32,6 +32,7 @@ dependencies:
- http-types == 0.12.*
- memory == 0.18.*
- mtl == 2.3.*
- myers-diff >= 0.2.0.0
- network >= 3.1.2.7 && < 3.2
- optparse-applicative >= 0.15 && < 0.17
- process == 1.6.*
+1 -1
View File
@@ -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
+39 -9
View File
@@ -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
+22
View File
@@ -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
+27
View File
@@ -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
+25
View File
@@ -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
+1 -1
View File
@@ -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";
+10 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 5.3.2.0
version: 5.4.0.0
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -34,6 +34,7 @@ library
Simplex.Chat.Core
Simplex.Chat.Help
Simplex.Chat.Markdown
Simplex.Chat.MarkdownDiff
Simplex.Chat.Messages
Simplex.Chat.Messages.CIContent
Simplex.Chat.Migrations.M20220101_initial
@@ -113,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
@@ -164,6 +166,7 @@ library
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -212,6 +215,7 @@ executable simplex-bot
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -261,6 +265,7 @@ executable simplex-bot-advanced
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -312,6 +317,7 @@ executable simplex-broadcast-bot
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network >=3.1.2.7 && <3.2
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -362,6 +368,7 @@ executable simplex-chat
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
@@ -452,6 +459,7 @@ test-suite simplex-chat-test
ChatTests.Profiles
ChatTests.Utils
MarkdownTests
MarkdownDiffTests
MobileTests
ProtocolTests
SchemaDump
@@ -491,6 +499,7 @@ test-suite simplex-chat-test
, http-types ==0.12.*
, memory ==0.18.*
, mtl ==2.3.*
, myers-diff >= 0.2.0.0
, network ==3.1.*
, optparse-applicative >=0.15 && <0.17
, process ==1.6.*
+34 -45
View File
@@ -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),
+49 -37
View File
@@ -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
+46 -11
View File
@@ -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
+14 -6
View File
@@ -17,7 +17,7 @@ import qualified Data.Attoparsec.Text as A
import Data.Char (isDigit)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.List (intercalate, foldl')
import Data.List (foldl', intercalate)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe, isNothing)
@@ -51,8 +51,16 @@ data Format
| SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text}
| Email
| Phone
| Edited EditAction Format
deriving (Eq, Show, Generic)
data EditAction = EAInsert | EADelete | EAChangeFormat
deriving (Eq, Show, Generic)
instance ToJSON EditAction where
toJSON = J.genericToJSON . enumJSON $ dropPrefix "EA"
toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "EA"
data SimplexLinkType = XLContact | XLInvitation | XLGroup
deriving (Eq, Show, Generic)
@@ -129,7 +137,7 @@ parseMaybeMarkdownList s
| otherwise = Just . reverse $ foldl' acc [] ml
where
ml = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s
acc [] m = [m]
acc [] m = [m]
acc ms@(FormattedText f t : ms') ft@(FormattedText f' t')
| f == f' = FormattedText f (t <> t') : ms'
| otherwise = ft : ms
@@ -170,14 +178,14 @@ markdownP = mconcat <$> A.many' fragmentP
md :: Char -> Format -> Text -> Markdown
md c f s
| T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ c `T.cons` s `T.snoc` c
unmarked $ c `T.cons` s `T.snoc` c
| otherwise = markdown f s
secretP :: Parser Markdown
secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
secret :: Text -> Text -> Text -> Markdown
secret b s a
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
unmarked $ '#' `T.cons` ss
unmarked $ '#' `T.cons` ss
| otherwise = markdown Secret $ T.init ss
where
ss = b <> s <> a
@@ -218,8 +226,8 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD s
| T.null s = unmarked s
| isUri s = case strDecode $ encodeUtf8 s of
Right cReq -> markdown (simplexUriFormat cReq) s
_ -> markdown Uri s
Right cReq -> markdown (simplexUriFormat cReq) s
_ -> markdown Uri s
| isEmail s = markdown Email s
| otherwise = unmarked s
isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"]
+136
View File
@@ -0,0 +1,136 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use newtype instead of data" #-}
module Simplex.Chat.MarkdownDiff
( DiffChar (..),
DiffPlainChar (..),
DiffFormatStatus (..),
FormatChar (..),
diff,
plainDiff,
)
where
import qualified Data.Diff.Myers as D
import qualified Data.Foldable as F
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Sequence (Seq (..), (><))
import qualified Data.Sequence as S
import qualified Data.Text as T
import Simplex.Chat.Markdown (EditAction (..), Format)
data DiffFormatStatus
= UnchangedFormat
| ChangedToFormat (Maybe Format)
deriving (Show, Eq)
data DiffChar = DiffChar FormatChar (Maybe EditAction)
deriving (Show, Eq)
data DiffPlainChar = DiffPlainChar Char (Maybe EditAction)
deriving (Show, Eq)
data FormatChar = FormatChar
{ char :: Char,
format :: Maybe Format
}
deriving (Show, Eq)
newtype DeleteIndices = DeleteIndices (Seq Int) deriving (Show, Eq)
newtype InsertIndices = InsertIndices (Seq Int) deriving (Show, Eq)
plainDiff :: T.Text -> T.Text -> Seq DiffPlainChar
plainDiff left right = toPlain <$> formattedDiff
where
formattedDiff = diff (toFormatted left) (toFormatted right)
toPlain :: DiffChar -> DiffPlainChar
toPlain (DiffChar (FormatChar c _) editAction) = DiffPlainChar c editActionPlain
where
editActionPlain = case editAction of
Just EAInsert -> Just EAInsert
Just EADelete -> Just EADelete
Just EAChangeFormat -> Nothing
Nothing -> Nothing
toFormatted :: T.Text -> Seq FormatChar
toFormatted = fmap (`FormatChar` Nothing) . S.fromList . T.unpack
diff :: Seq FormatChar -> Seq FormatChar -> Seq DiffChar
diff left right = addInserts markDeletesAndUnchangedChars
where
edits = D.diffTexts (toText left) (toText right)
(DeleteIndices deleteIndicies, InsertIndices insertIndicies) = indices
toText :: Seq FormatChar -> T.Text
toText = T.pack . F.toList . fmap char
indices :: (DeleteIndices, InsertIndices)
indices = F.foldl' f (DeleteIndices S.empty, InsertIndices S.empty) edits
where
f :: (DeleteIndices, InsertIndices) -> D.Edit -> (DeleteIndices, InsertIndices)
f (x@(DeleteIndices ds), y@(InsertIndices is)) e = case e of
D.EditDelete m n -> (x', y) where x' = DeleteIndices $ ds >< S.fromList [m .. n]
D.EditInsert _ m n -> (x, y') where y' = InsertIndices $ is >< S.fromList [m .. n]
unchangedChars :: Map Int DiffFormatStatus -- indexed in left
unchangedChars = F.foldl' f mempty unchangedCharPairs
where
unchangedCharPairs :: Seq (Int, FormatChar, FormatChar)
unchangedCharPairs = g <$> S.zip leftWithoutDeletes rightWithoutInserts
leftWithoutDeletes :: Seq (Int, FormatChar)
leftWithoutDeletes =
S.filter (\(i, _) -> i `notElem` deleteIndicies) $
S.zip (S.fromList [0 .. S.length left - 1]) left
rightWithoutInserts :: Seq (Int, FormatChar)
rightWithoutInserts =
S.filter (\(i, _) -> i `notElem` insertIndicies) $
S.zip (S.fromList [0 .. S.length right - 1]) right
f :: Map Int DiffFormatStatus -> (Int, FormatChar, FormatChar) -> Map Int DiffFormatStatus
f acc (i, FormatChar _ fL, FormatChar _ fR) = M.insert i x acc
where
x = if fL == fR then UnchangedFormat else ChangedToFormat fR
g :: ((Int, FormatChar), (Int, FormatChar)) -> (Int, FormatChar, FormatChar)
g ((i, c), (_, d)) = (i, c, d)
markDeletesAndUnchangedChars :: Seq DiffChar
markDeletesAndUnchangedChars = S.mapWithIndex f left
where
f :: Int -> FormatChar -> DiffChar
f i x@(FormatChar c _)
| i `elem` deleteIndicies = DiffChar x (Just EADelete)
| otherwise = case unchangedChars M.! i of -- should never error
UnchangedFormat -> DiffChar x Nothing
ChangedToFormat f' -> DiffChar (FormatChar c f') (Just EAChangeFormat)
addInserts :: Seq DiffChar -> Seq DiffChar
addInserts base = F.foldr f base edits -- start from end and work backwards, hence foldr
where
f :: D.Edit -> Seq DiffChar -> Seq DiffChar
f e acc = case e of
D.EditDelete _ _ -> acc
D.EditInsert i m n -> S.take i' acc >< inserts >< S.drop i' acc
where
-- D.EditInsert i m n -> S.take i acc >< inserts >< S.drop i acc
-- if ok to have inserts before deletes, use i not i'
-- Using i of course is faster, but perhaps i' approach can be optimised
i' = slidePastDeleteBlock i
slidePastDeleteBlock :: Int -> Int
slidePastDeleteBlock x = case S.lookup x acc of
Nothing -> x
Just (DiffChar _ diffStatus) ->
if diffStatus == Just EADelete
then slidePastDeleteBlock (x + 1)
else x
rightFormatChars = S.take (n - m + 1) $ S.drop m right
inserts = fmap (`DiffChar` Just EAInsert) rightFormatChars
+34
View File
@@ -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
@@ -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;
|]
@@ -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
+2 -17
View File
@@ -3,12 +3,11 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Chat.Mobile where
import Control.Concurrent.STM
import Control.Exception (catch, SomeException)
import Control.Exception (catch)
import Control.Monad.Except
import Control.Monad.Reader
import Data.Aeson (ToJSON (..))
@@ -41,9 +40,8 @@ import Simplex.Chat.Options
import Simplex.Chat.Store
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Env.SQLite (createAgentStore)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, closeSQLiteStore)
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError)
import Simplex.Messaging.Client (defaultNetworkConfig)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding.String
@@ -55,8 +53,6 @@ import System.Timeout (timeout)
foreign export ccall "chat_migrate_init" cChatMigrateInit :: CString -> CString -> CString -> Ptr (StablePtr ChatController) -> IO CJSONString
foreign export ccall "chat_close_store" cChatCloseStore :: StablePtr ChatController -> IO CString
foreign export ccall "chat_send_cmd" cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
foreign export ccall "chat_recv_msg" cChatRecvMsg :: StablePtr ChatController -> IO CJSONString
@@ -99,9 +95,6 @@ cChatMigrateInit fp key conf ctrl = do
Left e -> pure e
newCStringFromLazyBS $ J.encode r
cChatCloseStore :: StablePtr ChatController -> IO CString
cChatCloseStore cPtr = deRefStablePtr cPtr >>= chatCloseStore >>= newCAString
-- | send command to chat (same syntax as in terminal for now)
cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString
cChatSendCmd cPtr cCmd = do
@@ -202,14 +195,6 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do
_ -> dbError e
dbError e = Left . DBMErrorSQL dbFile $ show e
chatCloseStore :: ChatController -> IO String
chatCloseStore ChatController {chatStore, smpAgent} = handleErr $ do
closeSQLiteStore chatStore
closeSQLiteStore $ agentClientStore smpAgent
handleErr :: IO () -> IO String
handleErr a = (a $> "") `catch` (pure . show @SomeException)
chatSendCmd :: ChatController -> ByteString -> IO JSONByteString
chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc
+7
View File
@@ -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]
+4 -4
View File
@@ -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
+19 -5
View File
@@ -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,
+13 -12
View File
@@ -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 = Nothing, 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
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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
+5 -5
View File
@@ -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
+27 -1
View File
@@ -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,
+2
View File
@@ -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"]
+1 -1
View File
@@ -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
+41 -2
View File
@@ -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 </)
alice `hasContactProfiles` ["alice"]
@@ -212,6 +215,42 @@ testDeleteContactDeletesProfile =
(bob </)
bob `hasContactProfiles` ["bob"]
testDeleteUnusedContactSilent :: HasCallStack => 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"
+7 -1
View File
@@ -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"
+10 -4
View File
@@ -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 <your message>"
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 </)
-- delete group 1
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
+5
View File
@@ -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 </)
bob `hasContactProfiles` ["bob"]
@@ -633,6 +635,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil
-- delete contact, incognito profile is deleted
alice ##> "/d bob"
alice <## "bob: contact is deleted"
bob <## (aliceIncognitoBob <> " deleted contact with you")
alice ##> "/contacts"
(alice </)
alice `hasContactProfiles` ["alice"]
@@ -1063,6 +1066,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr
-- delete contact
bob ##> "/d alice"
bob <## "alice: contact is deleted"
alice <## (bobIncognito <> " deleted contact with you")
bob ##> "/contacts"
(bob </)
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
@@ -1125,6 +1129,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr
-- delete contact
bob ##> "/d alice"
bob <## "alice: contact is deleted"
alice <## (bobIncognito <> " deleted contact with you")
bob ##> "/contacts"
(bob </)
bob `hasContactProfiles` ["bob"]
+334
View File
@@ -0,0 +1,334 @@
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
module MarkdownDiffTests where
import qualified Data.List.NonEmpty as NE
import Simplex.Chat.Markdown
import Simplex.Chat.MarkdownDiff
import System.Console.ANSI.Types
import Test.Hspec
markdownDiffTests :: Spec
markdownDiffTests = do
formattedEditedTextTests
formattedEditedTextTests :: Spec
formattedEditedTextTests = describe "show edits" do
it "empty no change" $
diff [] [] `shouldBe` []
it "no change" $
diff [FormatChar 'H' Nothing] [FormatChar 'H' Nothing]
`shouldBe` [DiffChar (FormatChar 'H' Nothing) Nothing]
it "add 1 char to empty" $
diff [] [FormatChar 'H' Nothing]
`shouldBe` [DiffChar (FormatChar 'H' Nothing) $ Just EAInsert]
it "del the one and only" $
diff [FormatChar 'H' Nothing] []
`shouldBe` [DiffChar (FormatChar 'H' Nothing) $ Just EADelete]
it "one character change" do
diff
[ FormatChar 'H' Nothing,
FormatChar 'r' Nothing,
FormatChar 'l' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing
]
[ FormatChar 'H' Nothing,
FormatChar 'e' Nothing,
FormatChar 'l' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing
]
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'l' Nothing) Nothing,
DiffChar (FormatChar 'l' Nothing) Nothing,
DiffChar (FormatChar 'o' Nothing) Nothing
]
it "more1" do
diff
[ FormatChar 'H' Nothing,
FormatChar 'r' Nothing,
FormatChar 'l' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing
]
[ FormatChar 'H' Nothing,
FormatChar 'e' Nothing,
FormatChar 'l' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing,
FormatChar 'x' Nothing,
FormatChar 'y' Nothing,
FormatChar 'z' Nothing
]
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'l' Nothing) Nothing,
DiffChar (FormatChar 'l' Nothing) Nothing,
DiffChar (FormatChar 'o' Nothing) Nothing,
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert
]
it "more2" do
diff
[ FormatChar 'H' Nothing,
FormatChar 'r' Nothing,
FormatChar 'l' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing
]
[ FormatChar 'H' Nothing,
FormatChar 'e' Nothing,
FormatChar 'x' Nothing,
FormatChar 'y' Nothing,
FormatChar 'z' Nothing,
FormatChar 'o' Nothing
]
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'o' Nothing) Nothing
]
it "more3" do
diff
[ FormatChar 'H' $ Just Bold,
FormatChar 'H' $ Just Bold,
FormatChar 'r' Nothing,
FormatChar 'l' $ Just Secret,
FormatChar 'l' Nothing,
FormatChar 'o' $ Just $ colored Green
]
[ FormatChar 'H' $ Just Italic,
FormatChar 'H' $ Just Bold,
FormatChar 'e' $ Just $ colored Cyan,
FormatChar 'x' Nothing,
FormatChar 'y' Nothing,
FormatChar 'z' $ Just Secret,
FormatChar 'o' $ Just $ colored Blue
]
`shouldBe` [ DiffChar (FormatChar 'H' (Just Italic)) (Just EAChangeFormat),
DiffChar (FormatChar 'H' (Just Bold)) Nothing,
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
DiffChar (FormatChar 'l' (Just Secret)) $ Just EADelete,
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
DiffChar (FormatChar 'e' (Just $ colored Cyan)) $ Just EAInsert,
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'z' (Just Secret)) $ Just EAInsert,
DiffChar (FormatChar 'o' (Just $ colored Blue)) (Just EAChangeFormat)
]
it "more4" do
diff
[ FormatChar 'H' Nothing,
FormatChar 'r' Nothing,
FormatChar 'l' Nothing,
FormatChar '~' Nothing,
FormatChar '!' Nothing,
FormatChar '@' Nothing,
FormatChar 'l' Nothing,
FormatChar 'o' Nothing
]
[ FormatChar 'H' Nothing,
FormatChar 'e' Nothing,
FormatChar 'r' Nothing,
FormatChar 'x' Nothing,
FormatChar 'y' Nothing,
FormatChar '!' Nothing,
FormatChar '@' Nothing,
FormatChar 'z' Nothing,
FormatChar 'o' Nothing,
FormatChar '1' Nothing,
FormatChar '2' Nothing
]
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'r' Nothing) Nothing,
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
DiffChar (FormatChar '~' Nothing) $ Just EADelete,
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
DiffChar (FormatChar '!' Nothing) Nothing,
DiffChar (FormatChar '@' Nothing) Nothing,
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert,
DiffChar (FormatChar 'o' Nothing) Nothing,
DiffChar (FormatChar '1' Nothing) $ Just EAInsert,
DiffChar (FormatChar '2' Nothing) $ Just EAInsert
]
it "SimplexLink 1" do
diff
[ FormatChar '>' $
Just $
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/2/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host1", "host2", "host3"]
}
]
[ FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/3/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host0", "host2", "host3"]
}
]
`shouldBe` [ DiffChar
( FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/3/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host0", "host2", "host3"]
}
)
(Just EAChangeFormat)
]
it "SimplexLink 2" do
diff
[ FormatChar '>' $
Just $
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/2/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host1", "host2", "host3"]
}
]
[ FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/3/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host1", "host2", "host3"]
}
]
`shouldBe` [ DiffChar
( FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/3/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host1", "host2", "host3"]
}
)
(Just EAChangeFormat)
]
it "SimplexLink 3" do
diff
[ FormatChar '>' $
Just $
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/2/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host1", "host2", "host3"]
}
]
[ FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/2/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host0", "host2", "host3"]
}
]
`shouldBe` [ DiffChar
( FormatChar '>' $
Just
SimplexLink
{ linkType = XLContact,
simplexUri = "https://api.twitter.com/2/tweets/:id",
trustedUri = True,
smpHosts = NE.fromList ["host0", "host2", "host3"]
}
)
(Just EAChangeFormat)
]
it "plainDiff 1" do
plainDiff
"https://api.twitter.com/2/tweets/:id"
"https://api.twitter.com/3/tweets/:id"
`shouldBe` [ DiffPlainChar 'h' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 'p' Nothing,
DiffPlainChar 's' Nothing,
DiffPlainChar ':' Nothing,
DiffPlainChar '/' Nothing,
DiffPlainChar '/' Nothing,
DiffPlainChar 'a' Nothing,
DiffPlainChar 'p' Nothing,
DiffPlainChar 'i' Nothing,
DiffPlainChar '.' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 'w' Nothing,
DiffPlainChar 'i' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 'e' Nothing,
DiffPlainChar 'r' Nothing,
DiffPlainChar '.' Nothing,
DiffPlainChar 'c' Nothing,
DiffPlainChar 'o' Nothing,
DiffPlainChar 'm' Nothing,
DiffPlainChar '/' Nothing,
DiffPlainChar '2' $ Just EADelete,
DiffPlainChar '3' $ Just EAInsert,
DiffPlainChar '/' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 'w' Nothing,
DiffPlainChar 'e' Nothing,
DiffPlainChar 'e' Nothing,
DiffPlainChar 't' Nothing,
DiffPlainChar 's' Nothing,
DiffPlainChar '/' Nothing,
DiffPlainChar ':' Nothing,
DiffPlainChar 'i' Nothing,
DiffPlainChar 'd' Nothing
]
it "plainDiff 2" do
plainDiff
"Hrl~!@lo"
"Herxy!@zo12"
`shouldBe` [ DiffPlainChar 'H' Nothing,
DiffPlainChar 'e' $ Just EAInsert,
DiffPlainChar 'r' Nothing,
DiffPlainChar 'l' $ Just EADelete,
DiffPlainChar '~' $ Just EADelete,
DiffPlainChar 'x' $ Just EAInsert,
DiffPlainChar 'y' $ Just EAInsert,
DiffPlainChar '!' Nothing,
DiffPlainChar '@' Nothing,
DiffPlainChar 'l' $ Just EADelete,
DiffPlainChar 'z' $ Just EAInsert,
DiffPlainChar 'o' Nothing,
DiffPlainChar '1' $ Just EAInsert,
DiffPlainChar '2' $ Just EAInsert
]
+2
View File
@@ -5,6 +5,7 @@ import ChatTests
import ChatTests.Utils (xdescribe'')
import Control.Logger.Simple
import Data.Time.Clock.System
import MarkdownDiffTests
import MarkdownTests
import MobileTests
import ProtocolTests
@@ -20,6 +21,7 @@ main = do
withGlobalLogging logCfg . hspec $ do
describe "Schema dump" schemaDumpTest
describe "SimpleX chat markdown" markdownTests
fdescribe "SimpleX chat markdown diff" markdownDiffTests
describe "SimpleX chat view" viewTests
describe "SimpleX chat protocol" protocolTests
describe "WebRTC encryption" webRTCTests
@@ -2,10 +2,10 @@
<ul class="mb-[12px]">
<li>new desktop app! 💻</li>
<li>encrypt locally storead files & media</li>
<li>encrypt locally stored files & media</li>
<li>directory service and other group improvements</li>
<li>simplified incognito mode</li>
<li>better app responsiveness and stability, and 40% reduced memory usage.</li>
<li>better app responsiveness, stability and 40% reduced memory usage.</li>
<li>new privacy settings: show last messages & save draft.</li>
</ul>