From 4903966bea4b41bf4f782c0a97030e1a46e00393 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 20 Mar 2022 16:41:04 +0000 Subject: [PATCH 01/18] update nix dependencies config --- sha256map.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sha256map.nix b/sha256map.nix index c5c5c00503..4a7ab307b8 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,6 +1,6 @@ { - "git://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358"; - "git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; - "git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; - "git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; + "https://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358"; + "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; + "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; + "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; } From 22dc68ff4efe650f60faf23474b3e510ef7bb781 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 21 Mar 2022 08:43:34 +0000 Subject: [PATCH 02/18] ios: update dummy.m to work with x86 sim, upgrade libraries (#458) * ios: update dummy.m to work with x86 sim * add condition for CPU arch to dummy.m --- apps/ios/Shared/dummy.m | 15 +++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 61 ++++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 24 ++++---- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/apps/ios/Shared/dummy.m b/apps/ios/Shared/dummy.m index 73cb36a91d..301c29909a 100644 --- a/apps/ios/Shared/dummy.m +++ b/apps/ios/Shared/dummy.m @@ -6,3 +6,18 @@ // #import + +#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR + +#import + +int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *opendir$INODE64(const char *name) { + return opendir(name); +} + +#endif diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0012039e51..fe75f1995a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -9,20 +9,20 @@ /* Begin PBXBuildFile section */ 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; - 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; }; - 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; }; - 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; }; - 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; }; - 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; }; - 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; }; - 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; }; - 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; }; - 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; }; - 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; + 5C27D01727E863F900DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01227E863F800DD6182 /* libffi.a */; }; + 5C27D01827E863F900DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01227E863F800DD6182 /* libffi.a */; }; + 5C27D01927E863F900DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01327E863F800DD6182 /* libgmp.a */; }; + 5C27D01A27E863F900DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01327E863F800DD6182 /* libgmp.a */; }; + 5C27D01B27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */; }; + 5C27D01C27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */; }; + 5C27D01D27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */; }; + 5C27D01E27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */; }; + 5C27D01F27E863F900DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01627E863F900DD6182 /* libgmpxx.a */; }; + 5C27D02027E863F900DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D01627E863F900DD6182 /* libgmpxx.a */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; @@ -133,13 +133,13 @@ /* Begin PBXFileReference section */ 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; - 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a"; sourceTree = ""; }; - 5C0E5EF227E24676003DE3D0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a"; sourceTree = ""; }; - 5C0E5EF427E24676003DE3D0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; + 5C27D01227E863F800DD6182 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C27D01327E863F800DD6182 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a"; sourceTree = ""; }; + 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a"; sourceTree = ""; }; + 5C27D01627E863F900DD6182 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = ""; }; 5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; 5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; @@ -200,14 +200,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C27D01D27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */, + 5C27D01927E863F900DD6182 /* libgmp.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */, - 5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */, - 5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */, + 5C27D01F27E863F900DD6182 /* libgmpxx.a in Frameworks */, + 5C27D01B27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */, + 5C27D01727E863F900DD6182 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -216,12 +216,12 @@ buildActionMask = 2147483647; files = ( 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */, + 5C27D01C27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a in Frameworks */, + 5C27D01827E863F900DD6182 /* libffi.a in Frameworks */, + 5C27D01A27E863F900DD6182 /* libgmp.a in Frameworks */, + 5C27D01E27E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, - 5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */, - 5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */, - 5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */, - 5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */, + 5C27D02027E863F900DD6182 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -273,11 +273,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C0E5EF227E24676003DE3D0 /* libffi.a */, - 5C0E5EF427E24676003DE3D0 /* libgmp.a */, - 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */, - 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */, - 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */, + 5C27D01227E863F800DD6182 /* libffi.a */, + 5C27D01327E863F800DD6182 /* libgmp.a */, + 5C27D01627E863F900DD6182 /* libgmpxx.a */, + 5C27D01527E863F900DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh-ghc8.10.7.a */, + 5C27D01427E863F800DD6182 /* libHSsimplex-chat-1.3.3-ILT6I8E75GnANrGUR83jRh.a */, ); path = Libraries; sourceTree = ""; @@ -909,6 +909,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.3; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3f1d1790ff..9d4b1de373 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,16 @@ { - "pins" : [ - { - "identity" : "codescanner", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twostraws/CodeScanner", - "state" : { - "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", - "version" : "2.1.1" + "object": { + "pins": [ + { + "package": "CodeScanner", + "repositoryURL": "https://github.com/twostraws/CodeScanner", + "state": { + "branch": null, + "revision": "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", + "version": "2.1.1" + } } - } - ], - "version" : 2 + ] + }, + "version": 1 } From 366b84d3fa2e6d1fdfb07efb9cf69ddee33961ed Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 21 Mar 2022 17:15:25 +0000 Subject: [PATCH 03/18] use simplexmq with TCP keep-alive instead of SMP PINGs (#457) * use simplexmq with TCP keep-alive instead of SMP PINGs * update simplexmq * sha256nix --- .../xcshareddata/swiftpm/Package.resolved | 24 +++++++++---------- cabal.project | 2 +- sha256map.nix | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d4b1de373..3f1d1790ff 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,16 +1,14 @@ { - "object": { - "pins": [ - { - "package": "CodeScanner", - "repositoryURL": "https://github.com/twostraws/CodeScanner", - "state": { - "branch": null, - "revision": "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", - "version": "2.1.1" - } + "pins" : [ + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner", + "state" : { + "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", + "version" : "2.1.1" } - ] - }, - "version": 1 + } + ], + "version" : 2 } diff --git a/cabal.project b/cabal.project index 4d47cbcee4..f55eb3a4e7 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc + tag: a37b24a8c2869cd932e7cc931ff7c46f5aa499cd source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index 4a7ab307b8..0eea297cf3 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358"; + "https://github.com/simplex-chat/simplexmq.git"."a37b24a8c2869cd932e7cc931ff7c46f5aa499cd" = "0fbr5hizcgyq7qjzqwgr8zd09qzj031l8i7j7i6qsicrck80c2hn"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; From 71483b0fc47615b22e33ad79e334d106365c7304 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 22 Mar 2022 08:07:52 +0000 Subject: [PATCH 04/18] update simplexmq --- cabal.project | 2 +- sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index f55eb3a4e7..6d3a320775 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a37b24a8c2869cd932e7cc931ff7c46f5aa499cd + tag: 14d76a1582ce758b2ad62203b904b223eb45eb9f source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index 0eea297cf3..8c86cf3aad 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a37b24a8c2869cd932e7cc931ff7c46f5aa499cd" = "0fbr5hizcgyq7qjzqwgr8zd09qzj031l8i7j7i6qsicrck80c2hn"; + "https://github.com/simplex-chat/simplexmq.git"."14d76a1582ce758b2ad62203b904b223eb45eb9f" = "1hzzpbri6afsfzchqfln2ncr12k62i0l2ddhhyspjrwbmmwkynd4"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index 922f7b4902..33de93780f 100644 --- a/stack.yaml +++ b/stack.yaml @@ -48,7 +48,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc + commit: 14d76a1582ce758b2ad62203b904b223eb45eb9f # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 319b4dc841b2db2fdc44e5f6209e64d57500cfc1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 23 Mar 2022 08:47:36 +0000 Subject: [PATCH 05/18] bump haskell.nix (#459) Co-authored-by: Moritz Angermann --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index 3bfecf43ae..d2deeaa040 100644 --- a/flake.lock +++ b/flake.lock @@ -133,11 +133,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1646625282, - "narHash": "sha256-U23F/EXZC1UOxO3SkfzS82TwYtT42sp5Y6BImXsHWMo=", + "lastModified": 1647047557, + "narHash": "sha256-6A7jjz77f53GkvFxqVmeuqqXyDWsU24rUtFtOg68CAg=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "bff4ab542bc6f68fc078ccd0df2e8eae61650e32", + "rev": "fc07d4d4f2597334caa96f455cec190bdcc931f4", "type": "github" }, "original": { @@ -169,11 +169,11 @@ "stackage": "stackage" }, "locked": { - "lastModified": 1646643560, - "narHash": "sha256-mCzOavKLzXof7NuTBGQx+KWX2AIarrxxGykBE4OvjzY=", + "lastModified": 1647308139, + "narHash": "sha256-GRvEGSCz9YQwE/zYUtFYkq2mNm1QxVNyfVwfN+o6mbM=", "owner": "input-output-hk", "repo": "haskell.nix", - "rev": "98de1769b4d5d9a4d137a77c5ec153c900ab0fa5", + "rev": "d42e6bdd52b6a36ee54344a0d680ce248e64773f", "type": "github" }, "original": { @@ -217,11 +217,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1645623357, - "narHash": "sha256-vAaI91QFn/kY/uMiebW+kG2mPmxirMSJWYtkqkBKdDc=", + "lastModified": 1646955661, + "narHash": "sha256-AYLta1PubJnrkv15+7G+6ErW5m9NcI9wSdJ+n7pKAe0=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9222ae36b208d1c6b55d88e10aa68f969b5b5244", + "rev": "e9545762b032559c27d8ec9141ed63ceca1aa1ac", "type": "github" }, "original": { @@ -322,11 +322,11 @@ "stackage": { "flake": false, "locked": { - "lastModified": 1646625386, - "narHash": "sha256-dIsnm5vx9Dlxx/rRjFyO7uMBfKjEN6RX7oAenwfetHY=", + "lastModified": 1646961451, + "narHash": "sha256-fs3+CsqzgNVT2mJSJOc+MnhbRoIoB/L1ZEhiJn0nXHQ=", "owner": "input-output-hk", "repo": "stackage.nix", - "rev": "e6a7664a79ed4ec8a19d76fb60731190b8763874", + "rev": "02b9e7ea7304027b5d473233c2465d04a21a17e3", "type": "github" }, "original": { From 3c81a442732037b96f27b19c957a7d8869e03984 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 23 Mar 2022 11:37:51 +0000 Subject: [PATCH 06/18] message update and delete (#451) * core: message update and delete, protocol and command syntax * edit logic wip * message updates * revert project.pbxproj * corrections, dependency, editable Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- package.yaml | 2 + simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 66 +++++- src/Simplex/Chat/Controller.hs | 8 + src/Simplex/Chat/Messages.hs | 28 ++- .../Migrations/M20220321_chat_item_edited.hs | 12 + src/Simplex/Chat/Protocol.hs | 16 +- src/Simplex/Chat/Store.hs | 212 +++++++++++++----- src/Simplex/Chat/View.hs | 72 ++++-- stack.yaml | 1 + tests/ChatTests.hs | 117 +++++++++- 11 files changed, 454 insertions(+), 81 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs diff --git a/package.yaml b/package.yaml index ca39fdb7b6..ca1fb0b2d1 100644 --- a/package.yaml +++ b/package.yaml @@ -61,6 +61,8 @@ tests: - hspec == 2.7.* - network == 3.1.* - stm == 2.5.* + ghc-options: + - -threaded ghc-options: # - -haddock diff --git a/simplex-chat.cabal b/simplex-chat.cabal index dee280bcf6..d81eb92431 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -32,6 +32,7 @@ library Simplex.Chat.Migrations.M20220301_smp_servers Simplex.Chat.Migrations.M20220302_profile_images Simplex.Chat.Migrations.M20220304_msg_quotes + Simplex.Chat.Migrations.M20220321_chat_item_edited Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 87fa2934b6..607c771f17 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -215,6 +215,38 @@ processChatCommand = \case msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem) CTContactRequest -> pure $ chatCmdError "not supported" + APIUpdateMessage cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + CTDirect -> do + (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId + case ci of + CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do + case (ciContent, itemSharedMsgId) of + (CISndMsgContent _, Just itemSharedMId) -> do + SndMessage {msgId} <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc) + updCi <- withStore $ \st -> updateDirectChatItem st userId contactId itemId (CISndMsgContent mc) msgId + setActive $ ActiveC c + pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) updCi + _ -> throwChatError CEInvalidMessageUpdate + CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate + CTGroup -> do + Group gInfo@GroupInfo {groupId, localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + ci <- withStore $ \st -> getGroupChatItem st user chatId itemId + case ci of + CChatItem SMDSnd ChatItem {meta = CIMeta {itemSharedMsgId}, content = ciContent} -> do + case (ciContent, itemSharedMsgId) of + (CISndMsgContent _, Just itemSharedMId) -> do + SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgUpdate itemSharedMId mc) + updCi <- withStore $ \st -> updateGroupChatItem st user groupId itemId (CISndMsgContent mc) msgId + setActive $ ActiveG gName + pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) updCi + _ -> throwChatError CEInvalidMessageUpdate + CChatItem SMDRcv _ -> throwChatError CEInvalidMessageUpdate + CTContactRequest -> pure $ chatCmdError "not supported" + APIDeleteMessage cType _chatId _itemId _mode -> withUser $ \_user -> withChatLock $ case cType of + CTDirect -> pure CRCmdOk + CTGroup -> pure CRCmdOk + CTContactRequest -> pure $ chatCmdError "not supported" APIChatRead cType chatId fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -670,10 +702,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage withAckMessage agentConnId meta $ pure () ackMsgDeliveryEvent conn meta SENT msgId -> - -- ? updateDirectChatItem + -- ? updateDirectChatItemStatus sentMsgDeliveryEvent conn msgId -- TODO print errors - MERR _ _ -> pure () -- ? updateDirectChatItem + MERR _ _ -> pure () -- ? updateDirectChatItemStatus ERR _ -> pure () -- TODO add debugging output _ -> pure () @@ -683,6 +715,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage withAckMessage agentConnId msgMeta $ case chatMsgEvent of XMsgNew mc -> newContentMessage ct mc msg msgMeta + XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta XFile fInv -> processFileInvitation ct fInv msg msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv @@ -728,8 +761,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatItemId_ of Nothing -> pure () Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId CISSndSent - toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId CISSndSent + toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) END -> do toView $ CRContactAnotherClient ct showToast (c <> "> ") "connected to another client" @@ -747,8 +780,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage case chatItemId_ of Nothing -> pure () Just chatItemId -> do - chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (agentErrToItemStatus err) - toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + chatItem <- withStore $ \st -> updateDirectChatItemStatus st userId contactId chatItemId (agentErrToItemStatus err) + toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) ERR _ -> pure () -- TODO add debugging output _ -> pure () @@ -822,6 +855,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage withAckMessage agentConnId msgMeta $ case chatMsgEvent of XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta + XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo sharedMsgId mContent msg XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo @@ -1000,6 +1034,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () + messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc RcvMessage {msgId} msgMeta = do + updCi <- withStore $ \st -> updateDirectChatItemByMsgId st userId contactId sharedMsgId (CIRcvMsgContent mc) msgId + toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) updCi + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + setActive $ ActiveC c + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do let content = mcContent mc @@ -1009,6 +1050,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText setActive $ ActiveG g + groupMessageUpdate :: GroupInfo -> SharedMsgId -> MsgContent -> RcvMessage -> m () + groupMessageUpdate gInfo@GroupInfo {groupId} sharedMsgId mc RcvMessage {msgId} = do + updCi <- withStore $ \st -> updateGroupChatItemByMsgId st user groupId sharedMsgId (CIRcvMsgContent mc) msgId + toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) updCi + let g = groupName' gInfo + setActive $ ActiveG g + processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do -- TODO chunk size has to be sent as part of invitation @@ -1396,8 +1444,9 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brok mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do tz <- getCurrentTimeZone + currentTs <- liftIO getCurrentTime let itemText = ciContentToText content - meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId tz itemTs createdAt + meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () @@ -1517,6 +1566,8 @@ chatCommandP = <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_update item " *> (APIUpdateMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_delete item " *> (APIDeleteMessage <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgDeleteMode) <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) @@ -1578,6 +1629,7 @@ chatCommandP = msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) <|> "json " *> (J.eitherDecodeStrict' <$?> A.takeByteString) + msgDeleteMode = "broadcast" $> MDBroadcast <|> "internal" $> MDInternal displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7d5fa8c8b3..6ea60d7549 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -81,6 +81,9 @@ data ChatController = ChatController data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSQuotes deriving (Show, Generic) +data MsgDeleteMode = MDBroadcast | MDInternal + deriving (Show, Generic) + instance ToJSON HelpSection where toJSON = J.genericToJSON . enumJSON $ dropPrefix "HS" toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "HS" @@ -94,6 +97,8 @@ data ChatCommand | APIGetChatItems Int | APISendMessage ChatType Int64 MsgContent | APISendMessageQuote ChatType Int64 ChatItemId MsgContent + | APIUpdateMessage ChatType Int64 ChatItemId MsgContent + | APIDeleteMessage ChatType Int64 ChatItemId MsgDeleteMode | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 @@ -146,7 +151,9 @@ data ChatResponse | CRApiChat {chat :: AChat} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} + | CRChatItemStatusUpdated {chatItem :: AChatItem} | CRChatItemUpdated {chatItem :: AChatItem} + | CRChatItemDeleted {chatItem :: AChatItem} | CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile | CRCmdAccepted {corr :: CorrId} | CRCmdOk @@ -295,6 +302,7 @@ data ChatErrorType | CEFileRcvChunk {message :: String} | CEFileInternal {message :: String} | CEInvalidQuote + | CEInvalidMessageUpdate | CEAgentVersion | CECommandError {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index dc8d9ece9d..011b3c338f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -22,7 +22,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) -import Data.Time.Clock (UTCTime) +import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) import Data.Type.Equality import Data.Typeable (Typeable) @@ -206,15 +206,19 @@ data CIMeta (d :: MsgDirection) = CIMeta itemText :: Text, itemStatus :: CIStatus d, itemSharedMsgId :: Maybe SharedMsgId, + itemDeleted :: Bool, + itemEdited :: Bool, + editable :: Bool, localItemTs :: ZonedTime, createdAt :: UTCTime } deriving (Show, Generic) -mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d -mkCIMeta itemId itemText itemStatus itemSharedMsgId tz itemTs createdAt = +mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> Bool -> Bool -> TimeZone -> UTCTime -> ChatItemTs -> UTCTime -> CIMeta d +mkCIMeta itemId itemText itemStatus itemSharedMsgId itemDeleted itemEdited tz currentTs itemTs createdAt = let localItemTs = utcToZonedTime tz itemTs - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, localItemTs, createdAt} + editable = diffUTCTime currentTs itemTs < nominalDay + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, editable, localItemTs, createdAt} instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions @@ -343,6 +347,8 @@ type ChatItemTs = UTCTime data CIContent (d :: MsgDirection) where CISndMsgContent :: MsgContent -> CIContent 'MDSnd CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv + CISndMsgDeleted :: MsgContent -> CIContent 'MDSnd + CIRcvMsgDeleted :: MsgContent -> CIContent 'MDRcv CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv @@ -352,6 +358,8 @@ ciContentToText :: CIContent d -> Text ciContentToText = \case CISndMsgContent mc -> msgContentText mc CIRcvMsgContent mc -> msgContentText mc + CISndMsgDeleted _ -> "this message is deleted" + CIRcvMsgDeleted _ -> "this message is deleted" CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName @@ -379,6 +387,8 @@ instance FromField ACIContent where fromField = fromTextField_ $ fmap aciContent data JSONCIContent = JCISndMsgContent {msgContent :: MsgContent} | JCIRcvMsgContent {msgContent :: MsgContent} + | JCISndMsgDeleted {msgContent :: MsgContent} + | JCIRcvMsgDeleted {msgContent :: MsgContent} | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) @@ -394,6 +404,8 @@ jsonCIContent :: CIContent d -> JSONCIContent jsonCIContent = \case CISndMsgContent mc -> JCISndMsgContent mc CIRcvMsgContent mc -> JCIRcvMsgContent mc + CISndMsgDeleted mc -> JCISndMsgDeleted mc + CIRcvMsgDeleted mc -> JCIRcvMsgDeleted mc CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath CIRcvFileInvitation ft -> JCIRcvFileInvitation ft @@ -401,6 +413,8 @@ aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case JCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc + JCISndMsgDeleted mc -> ACIContent SMDSnd $ CISndMsgDeleted mc + JCIRcvMsgDeleted mc -> ACIContent SMDRcv $ CIRcvMsgDeleted mc JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft @@ -408,6 +422,8 @@ aciContentJSON = \case data DBJSONCIContent = DBJCISndMsgContent {msgContent :: MsgContent} | DBJCIRcvMsgContent {msgContent :: MsgContent} + | DBJCISndMsgDeleted {msgContent :: MsgContent} + | DBJCIRcvMsgDeleted {msgContent :: MsgContent} | DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} | DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) @@ -423,6 +439,8 @@ dbJsonCIContent :: CIContent d -> DBJSONCIContent dbJsonCIContent = \case CISndMsgContent mc -> DBJCISndMsgContent mc CIRcvMsgContent mc -> DBJCIRcvMsgContent mc + CISndMsgDeleted mc -> DBJCISndMsgDeleted mc + CIRcvMsgDeleted mc -> DBJCIRcvMsgDeleted mc CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft @@ -430,6 +448,8 @@ aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case DBJCISndMsgContent mc -> ACIContent SMDSnd $ CISndMsgContent mc DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc + DBJCISndMsgDeleted ciId -> ACIContent SMDSnd $ CISndMsgDeleted ciId + DBJCIRcvMsgDeleted ciId -> ACIContent SMDRcv $ CIRcvMsgDeleted ciId DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft diff --git a/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs b/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs new file mode 100644 index 0000000000..7a77f00262 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220321_chat_item_edited.hs @@ -0,0 +1,12 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220321_chat_item_edited where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220321_chat_item_edited :: Query +m20220321_chat_item_edited = + [sql| +ALTER TABLE chat_items ADD COLUMN item_edited INTEGER; -- 1 for edited +|] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index a238f0e028..ff011ea8a3 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -109,6 +109,8 @@ instance StrEncoding ChatMessage where data ChatMsgEvent = XMsgNew MsgContainer + | XMsgUpdate SharedMsgId MsgContent + | XMsgDel SharedMsgId | XFile FileInvitation | XFileAcpt String | XInfo Profile @@ -232,6 +234,8 @@ instance FromField MsgContent where data CMEventTag = XMsgNew_ + | XMsgUpdate_ + | XMsgDel_ | XFile_ | XFileAcpt_ | XInfo_ @@ -258,6 +262,8 @@ data CMEventTag instance StrEncoding CMEventTag where strEncode = \case XMsgNew_ -> "x.msg.new" + XMsgUpdate_ -> "x.msg.update" + XMsgDel_ -> "x.msg.del" XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" XInfo_ -> "x.info" @@ -281,6 +287,8 @@ instance StrEncoding CMEventTag where XUnknown_ t -> encodeUtf8 t strDecode = \case "x.msg.new" -> Right XMsgNew_ + "x.msg.update" -> Right XMsgUpdate_ + "x.msg.del" -> Right XMsgDel_ "x.file" -> Right XFile_ "x.file.acpt" -> Right XFileAcpt_ "x.info" -> Right XInfo_ @@ -307,6 +315,8 @@ instance StrEncoding CMEventTag where toCMEventTag :: ChatMsgEvent -> CMEventTag toCMEventTag = \case XMsgNew _ -> XMsgNew_ + XMsgUpdate _ _ -> XMsgUpdate_ + XMsgDel _ -> XMsgDel_ XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ XInfo _ -> XInfo_ @@ -350,7 +360,9 @@ appToChatMessage AppMessage {msgId, event, params} = do opt :: FromJSON a => J.Key -> Either String (Maybe a) opt key = JT.parseEither (.:? key) params msg = \case - XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params + XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params + XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" + XMsgDel_ -> XMsgDel <$> p "msgId" XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XInfo_ -> XInfo <$> p "profile" @@ -382,6 +394,8 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p key .=? value = maybe id ((:) . (key .=)) value params = case chatMsgEvent of XMsgNew container -> msgContainerJSON container + XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content] + XMsgDel msgId' -> o ["msgId" .= msgId'] XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XInfo profile -> o ["profile" .= profile] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 7d0be6b857..43638327de 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -123,8 +123,12 @@ module Simplex.Chat.Store getGroupChatItem, getDirectChatItemIdByText, getGroupChatItemIdByText, + updateDirectChatItemStatus, updateDirectChatItem, + updateDirectChatItemByMsgId, updateDirectChatItemsRead, + updateGroupChatItem, + updateGroupChatItemByMsgId, updateGroupChatItemsRead, getSMPServers, overwriteSMPServers, @@ -148,7 +152,7 @@ import Data.Function (on) import Data.Functor (($>)) import Data.Int (Int64) import Data.List (find, sortBy, sortOn) -import Data.Maybe (isJust, listToMaybe) +import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Ord (Down (..)) import Data.Text (Text) import qualified Data.Text as T @@ -168,6 +172,7 @@ import Simplex.Chat.Migrations.M20220224_messages_fks import Simplex.Chat.Migrations.M20220301_smp_servers import Simplex.Chat.Migrations.M20220302_profile_images import Simplex.Chat.Migrations.M20220304_msg_quotes +import Simplex.Chat.Migrations.M20220321_chat_item_edited import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) @@ -190,7 +195,8 @@ schemaMigrations = ("20220224_messages_fks", m20220224_messages_fks), ("20220301_smp_servers", m20220301_smp_servers), ("20220302_profile_images", m20220302_profile_images), - ("20220304_msg_quotes", m20220304_msg_quotes) + ("20220304_msg_quotes", m20220304_msg_quotes), + ("20220321_chat_item_edited", m20220321_chat_item_edited) ] -- | The list of migrations in ascending order by date @@ -2182,7 +2188,7 @@ createNewSndChatItem st user chatDirection SndMessage {msgId, sharedMsgId} ciCon quoteRow :: NewQuoteRow quoteRow = case quotedItem of Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing) - Just (CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content}) -> + Just CIQuote {chatDir, sharedMsgId = quotedSharedMsgId, sentAt, content} -> uncurry (quotedSharedMsgId,Just sentAt,Just content,,) $ case chatDir of CIQDirectSnd -> (Just True, Nothing) CIQDirectRcv -> (Just False, Nothing) @@ -2320,7 +2326,8 @@ chatItemTs (CChatItem _ ChatItem {meta = CIMeta {itemTs}}) = itemTs getDirectChatPreviews_ :: DB.Connection -> User -> IO [AChat] getDirectChatPreviews_ db User {userId} = do tz <- getCurrentTimeZone - map (toDirectChatPreview tz) + currentTs <- getCurrentTime + map (toDirectChatPreview tz currentTs) <$> DB.query db [sql| @@ -2333,7 +2340,7 @@ getDirectChatPreviews_ db User {userId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct @@ -2370,17 +2377,18 @@ getDirectChatPreviews_ db User {userId} = do |] (CISRcvNew, userId, ConnReady, ConnSndReady) where - toDirectChatPreview :: TimeZone -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat - toDirectChatPreview tz (contactRow :. connRow :. statsRow :. ciRow_) = + toDirectChatPreview :: TimeZone -> UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat + toDirectChatPreview tz currentTs (contactRow :. connRow :. statsRow :. ciRow_) = let contact = toContact $ contactRow :. connRow - ci_ = toDirectChatItemList tz ciRow_ + ci_ = toDirectChatItemList tz currentTs ciRow_ stats = toChatStats statsRow in AChat SCTDirect $ Chat (DirectChat contact) ci_ stats getGroupChatPreviews_ :: DB.Connection -> User -> IO [AChat] getGroupChatPreviews_ db User {userId, userContactId} = do tz <- getCurrentTimeZone - map (toGroupChatPreview tz) + currentTs <- getCurrentTime + map (toGroupChatPreview tz currentTs) <$> DB.query db [sql| @@ -2394,7 +2402,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2433,10 +2441,10 @@ getGroupChatPreviews_ db User {userId, userContactId} = do |] (CISRcvNew, userId, userContactId) where - toGroupChatPreview :: TimeZone -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat - toGroupChatPreview tz (groupInfoRow :. statsRow :. ciRow_) = + toGroupChatPreview :: TimeZone -> UTCTime -> GroupInfoRow :. ChatStatsRow :. MaybeGroupChatItemRow -> AChat + toGroupChatPreview tz currentTs (groupInfoRow :. statsRow :. ciRow_) = let groupInfo = toGroupInfo userContactId groupInfoRow - ci_ = toGroupChatItemList tz userContactId ciRow_ + ci_ = toGroupChatItemList tz currentTs userContactId ciRow_ stats = toChatStats statsRow in AChat SCTGroup $ Chat (GroupChat groupInfo) ci_ stats @@ -2480,13 +2488,14 @@ getDirectChatLast_ db User {userId} contactId count = do getDirectChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsLast_ = do tz <- getCurrentTimeZone - mapM (toDirectChatItem tz) + currentTs <- getCurrentTime + mapM (toDirectChatItem tz currentTs) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -2507,13 +2516,14 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do tz <- getCurrentTimeZone - mapM (toDirectChatItem tz) + currentTs <- getCurrentTime + mapM (toDirectChatItem tz currentTs) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -2534,13 +2544,14 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do tz <- getCurrentTimeZone - mapM (toDirectChatItem tz) + currentTs <- getCurrentTime + mapM (toDirectChatItem tz currentTs) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -2633,13 +2644,14 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do getGroupChatItemsLast_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsLast_ = do tz <- getCurrentTimeZone - mapM (toGroupChatItem tz userContactId) + currentTs <- getCurrentTime + mapM (toGroupChatItem tz currentTs userContactId) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2672,13 +2684,14 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId getGroupChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsAfter_ = do tz <- getCurrentTimeZone - mapM (toGroupChatItem tz userContactId) + currentTs <- getCurrentTime + mapM (toGroupChatItem tz currentTs userContactId) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2711,13 +2724,14 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI getGroupChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTGroup]) getGroupChatItemsBefore_ = do tz <- getCurrentTimeZone - mapM (toGroupChatItem tz userContactId) + currentTs <- getCurrentTime + mapM (toGroupChatItem tz currentTs userContactId) <$> DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2810,8 +2824,8 @@ getChatItemIdByAgentMsgId st connId msgId = |] (connId, msgId) -updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) -updateDirectChatItem st userId contactId itemId itemStatus = +updateDirectChatItemStatus :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIStatus d -> m (ChatItem 'CTDirect d) +updateDirectChatItemStatus st userId contactId itemId itemStatus = liftIOEither . withTransaction st $ \db -> runExceptT $ do ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId currentTs <- liftIO getCurrentTime @@ -2821,6 +2835,50 @@ updateDirectChatItem st userId contactId itemId itemStatus = correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci +updateDirectChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d) +updateDirectChatItem st userId contactId itemId newContent msgId = + liftIOEither . withTransaction st $ \db -> updateDirectChatItem_ db userId contactId itemId newContent msgId + +updateDirectChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> UserId -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTDirect d)) +updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ do + ci <- ExceptT $ (correctDir =<<) <$> getDirectChatItem_ db userId contactId itemId + currentTs <- liftIO getCurrentTime + let newText = ciContentToText newContent + liftIO $ + DB.execute + db + [sql| + UPDATE chat_items + SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ? + WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? + |] + (newContent, newText, currentTs, userId, contactId, itemId) + liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs) + pure ci {content = newContent, meta = (meta ci) {itemText = newText}, formattedText = parseMaybeMarkdownList newText} + where + correctDir :: CChatItem c -> Either StoreError (ChatItem c d) + correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +updateDirectChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> UserId -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTDirect d) +updateDirectChatItemByMsgId st userId contactId sharedMsgId newContent msgId = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + itemId <- ExceptT $ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId + liftIOEither $ updateDirectChatItem_ db userId contactId itemId newContent msgId + +getDirectChatItemIdBySharedMsgId_ :: DB.Connection -> UserId -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) +getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = + firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, contactId, sharedMsgId) + getDirectChatItem :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ChatItemId -> m (CChatItem 'CTDirect) getDirectChatItem st userId contactId itemId = liftIOEither . withTransaction st $ \db -> getDirectChatItem_ db userId contactId itemId @@ -2828,7 +2886,8 @@ getDirectChatItem st userId contactId itemId = getDirectChatItem_ :: DB.Connection -> UserId -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTDirect)) getDirectChatItem_ db userId contactId itemId = do tz <- getCurrentTimeZone - join <$> firstRow (toDirectChatItem tz) (SEChatItemNotFound itemId) getItem + currentTs <- getCurrentTime + join <$> firstRow (toDirectChatItem tz currentTs) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -2836,7 +2895,7 @@ getDirectChatItem_ db userId contactId itemId = do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i @@ -2860,19 +2919,67 @@ getDirectChatItemIdByText st userId contactId msgDir quotedMsg = |] (userId, contactId, msgDir, quotedMsg <> "%") -getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup) -getGroupChatItem st User {userId, userContactId} groupId itemId = - liftIOEither . withTransaction st $ \db -> do - tz <- getCurrentTimeZone - join <$> firstRow (toGroupChatItem tz userContactId) (SEChatItemNotFound itemId) (getItem db) +updateGroupChatItem :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d) +updateGroupChatItem st user groupId itemId newContent msgId = + liftIOEither . withTransaction st $ \db -> updateGroupChatItem_ db user groupId itemId newContent msgId + +updateGroupChatItem_ :: forall d. (MsgDirectionI d) => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> MessageId -> IO (Either StoreError (ChatItem 'CTGroup d)) +updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = runExceptT $ do + ci <- ExceptT $ (correctDir =<<) <$> getGroupChatItem_ db user groupId itemId + currentTs <- liftIO getCurrentTime + let newText = ciContentToText newContent + liftIO $ + DB.execute + db + [sql| + UPDATE chat_items + SET item_content = ?, item_text = ?, item_edited = 1, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + |] + (newContent, newText, currentTs, userId, groupId, itemId) + liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs) + pure ci {content = newContent, meta = (meta ci) {itemText = newText}, formattedText = parseMaybeMarkdownList newText} where - getItem db = + correctDir :: CChatItem c -> Either StoreError (ChatItem c d) + correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +updateGroupChatItemByMsgId :: forall m d. (StoreMonad m, MsgDirectionI d) => SQLiteStore -> User -> Int64 -> SharedMsgId -> CIContent d -> MessageId -> m (ChatItem 'CTGroup d) +updateGroupChatItemByMsgId st user groupId sharedMsgId newContent msgId = + liftIOEither . withTransaction st $ \db -> runExceptT $ do + itemId <- ExceptT $ getGroupChatItemIdBySharedMsgId_ db user groupId sharedMsgId + liftIOEither $ updateGroupChatItem_ db user groupId itemId newContent msgId + +getGroupChatItemIdBySharedMsgId_ :: DB.Connection -> User -> Int64 -> SharedMsgId -> IO (Either StoreError Int64) +getGroupChatItemIdBySharedMsgId_ db User {userId} groupId sharedMsgId = + firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, sharedMsgId) + +getGroupChatItem :: StoreMonad m => SQLiteStore -> User -> Int64 -> ChatItemId -> m (CChatItem 'CTGroup) +getGroupChatItem st user groupId itemId = + liftIOEither . withTransaction st $ \db -> getGroupChatItem_ db user groupId itemId + +getGroupChatItem_ :: DB.Connection -> User -> Int64 -> ChatItemId -> IO (Either StoreError (CChatItem 'CTGroup)) +getGroupChatItem_ db User {userId, userContactId} groupId itemId = do + tz <- getCurrentTimeZone + currentTs <- liftIO getCurrentTime + join <$> firstRow (toGroupChatItem tz currentTs userContactId) (SEChatItemNotFound itemId) getItem + where + getItem = DB.query db [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.created_at, + i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2967,9 +3074,9 @@ type ChatStatsRow = (Int, ChatItemId) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId} -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, UTCTime) +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe UTCTime) +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -2990,8 +3097,8 @@ toQuote :: QuoteRow -> Maybe (CIQDirection c) -> Maybe (CIQuote c) toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir = CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) -toDirectChatItem :: TimeZone -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow) = +toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) +toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) = case (itemContent, itemStatus) of (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent @@ -3002,12 +3109,12 @@ toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedM CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow} badItem = Left $ SEBadChatItem itemId ciMeta :: CIStatus d -> CIMeta d - ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt + ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt -toDirectChatItemList :: TimeZone -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. quoteRow) -toDirectChatItemList _ _ = [] +toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] +toDirectChatItemList tz currentTs ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) +toDirectChatItemList _ _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -3021,8 +3128,8 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction (Just False) Nothing = Just $ CIQGroupRcv Nothing direction _ _ = Nothing -toGroupChatItem :: TimeZone -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do +toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) +toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ case (itemContent, itemStatus, member_) of @@ -3035,12 +3142,12 @@ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemSt CChatItem d ChatItem {chatDir, meta = ciMeta ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_} badItem = Left $ SEBadChatItem itemId ciMeta :: CIStatus d -> CIMeta d - ciMeta status = mkCIMeta itemId itemText status sharedMsgId tz itemTs createdAt + ciMeta status = mkCIMeta itemId itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt -toGroupChatItemList :: TimeZone -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) -toGroupChatItemList _ _ _ = [] +toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] +toGroupChatItemList tz currentTs userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) +toGroupChatItemList _ _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] getSMPServers st User {userId} = @@ -3160,6 +3267,7 @@ data StoreError | SEBadChatItem {itemId :: ChatItemId} | SEChatItemNotFound {itemId :: ChatItemId} | SEQuotedChatItemNotFound + | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 07d952c0af..61e514b325 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -48,7 +48,9 @@ responseToView testView = \case CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item - CRChatItemUpdated _ -> [] + CRChatItemStatusUpdated _ -> [] + CRChatItemUpdated (AChatItem _ _ chat item) -> viewMessageUpdate chat item + CRChatItemDeleted _ -> [] -- TODO CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr CRCmdAccepted _ -> [] CRCmdOk -> ["ok"] @@ -166,11 +168,13 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgDeleted _mc -> [] CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToContact' c CIDirectRcv -> case content of CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgDeleted _mc -> [] CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromContact' c @@ -179,33 +183,62 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of GroupChat g -> case chatDir of CIGroupSnd -> case content of CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgDeleted _mc -> [] CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToGroup g CIGroupRcv m -> case content of CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgDeleted _mc -> [] CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromGroup' g m where quote = maybe [] (groupQuote g) quotedItem _ -> [] - where - directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString] - directQuote _ CIQuote {content = qmc, chatDir = qouteDir} = - quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection qouteDir then ">>" else ">" - groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString] - groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir - sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember - sentByMember GroupInfo {membership} = \case - CIQGroupSnd -> Just membership - CIQGroupRcv m -> m - quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc - msgPreview = msgPlain . preview . msgContentText + +viewMessageUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] +viewMessageUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of + DirectChat Contact {localDisplayName = c} -> case chatDir of + CIDirectRcv -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + _ -> [] where - preview t - | T.length t <= 60 = t - | otherwise = t <> "..." + from = ttyFromContactEdited c + quote = maybe [] (directQuote chatDir) quotedItem + CIDirectSnd -> [] + GroupChat g -> case chatDir of + CIGroupRcv GroupMember {localDisplayName = m} -> case content of + CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + _ -> [] + where + from = ttyFromGroupEdited g m + quote = maybe [] (groupQuote g) quotedItem + CIGroupSnd -> [] + where + _ -> [] + +directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString] +directQuote _ CIQuote {content = qmc, chatDir = quoteDir} = + quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection quoteDir then ">>" else ">" + +groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString] +groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir + +sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember +sentByMember GroupInfo {membership} = \case + CIQGroupSnd -> Just membership + CIQGroupRcv m -> m + +quoteText :: MsgContent -> StyledString -> [StyledString] +quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc + +msgPreview :: MsgContent -> [StyledString] +msgPreview = msgPlain . preview . msgContentText + where + preview t + | T.length t <= 60 = t + | otherwise = t <> "..." viewMsgIntegrityError :: MsgErrorType -> [StyledString] viewMsgIntegrityError err = msgError $ case err of @@ -552,6 +585,7 @@ viewChatError = \case CEFileRcvChunk e -> ["error receiving file: " <> plain e] CEFileInternal e -> ["file error: " <> plain e] CEInvalidQuote -> ["cannot reply to this message"] + CEInvalidMessageUpdate -> ["cannot update this message"] CEAgentVersion -> ["unsupported agent version"] CECommandError e -> ["bad chat command: " <> plain e] -- e -> ["chat error: " <> sShow e] @@ -602,6 +636,9 @@ ttyToContact c = styled (colored Cyan) $ "@" <> c <> " " ttyFromContact :: ContactName -> StyledString ttyFromContact c = ttyFrom $ c <> "> " +ttyFromContactEdited :: ContactName -> StyledString +ttyFromContactEdited c = ttyFrom $ c <> "> [edited] " + ttyToContact' :: Contact -> StyledString ttyToContact' Contact {localDisplayName = c} = ttyToContact c @@ -633,6 +670,9 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN ttyFromGroup :: GroupInfo -> ContactName -> StyledString ttyFromGroup GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> " +ttyFromGroupEdited :: GroupInfo -> ContactName -> StyledString +ttyFromGroupEdited GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> [edited] " + ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow diff --git a/stack.yaml b/stack.yaml index 33de93780f..f9db178816 100644 --- a/stack.yaml +++ b/stack.yaml @@ -36,6 +36,7 @@ packages: # extra-deps: - cryptostore-0.2.1.0@sha256:9896e2984f36a1c8790f057fd5ce3da4cbcaf8aa73eb2d9277916886978c5b19,3881 + - network-3.1.2.7@sha256:e3d78b13db9512aeb106e44a334ab42b7aa48d26c097299084084cb8be5c5568,4888 - simple-logger-0.1.0@sha256:be8ede4bd251a9cac776533bae7fb643369ebd826eb948a9a18df1a8dd252ff8,1079 - tls-1.5.7@sha256:1cc30253a9696b65a9cafc0317fbf09f7dcea15e3a145ed6c9c0e28c632fa23a,6991 # below hackage dependancies are to update Aeson to 2.0.3 diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index ca270a60b4..a3e633495a 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -35,6 +35,7 @@ chatTests = do describe "direct messages" $ do it "add contact and send/receive message" testAddContact it "direct message quoted replies" testDirectMessageQuotedReply + it "direct message update" testDirectMessageUpdate describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "create and join group with 4 members" testGroup2 @@ -44,6 +45,7 @@ chatTests = do it "remove contact from group and add again" testGroupRemoveAdd it "list groups containing group invitations" testGroupList it "group message quoted replies" testGroupMessageQuotedReply + it "group message update" testGroupMessageUpdate describe "user profiles" $ do it "update user profiles and notify contacts" testUpdateProfile it "update user profile with image" testUpdateProfileImage @@ -150,6 +152,59 @@ testDirectMessageQuotedReply = do bob #$> ("/_get chat @2 count=1", chat', [((1, "will tell more"), Just (1, "all good - you?"))]) alice #$> ("/_get chat @2 count=1", chat', [((0, "will tell more"), Just (0, "all good - you?"))]) +testDirectMessageUpdate :: IO () +testDirectMessageUpdate = do + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + -- msg id 1 + alice #> "@bob hello 🙂" + bob <# "alice> hello 🙂" + + -- msg id 2 + bob `send` "> @alice (hello) hi alice" + bob <# "@alice > hello 🙂" + bob <## " hi alice" + alice <# "bob> > hello 🙂" + alice <## " hi alice" + + alice #$> ("/_get chat @2 count=100", chat', [((1, "hello 🙂"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))]) + bob #$> ("/_get chat @2 count=100", chat', [((0, "hello 🙂"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))]) + + alice ##> "/_update item @2 1 text hey 👋" + bob <# "alice> [edited] hey 👋" + + alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂"))]) + bob #$> ("/_get chat @2 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂"))]) + + -- msg id 3 + bob `send` "> @alice (hey) hey alice" + bob <# "@alice > hey 👋" + bob <## " hey alice" + alice <# "bob> > hey 👋" + alice <## " hey alice" + + alice #$> ("/_get chat @2 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))]) + bob #$> ("/_get chat @2 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))]) + + alice ##> "/_update item @2 1 text greetings 🤝" + bob <# "alice> [edited] greetings 🤝" + + alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (1, "hello 🙂")), ((0, "hey alice"), Just (1, "hey 👋"))]) + bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hello 🙂")), ((1, "hey alice"), Just (0, "hey 👋"))]) + + bob ##> "/_update item @2 2 text hey Alice" + alice <# "bob> [edited] > hello 🙂" + alice <## " hey Alice" + + bob ##> "/_update item @2 3 text greetings Alice" + alice <# "bob> [edited] > hey 👋" + alice <## " greetings Alice" + + alice #$> ("/_get chat @2 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hey Alice"), Just (1, "hello 🙂")), ((0, "greetings Alice"), Just (1, "hey 👋"))]) + bob #$> ("/_get chat @2 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hey Alice"), Just (0, "hello 🙂")), ((1, "greetings Alice"), Just (0, "hey 👋"))]) + testGroup :: IO () testGroup = testChat3 aliceProfile bobProfile cathProfile $ @@ -619,7 +674,7 @@ testGroupMessageQuotedReply = cath #$> ("/_get chat #1 count=1", chat', [((1, "hi there!"), Just (0, "hello, all good, you?"))]) alice #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (0, "hello, all good, you?"))]) bob #$> ("/_get chat #1 count=1", chat', [((0, "hi there!"), Just (1, "hello, all good, you?"))]) - alice `send ` "> #team (will tell) go on" + alice `send` "> #team (will tell) go on" alice <# "#team > bob will tell more" alice <## " go on" concurrently_ @@ -632,6 +687,66 @@ testGroupMessageQuotedReply = cath <## " go on" ) +testGroupMessageUpdate :: IO () +testGroupMessageUpdate = do + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice #> "#team hello!" + concurrently_ + (bob <# "#team alice> hello!") + (cath <# "#team alice> hello!") + + alice ##> "/_update item #1 1 text hey 👋" + concurrently_ + (bob <# "#team alice> [edited] hey 👋") + (cath <# "#team alice> [edited] hey 👋") + + alice #$> ("/_get chat #1 count=100", chat', [((1, "hey 👋"), Nothing)]) + bob #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing)]) + cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing)]) + + threadDelay 1000000 + bob `send` "> #team @alice (hey) hi alice" + bob <# "#team > alice hey 👋" + bob <## " hi alice" + concurrently_ + ( do + alice <# "#team bob> > alice hey 👋" + alice <## " hi alice" + ) + ( do + cath <# "#team bob> > alice hey 👋" + cath <## " hi alice" + ) + + alice #$> ("/_get chat #1 count=100", chat', [((1, "hey 👋"), Nothing), ((0, "hi alice"), Just (1, "hey 👋"))]) + bob #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((1, "hi alice"), Just (0, "hey 👋"))]) + cath #$> ("/_get chat #1 count=100", chat', [((0, "hey 👋"), Nothing), ((0, "hi alice"), Just (0, "hey 👋"))]) + + alice ##> "/_update item #1 1 text greetings 🤝" + concurrently_ + (bob <# "#team alice> [edited] greetings 🤝") + (cath <# "#team alice> [edited] greetings 🤝") + + threadDelay 1000000 + cath `send` "> #team @alice (greetings) greetings!" + cath <# "#team > alice greetings 🤝" + cath <## " greetings!" + concurrently_ + ( do + alice <# "#team cath> > alice greetings 🤝" + alice <## " greetings!" + ) + ( do + bob <# "#team cath> > alice greetings 🤝" + bob <## " greetings!" + ) + + alice #$> ("/_get chat #1 count=100", chat', [((1, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (1, "hey 👋")), ((0, "greetings!"), Just (1, "greetings 🤝"))]) + bob #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((1, "hi alice"), Just (0, "hey 👋")), ((0, "greetings!"), Just (0, "greetings 🤝"))]) + cath #$> ("/_get chat #1 count=100", chat', [((0, "greetings 🤝"), Nothing), ((0, "hi alice"), Just (0, "hey 👋")), ((1, "greetings!"), Just (0, "greetings 🤝"))]) + testUpdateProfile :: IO () testUpdateProfile = testChat3 aliceProfile bobProfile cathProfile $ From d4925b7cddd4bf14e473470f8c45783be0edce7c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 23 Mar 2022 20:52:00 +0000 Subject: [PATCH 07/18] core: api to update user profile in one request (#461) --- src/Simplex/Chat.hs | 6 +++++- src/Simplex/Chat/Controller.hs | 1 + tests/ChatTests.hs | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 607c771f17..7c5314db9e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -276,6 +276,7 @@ processChatCommand = \case `E.finally` deleteContactRequest st userId connReqId withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq + APIUpdateProfile profile -> withUser (`updateProfile` profile) GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers @@ -1572,6 +1573,7 @@ chatCommandP = <|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal) <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) + <|> "/_profile " *> (APIUpdateProfile <$> jsonP) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -1628,7 +1630,7 @@ chatCommandP = <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString) - <|> "json " *> (J.eitherDecodeStrict' <$?> A.takeByteString) + <|> "json " *> jsonP msgDeleteMode = "broadcast" $> MDBroadcast <|> "internal" $> MDInternal displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString @@ -1642,6 +1644,8 @@ chatCommandP = userProfile = do (cName, fullName) <- userNames pure Profile {displayName = cName, fullName, image = Nothing} + jsonP :: J.FromJSON a => Parser a + jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do gName <- displayName fullName <- fullNameP gName diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6ea60d7549..d2bb4bfeb4 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -103,6 +103,7 @@ data ChatCommand | APIDeleteChat ChatType Int64 | APIAcceptContact Int64 | APIRejectContact Int64 + | APIUpdateProfile Profile | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index a3e633495a..19b28a7cbb 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -794,11 +794,14 @@ testUpdateProfileImage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob - -- Note we currently don't support removing profile image. alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" alice <## "profile image updated" alice ##> "/profile_image" alice <## "profile image removed" + alice ##> "/_profile {\"displayName\": \"alice2\", \"fullName\": \"\"}" + alice <## "user profile is changed to alice2 (your contacts are notified)" + bob <## "contact alice changed to alice2" + bob <## "use @alice2 to send messages" (bob Date: Thu, 24 Mar 2022 11:01:22 +0000 Subject: [PATCH 08/18] trigger new CI job From 26558dfaca25a82e03975b615f30a6bc303298ae Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 25 Mar 2022 22:13:01 +0400 Subject: [PATCH 09/18] profile images (restore #423) (#466) * core: configurable smp servers (#366) * core: update simplexmq hash * core: update simplexmq hash (fix SMPServer json encoding) * core: fix crashing on supplying duplicate SMP servers * core: update simplexmq hash (remove SMPServer FromJSON) * core: update simplexmq hash (merged master) * core: profile images (#384) * adding initial RFC * adding migration SQL * update RFC * linting * Apply suggestions from code review Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * refine RFC * add avatars db migration to Store.hs * initial chages to have images in users/groups * fix protocol tests * update SQL & MobileTests * minor bug fixes * add missing comma * fix query error * refactor and update functions * bug fixes + testing * update to parse base64 web format images * fix parsing and use valid padded base64 encoded image * fix typos * respose to and suggestions from review * fix: typo * refactor: avatars -> profile_images * fix: typo * swap updateProfile parameters * remove TODO Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> * initial changes to show profile images * simple set up complete * add initial shape of image getting (needs work) * redesign * ios, android: configurable smp servers (only model and api for android) (#392) * example image picker placed in edit profile screen * tidy up and allow encoding * more tidying * update bottom modal bar * v0.1 UI for upload ready * add api calls * refactor edit profile screen * complete the refactor with connection back to api * linting * update encoding for hs compat * no line wrapping and resize image * refactor and tidy up for cleanest compatability with haskell * ios: UI for editing images * crop image to square * update profile edit layout * fixing image preview orientation etc * allow expandable image in profile view * handle case where user exits camera rather than take image * housekeeping on when to call apiUpdateProfileImage * improve scaling of large image * linting * spacing * fix padding * revert whitespace change * tidy up, one remaining issue * refactor to get parsing working * add missed change * use custom modal in user profile * fix image size after scaling * scale image iteratively * add filter * update profile editing view * ios: edit profile image (TODO aspect ratio) * ios: UI to manage profile images * ios: use new profile api * android: use new api to update profile * android: scroll profile view up when editing * revert change * reduce profile image resolution to 104px to fit in 12.5kb Co-authored-by: IanRDavies Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/src/main/AndroidManifest.xml | 12 +- .../java/chat/simplex/app/model/ChatModel.kt | 15 +- .../java/chat/simplex/app/model/SimpleXAPI.kt | 8 +- .../chat/simplex/app/views/WelcomeView.kt | 2 +- .../chat/simplex/app/views/chat/ChatView.kt | 7 +- .../app/views/helpers/ChatInfoImage.kt | 36 +- .../simplex/app/views/helpers/GetImageView.kt | 187 ++++++++++ .../simplex/app/views/newchat/NewChatSheet.kt | 28 +- .../views/usersettings/MarkdownHelpView.kt | 2 +- .../app/views/usersettings/SettingsView.kt | 13 +- .../app/views/usersettings/UserProfileView.kt | 321 +++++++++++------- .../app/src/main/res/xml/file_paths.xml | 3 + apps/ios/Shared/Model/ChatModel.swift | 18 +- apps/ios/Shared/Model/SimpleXAPI.swift | 19 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 + .../Shared/Views/Helpers/ChatInfoImage.swift | 9 +- .../Shared/Views/Helpers/ImagePicker.swift | 48 +++ .../Shared/Views/Helpers/ProfileImage.swift | 45 +++ .../Views/UserSettings/SettingsView.swift | 11 +- .../Views/UserSettings/UserProfile.swift | 167 +++++++-- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 + 21 files changed, 767 insertions(+), 198 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt create mode 100644 apps/android/app/src/main/res/xml/file_paths.xml create mode 100644 apps/ios/Shared/Views/Helpers/ImagePicker.swift create mode 100644 apps/ios/Shared/Views/Helpers/ProfileImage.swift diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index c7e7c1e20d..284544089a 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -33,6 +34,15 @@ + + + - \ No newline at end of file + diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index d91262a249..2a67c09296 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -191,6 +191,7 @@ data class User( ): NamedChat { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName + override val image: String? get() = profile.image companion object { val sampleData = User( @@ -208,6 +209,7 @@ typealias ChatId = String interface NamedChat { val displayName: String val fullName: String + val image: String? val chatViewName: String get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") } @@ -272,6 +274,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val createdAt get() = contact.createdAt override val displayName get() = contact.displayName override val fullName get() = contact.fullName + override val image get() = contact.image companion object { val sampleData = Direct(Contact.sampleData) @@ -288,6 +291,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val createdAt get() = groupInfo.createdAt override val displayName get() = groupInfo.displayName override val fullName get() = groupInfo.fullName + override val image get() = groupInfo.image companion object { val sampleData = Group(GroupInfo.sampleData) @@ -304,6 +308,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val createdAt get() = contactRequest.createdAt override val displayName get() = contactRequest.displayName override val fullName get() = contactRequest.fullName + override val image get() = contactRequest.image companion object { val sampleData = ContactRequest(UserContactRequest.sampleData) @@ -326,6 +331,7 @@ class Contact( override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" override val displayName get() = profile.displayName override val fullName get() = profile.fullName + override val image get() = profile.image companion object { val sampleData = Contact( @@ -354,7 +360,8 @@ class Connection(val connStatus: String) { @Serializable class Profile( val displayName: String, - val fullName: String + val fullName: String, + val image: String? = null ) { companion object { val sampleData = Profile( @@ -377,6 +384,7 @@ class GroupInfo ( override val ready get() = true override val displayName get() = groupProfile.displayName override val fullName get() = groupProfile.fullName + override val image get() = groupProfile.image companion object { val sampleData = GroupInfo( @@ -391,7 +399,8 @@ class GroupInfo ( @Serializable class GroupProfile ( override val displayName: String, - override val fullName: String + override val fullName: String, + override val image: String? = null ): NamedChat { companion object { val sampleData = GroupProfile( @@ -444,6 +453,7 @@ class UserContactRequest ( override val ready get() = true override val displayName get() = profile.displayName override val fullName get() = profile.fullName + override val image get() = profile.image companion object { val sampleData = UserContactRequest( @@ -641,6 +651,7 @@ sealed class MsgContent { } object MsgContentSerializer : KSerializer { + @OptIn(InternalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) { element("MCText", buildClassSerialDescriptor("MCText") { element("text") diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 820a955304..b8611633b1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -203,7 +203,7 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap } suspend fun apiUpdateProfile(profile: Profile): Profile? { - val r = sendCmd(CC.UpdateProfile(profile)) + val r = sendCmd(CC.ApiUpdateProfile(profile)) if (r is CR.UserProfileNoChange) return profile if (r is CR.UserProfileUpdated) return r.toProfile Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") @@ -351,7 +351,7 @@ sealed class CC { class AddContact: CC() class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() - class UpdateProfile(val profile: Profile): CC() + class ApiUpdateProfile(val profile: Profile): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -373,7 +373,7 @@ sealed class CC { is AddContact -> "/connect" is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" - is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}" + is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -396,7 +396,7 @@ sealed class CC { is AddContact -> "addContact" is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" - is UpdateProfile -> "updateProfile" + is ApiUpdateProfile -> "updateProfile" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 721553f2c0..3c2e33adbc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -148,7 +148,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { Button(onClick = { withApi { val user = chatModel.controller.apiCreateActiveUser( - Profile(displayName, fullName) + Profile(displayName, fullName, null) ) chatModel.controller.startChat(user) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 0d0e2b146d..b2ce920b74 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -3,18 +3,18 @@ package chat.simplex.app.views.chat import android.content.res.Configuration import android.util.Log import androidx.activity.compose.BackHandler -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.TAG import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.ChatItemView import chat.simplex.app.views.helpers.* diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index cdc1f86039..d5d582e9ed 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -1,6 +1,8 @@ package chat.simplex.app.views.helpers +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons @@ -8,6 +10,10 @@ import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.SupervisedUserCircle import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -20,12 +26,32 @@ fun ChatInfoImage(chat: Chat, size: Dp) { val icon = if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle else Icons.Filled.AccountCircle + ProfileImage(size, chat.chatInfo.image, icon) +} + +@Composable +fun ProfileImage( + size: Dp, + image: String? = null, + icon: ImageVector = Icons.Filled.AccountCircle +) { Box(Modifier.size(size)) { - Icon(icon, - contentDescription = "Avatar Placeholder", - tint = MaterialTheme.colors.secondary, - modifier = Modifier.fillMaxSize() - ) + if (image == null) { + Icon( + icon, + contentDescription = "profile image placeholder", + tint = MaterialTheme.colors.secondary, + modifier = Modifier.fillMaxSize() + ) + } else { + val imageBitmap = base64ToBitmap(image).asImageBitmap() + Image( + imageBitmap, + "profile image", + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).padding(size / 12).clip(CircleShape) + ) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt new file mode 100644 index 0000000000..6ae0c384a6 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -0,0 +1,187 @@ +package chat.simplex.app.views.helpers + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.* +import android.net.Uri +import android.provider.MediaStore +import android.util.Base64 +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.CallSuper +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import chat.simplex.app.BuildConfig +import chat.simplex.app.TAG +import chat.simplex.app.views.newchat.ActionButton +import java.io.ByteArrayOutputStream +import java.io.File + +// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery + +fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String { + val size = 104 + var height = size + var width = size + var xOffset = 0 + var yOffset = 0 + if (bitmap.height < bitmap.width) { + width = height * bitmap.width / bitmap.height + xOffset = (width - height) / 2 + } else { + height = width * bitmap.height / bitmap.width + yOffset = (height - width) / 2 + } + var image = bitmap + while (image.width / 2 > width) { + image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true) + } + image = Bitmap.createScaledBitmap(image, width, height, true) + if (squareCrop) { + image = Bitmap.createBitmap(image, xOffset, yOffset, size, size) + } + val stream = ByteArrayOutputStream() + image.compress(Bitmap.CompressFormat.JPEG, 85, stream) + return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) +} + +fun base64ToBitmap(base64ImageString: String) : Bitmap { + val imageString = base64ImageString + .removePrefix("data:image/png;base64,") + .removePrefix("data:image/jpg;base64,") + val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) + return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) +} + +class CustomTakePicturePreview : ActivityResultContract() { + private var uri: Uri? = null + private var tmpFile: File? = null + lateinit var externalContext: Context + + @CallSuper + override fun createIntent(context: Context, input: Void?): Intent { + externalContext = context + tmpFile = File.createTempFile("image", ".bmp", context.filesDir) + uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!) + return Intent(MediaStore.ACTION_IMAGE_CAPTURE) + .putExtra(MediaStore.EXTRA_OUTPUT, uri) + } + + override fun getSynchronousResult( + context: Context, + input: Void? + ): SynchronousResult? = null + + override fun parseResult(resultCode: Int, intent: Intent?): Bitmap? { + return if (resultCode == Activity.RESULT_OK && uri != null) { + val source = ImageDecoder.createSource(externalContext.contentResolver, uri!!) + val bitmap = ImageDecoder.decodeBitmap(source) + tmpFile?.delete() + bitmap + } else { + Log.e( TAG, "Getting image from camera cancelled or failed.") + tmpFile?.delete() + null + } + } +} + +@Composable +fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) + +@Composable +fun rememberCameraLauncher(cb: (Bitmap?) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = CustomTakePicturePreview(), cb) + +@Composable +fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission(), cb) + +@Composable +fun GetImageBottomSheet( + profileImageStr: MutableState, + hideBottomSheet: () -> Unit +) { + val context = LocalContext.current + val isCameraSelected = remember { mutableStateOf (false) } + + val galleryLauncher = rememberGalleryLauncher { uri: Uri? -> + if (uri != null) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) + profileImageStr.value = bitmapToBase64(bitmap) + } + } + + val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> + if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap) + } + + val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> + if (isGranted) { + if (isCameraSelected.value) cameraLauncher.launch(null) + else galleryLauncher.launch("image/*") + hideBottomSheet() + } else { + Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .onFocusChanged { focusState -> + if (!focusState.hasFocus) hideBottomSheet() + } + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 30.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { + cameraLauncher.launch(null) + hideBottomSheet() + } + else -> { + isCameraSelected.value = true + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { + galleryLauncher.launch("image/*") + hideBottomSheet() + } + else -> { + isCameraSelected.value = false + permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + } + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 65d882ae71..61d2400a01 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -83,7 +83,7 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { } @Composable -fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false, +fun ActionButton(text: String?, comment: String?, icon: ImageVector, disabled: Boolean = false, click: () -> Unit = {}) { Column( Modifier @@ -97,16 +97,22 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo modifier = Modifier .size(40.dp) .padding(bottom = 8.dp)) - Text(text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - color = tint, - modifier = Modifier.padding(bottom = 4.dp) - ) - Text(comment, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.body2 - ) + if (text != null) { + Text( + text, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + color = tint, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + if (comment != null) { + Text( + comment, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2 + ) + } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt index 0f0a649f1e..ef6e29a6d4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt @@ -16,7 +16,7 @@ import chat.simplex.app.ui.theme.SimpleXTheme @Composable fun MarkdownHelpView() { - Column(Modifier.padding(horizontal = 16.dp)) { + Column { Text( "How to use markdown", style = MaterialTheme.typography.h1, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 0734965898..581c9cf271 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -23,6 +23,7 @@ import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView +import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.newchat.ModalManager @Composable @@ -32,6 +33,7 @@ fun SettingsView(chatModel: ChatModel) { SettingsLayout( profile = user.profile, showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } } ) } @@ -44,6 +46,7 @@ val simplexTeamUri = fun SettingsLayout( profile: Profile, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showTerminal: () -> Unit ) { val uriHandler = LocalUriHandler.current @@ -66,11 +69,8 @@ fun SettingsLayout( ) Spacer(Modifier.height(30.dp)) - SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) { - Icon( - Icons.Outlined.AccountCircle, - contentDescription = "Avatar Placeholder", - ) + SettingsSectionView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp) { + ProfileImage(size = 60.dp, profile.image) Spacer(Modifier.padding(horizontal = 4.dp)) Column { Text( @@ -186,7 +186,7 @@ fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Compos .height(height), verticalAlignment = Alignment.CenterVertically ) { - content.invoke() + content() } } @@ -202,6 +202,7 @@ fun PreviewSettingsLayout() { SettingsLayout( profile = Profile.sampleData, showModal = {{}}, + showCustomModal = {{}}, showTerminal = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 4dd2ad0d44..f72e512bca 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -1,15 +1,24 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration -import androidx.compose.foundation.clickable +import android.widget.ScrollView +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.PhotoCamera import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview @@ -17,29 +26,32 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.chat.CIListState +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.ModalView +import com.google.accompanist.insets.ProvideWindowInsets +import com.google.accompanist.insets.navigationBarsWithImePadding +import kotlinx.coroutines.launch @Composable -fun UserProfileView(chatModel: ChatModel) { +fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val user = chatModel.currentUser.value if (user != null) { - var editProfile by remember { mutableStateOf(false) } + var editProfile = remember { mutableStateOf(false) } var profile by remember { mutableStateOf(user.profile) } UserProfileLayout( + close = close, editProfile = editProfile, profile = profile, - editProfileOff = { editProfile = false }, - editProfileOn = { editProfile = true }, - saveProfile = { displayName: String, fullName: String -> + saveProfile = { displayName, fullName, image -> withApi { - val newProfile = chatModel.controller.apiUpdateProfile( - profile = Profile(displayName, fullName) - ) + val p = Profile(displayName, fullName, image) + val newProfile = chatModel.controller.apiUpdateProfile(p) if (newProfile != null) { chatModel.updateUserProfile(newProfile) profile = newProfile } - editProfile = false + editProfile.value = false } } ) @@ -48,119 +60,192 @@ fun UserProfileView(chatModel: ChatModel) { @Composable fun UserProfileLayout( - editProfile: Boolean, + close: () -> Unit, + editProfile: MutableState, profile: Profile, - editProfileOff: () -> Unit, - editProfileOn: () -> Unit, - saveProfile: (String, String) -> Unit, + saveProfile: (String, String, String?) -> Unit, ) { - Column(horizontalAlignment = Alignment.Start) { - Text( - "Your chat profile", - Modifier.padding(bottom = 24.dp), - style = MaterialTheme.typography.h1, - color = MaterialTheme.colors.onBackground - ) - Text( - "Your profile is stored on your device and shared only with your contacts.\n" + - "SimpleX servers cannot see your profile.", - Modifier.padding(bottom = 24.dp), - color = MaterialTheme.colors.onBackground - ) - if (editProfile) { - var displayName by remember { mutableStateOf(profile.displayName) } - var fullName by remember { mutableStateOf(profile.fullName) } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - // TODO hints - BasicTextField( - value = displayName, - onValueChange = { displayName = it }, - modifier = Modifier - .padding(bottom = 24.dp) - .fillMaxWidth(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - BasicTextField( - value = fullName, - onValueChange = { fullName = it }, - modifier = Modifier - .padding(bottom = 24.dp) - .fillMaxWidth(), - textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrect = false - ), - singleLine = true - ) - Row { - Text( - "Cancel", - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable(onClick = editProfileOff), - ) - Spacer(Modifier.padding(horizontal = 8.dp)) - Text( - "Save (and notify contacts)", - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable(onClick = { saveProfile(displayName, fullName) }) - ) - } - } - } else { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - Row( - Modifier.padding(bottom = 24.dp) + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val displayName = remember { mutableStateOf(profile.displayName) } + val fullName = remember { mutableStateOf(profile.fullName) } + val profileImage = remember { mutableStateOf(profile.image) } + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val keyboardState by getKeyboardState() + var savedKeyboardState by remember { mutableStateOf(keyboardState) } + + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.navigationBarsWithImePadding(), + sheetContent = { + GetImageBottomSheet(profileImage, hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + ModalView(close = close) { + Column( + Modifier + .verticalScroll(scrollState) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.Start ) { Text( - "Display name:", + "Your chat profile", + Modifier.padding(bottom = 24.dp), + style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) - Spacer(Modifier.padding(horizontal = 4.dp)) Text( - profile.displayName, - fontWeight = FontWeight.Bold, + "Your profile is stored on your device and shared only with your contacts.\n\n" + + "SimpleX servers cannot see your profile.", + Modifier.padding(bottom = 24.dp), color = MaterialTheme.colors.onBackground ) + if (editProfile.value) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(192.dp, profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + ProfileNameTextField(displayName) + ProfileNameTextField(fullName) + Row { + TextButton("Cancel") { + displayName.value = profile.displayName + fullName.value = profile.fullName + profileImage.value = profile.image + editProfile.value = false + } + Spacer(Modifier.padding(horizontal = 8.dp)) + TextButton("Save (and notify contacts)") { + saveProfile(displayName.value, fullName.value, profileImage.value) + } + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), contentAlignment = Alignment.Center + ) { + ProfileImage(192.dp, profile.image) + if (profile.image == null) { + EditImageButton { + editProfile.value = true + scope.launch { bottomSheetModalState.show() } + } + } + } + ProfileNameRow("Display name:", profile.displayName) + ProfileNameRow("Full name:", profile.fullName) + TextButton("Edit") { editProfile.value = true } + } + } + if (savedKeyboardState != keyboardState) { + LaunchedEffect(keyboardState) { + scope.launch { + savedKeyboardState = keyboardState + scrollState.animateScrollTo(scrollState.maxValue) + } + } + } } - Row( - Modifier.padding(bottom = 24.dp) - ) { - Text( - "Full name:", - color = MaterialTheme.colors.onBackground - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - profile.fullName, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.onBackground - ) - } - Text( - "Edit", - color = MaterialTheme.colors.primary, - modifier = Modifier - .clickable(onClick = editProfileOn) - ) } } } } +@Composable +private fun ProfileNameTextField(name: MutableState) { + BasicTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = Modifier + .padding(bottom = 24.dp) + .fillMaxWidth(), + textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false + ), + singleLine = true + ) +} + +@Composable +private fun ProfileNameRow(label: String, text: String) { + Row(Modifier.padding(bottom = 24.dp)) { + Text( + label, + color = MaterialTheme.colors.onBackground + ) + Spacer(Modifier.padding(horizontal = 4.dp)) + Text( + text, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onBackground + ) + } +} + +@Composable +private fun TextButton(text: String, click: () -> Unit) { + Text( + text, + color = MaterialTheme.colors.primary, + modifier = Modifier.clickable(onClick = click), + ) +} + +@Composable +fun EditImageButton(click: () -> Unit) { + IconButton( + onClick = click, + modifier = Modifier.background(Color(1f, 1f, 1f, 0.2f), shape = CircleShape) + ) { + Icon( + Icons.Outlined.PhotoCamera, + contentDescription = "Edit image", + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(36.dp) + ) + } +} + +@Composable +fun DeleteImageButton(click: () -> Unit) { + IconButton(onClick = click) { + Icon( + Icons.Outlined.Close, + contentDescription = "Delete image", + tint = MaterialTheme.colors.primary, + ) + } +} + @Preview(showBackground = true) @Preview( uiMode = Configuration.UI_MODE_NIGHT_YES, @@ -171,11 +256,10 @@ fun UserProfileLayout( fun PreviewUserProfileLayoutEditOff() { SimpleXTheme { UserProfileLayout( + close = {}, profile = Profile.sampleData, - editProfile = false, - editProfileOff = {}, - editProfileOn = {}, - saveProfile = { _, _ -> } + editProfile = remember { mutableStateOf(false) }, + saveProfile = { _, _, _ -> } ) } } @@ -190,11 +274,10 @@ fun PreviewUserProfileLayoutEditOff() { fun PreviewUserProfileLayoutEditOn() { SimpleXTheme { UserProfileLayout( + close = {}, profile = Profile.sampleData, - editProfile = true, - editProfileOff = {}, - editProfileOn = {}, - saveProfile = { _, _ -> } + editProfile = remember { mutableStateOf(true) }, + saveProfile = {_, _, _ ->} ) } } diff --git a/apps/android/app/src/main/res/xml/file_paths.xml b/apps/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000000..5cb7c4876d --- /dev/null +++ b/apps/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,3 @@ + + + diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c553543b3a..3836026c04 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -190,8 +190,8 @@ struct User: Decodable, NamedChat { var activeUser: Bool var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } static let sampleData = User( userId: 1, @@ -209,6 +209,7 @@ typealias GroupName = String struct Profile: Codable, NamedChat { var displayName: String var fullName: String + var image: String? static let sampleData = Profile( displayName: "alice", @@ -225,6 +226,7 @@ enum ChatType: String { protocol NamedChat { var displayName: String { get } var fullName: String { get } + var image: String? { get } } extension NamedChat { @@ -270,6 +272,16 @@ enum ChatInfo: Identifiable, Decodable, NamedChat { } } + var image: String? { + get { + switch self { + case let .direct(contact): return contact.image + case let .group(groupInfo): return groupInfo.image + case let .contactRequest(contactRequest): return contactRequest.image + } + } + } + var id: ChatId { get { switch self { @@ -420,6 +432,7 @@ struct Contact: Identifiable, Decodable, NamedChat { var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } static let sampleData = Contact( contactId: 1, @@ -452,6 +465,7 @@ struct UserContactRequest: Decodable, NamedChat { var ready: Bool { get { true } } var displayName: String { get { profile.displayName } } var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } static let sampleData = UserContactRequest( contactRequestId: 1, @@ -472,6 +486,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat { var ready: Bool { get { true } } var displayName: String { get { groupProfile.displayName } } var fullName: String { get { groupProfile.fullName } } + var image: String? { get { groupProfile.image } } static let sampleData = GroupInfo( groupId: 1, @@ -484,6 +499,7 @@ struct GroupInfo: Identifiable, Decodable, NamedChat { struct GroupProfile: Codable, NamedChat { var displayName: String var fullName: String + var image: String? static let sampleData = GroupProfile( displayName: "team", diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 60678b2fef..da985998c6 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -28,7 +28,7 @@ enum ChatCommand { case addContact case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) - case updateProfile(profile: Profile) + case apiUpdateProfile(profile: Profile) case createMyAddress case deleteMyAddress case showMyAddress @@ -52,7 +52,7 @@ enum ChatCommand { case .addContact: return "/connect" case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" - case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)" + case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -79,7 +79,7 @@ enum ChatCommand { case .addContact: return "addContact" case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" - case .updateProfile: return "updateProfile" + case .apiUpdateProfile: return "apiUpdateProfile" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -155,7 +155,7 @@ enum ChatResponse: Decodable, Error { case .sentInvitation: return "sentInvitation" case .contactDeleted: return "contactDeleted" case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileUpdated" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -427,7 +427,7 @@ func apiDeleteChat(type: ChatType, id: Int64) async throws { } func apiUpdateProfile(profile: Profile) async throws -> Profile? { - let r = await chatSendCmd(.updateProfile(profile: profile)) + let r = await chatSendCmd(.apiUpdateProfile(profile: profile)) switch r { case .userProfileNoChange: return nil case let .userProfileUpdated(_, toProfile): return toProfile @@ -703,10 +703,13 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { return try? JSONSerialization.jsonObject(with: d) as? NSDictionary } -private func encodeCJSON(_ value: T) -> [CChar] { +private func encodeJSON(_ value: T) -> String { let data = try! jsonEncoder.encode(value) - let str = String(decoding: data, as: UTF8.self) - return str.cString(using: .utf8)! + return String(decoding: data, as: UTF8.self) +} + +private func encodeCJSON(_ value: T) -> [CChar] { + encodeJSON(value).cString(using: .utf8)! } enum ChatError: Decodable { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5860cfdf6a..bc67bb498e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -130,7 +130,9 @@ struct ChatView: View { } func sendMessage(_ msg: String) { + logger.debug("ChatView sendMessage") Task { + logger.debug("ChatView sendMessage: in Task") do { let chatItem = try await apiSendMessage( type: chat.chatInfo.chatType, diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index 6f95a9be97..c1b9abc2f4 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -19,10 +19,11 @@ struct ChatInfoImage: View { case .group: iconName = "person.2.circle.fill" default: iconName = "circle.fill" } - - return Image(systemName: iconName) - .resizable() - .foregroundColor(color) + return ProfileImage( + imageStr: chat.chatInfo.image, + iconName: iconName, + color: color + ) } } diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift new file mode 100644 index 0000000000..8786e40da0 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -0,0 +1,48 @@ +// +// ImagePicker.swift +// SimpleX +// +// Created by Evgeny on 23/03/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) var presentationMode + var source: UIImagePickerController.SourceType + @Binding var image: UIImage? + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.image = uiImage + } + parent.presentationMode.wrappedValue.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = source + picker.allowsEditing = false + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + + } +} diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift new file mode 100644 index 0000000000..74abaca4b9 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -0,0 +1,45 @@ +// +// ProfileImage.swift +// SimpleX +// +// Created by Evgeny on 23/03/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ProfileImage: View { + var imageStr: String? = nil + var iconName: String = "person.crop.circle.fill" + var color = Color(uiColor: .tertiarySystemGroupedBackground) + + var body: some View { + if let image = imageStr, + let data = Data(base64Encoded: dropImagePrefix(image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .clipShape(Circle()) + } else { + Image(systemName: iconName) + .resizable() + .foregroundColor(color) + } + } + + func dropPrefix(_ s: String, _ prefix: String) -> String { + s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s + } + + func dropImagePrefix(_ s: String) -> String { + dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") + } +} + +struct ProfileImage_Previews: PreviewProvider { + static var previews: some View { + ProfileImage(imageStr: "data:image/png;base64,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") + .previewLayout(.fixed(width: 63, height: 63)) + .background(.black) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f16c8cf5a5..feb7ec85fc 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -30,15 +30,18 @@ struct SettingsView: View { .navigationTitle("Your chat profile") } label: { HStack { - Image(systemName: "person.crop.circle") - .padding(.trailing, 8) + ProfileImage(imageStr: user.image) + .frame(width: 44, height: 44) + .padding(.trailing, 6) + .padding(.vertical, 6) VStack(alignment: .leading) { - Text(user.profile.displayName) + Text(user.displayName) .fontWeight(.bold) .font(.title2) - Text(user.profile.fullName) + Text(user.fullName) } } + .padding(.leading, -8) } NavigationLink { UserAddress() diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 79b33d03bc..7e92301383 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -11,7 +11,11 @@ import SwiftUI struct UserProfile: View { @EnvironmentObject var chatModel: ChatModel @State private var profile = Profile(displayName: "", fullName: "") - @State private var editProfile: Bool = false + @State private var editProfile = false + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var imageSource: UIImagePickerController.SourceType = .photoLibrary + @State private var pickedImage: UIImage? = nil var body: some View { let user: User = chatModel.currentUser! @@ -19,16 +23,30 @@ struct UserProfile: View { return VStack(alignment: .leading) { Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.") .padding(.bottom) + if editProfile { + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + profileImageView(profile.image) + if user.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } + } + } + + editImageButton { showChooseSource = true } + } + .frame(maxWidth: .infinity, alignment: .center) + VStack(alignment: .leading) { - TextField("Display name", text: $profile.displayName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) - TextField("Full name (optional)", text: $profile.fullName) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .padding(.bottom) + profileNameTextEdit("Display name", $profile.displayName) + profileNameTextEdit("Full name (optional)", $profile.fullName) HStack(spacing: 20) { Button("Cancel") { editProfile = false } Button("Save (and notify contacts)") { saveProfile() } @@ -36,19 +54,19 @@ struct UserProfile: View { } .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) } else { + ZStack(alignment: .center) { + profileImageView(user.image) + .onTapGesture { startEditingImage(user) } + + if user.image == nil { + editImageButton { startEditingImage(user) } + } + } + .frame(maxWidth: .infinity, alignment: .center) + VStack(alignment: .leading) { - HStack { - Text("Display name:") - Text(user.profile.displayName) - .fontWeight(.bold) - } - .padding(.bottom) - HStack { - Text("Full name:") - Text(user.profile.fullName) - .fontWeight(.bold) - } - .padding(.bottom) + profileNameView("Display name:", user.profile.displayName) + profileNameView("Full name:", user.profile.fullName) Button("Edit") { profile = user.profile editProfile = true @@ -59,6 +77,70 @@ struct UserProfile: View { } .padding() .frame(maxHeight: .infinity, alignment: .top) + .confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { + imageSource = .camera + showImagePicker = true + } + Button("Choose from library") { + imageSource = .photoLibrary + showImagePicker = true + } + } + .sheet(isPresented: $showImagePicker) { + ImagePicker(source: imageSource, image: $pickedImage) + } + .onChange(of: pickedImage) { image in + if let image = image, + let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) { + let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())" + if imageStr.count <= 12500 { + profile.image = imageStr + } else { + logger.error("UserProfile: resized image is too big \(imageStr.count)") + } + } else { + profile.image = nil + } + } + } + + func profileNameTextEdit(_ label: String, _ name: Binding) -> some View { + TextField(label, text: name) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .padding(.bottom) + } + + func profileNameView(_ label: String, _ name: String) -> some View { + HStack { + Text(label) + Text(name).fontWeight(.bold) + } + .padding(.bottom) + } + + func profileImageView(_ imageStr: String?) -> some View { + ProfileImage(imageStr: imageStr) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 192, maxHeight: 192) + } + + func editImageButton(action: @escaping () -> Void) -> some View { + Button { + action() + } label: { + Image(systemName: "camera") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 48) + } + } + + func startEditingImage(_ user: User) { + profile = user.profile + editProfile = true + showChooseSource = true } func saveProfile() { @@ -78,11 +160,42 @@ struct UserProfile: View { } } -struct UserProfile_Previews: PreviewProvider { - static var previews: some View { - let chatModel = ChatModel() - chatModel.currentUser = User.sampleData - return UserProfile() - .environmentObject(chatModel) +func resize(_ image: UIImage, to newSize: CGSize) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + format.opaque = true + return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in + let size = image.size + let hScale = newSize.height / size.height + let vScale = newSize.width / size.width + let scale = max(hScale, vScale) // scaleToFill + let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) + var middle = CGPoint.zero + if resizeSize.width > newSize.width { + middle.x -= (resizeSize.width - newSize.width) / 2 + } else if resizeSize.height > newSize.height { + middle.y -= (resizeSize.height - newSize.height) / 2 + } + image.draw(in: CGRect(origin: middle, size: resizeSize)) + } +} + +func resizeToSquare(_ image: UIImage, _ side: CGFloat) -> UIImage { + resize(image, to: CGSize(width: side, height: side)) +} + +struct UserProfile_Previews: PreviewProvider { + static var previews: some View { + let chatModel1 = ChatModel() + chatModel1.currentUser = User.sampleData + let chatModel2 = ChatModel() + chatModel2.currentUser = User.sampleData + chatModel2.currentUser?.profile.image = "data:image/jpg;base64,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" + return Group { + UserProfile() + .environmentObject(chatModel1) + UserProfile() + .environmentObject(chatModel2) + } } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index fe75f1995a..e4598f1f1d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,6 +43,10 @@ 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; + 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; + 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; + 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; + 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; @@ -151,6 +155,8 @@ 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; + 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; @@ -310,6 +316,8 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */, 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */, 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */, + 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */, + 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */, ); path = Helpers; sourceTree = ""; @@ -634,6 +642,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */, @@ -644,6 +653,7 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, ); @@ -683,6 +693,7 @@ 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, + 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */, 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, @@ -693,6 +704,7 @@ 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, + 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */, 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, ); From 897c64e0baceeb23b1af4f3c4d6b8056e3949439 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 25 Mar 2022 22:23:51 +0400 Subject: [PATCH 10/18] core: use existential connection request type in file invitations to allow switching groups to "contact" requests (restore #464) (#468) --- src/Simplex/Chat.hs | 10 +++++----- src/Simplex/Chat/Store.hs | 4 ++-- src/Simplex/Chat/Types.hs | 4 ++-- tests/ProtocolTests.hs | 29 ++++++++++++++--------------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7c5314db9e..458a34c460 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -414,8 +414,8 @@ processChatCommand = \case SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName - (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq} + (agentConnId, connReq) <- withAgent (`createConnection` SCMInvitation) + let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = ACR SCMInvitation connReq} SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing @@ -428,8 +428,8 @@ processChatCommand = \case unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do - (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq}) + (connId, connReq) <- withAgent (`createConnection` SCMInvitation) + pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = ACR SCMInvitation connReq}) fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize -- TODO sendGroupChatItem - same file invitation to all forM_ ms $ \(m, _, fileInv) -> @@ -442,7 +442,7 @@ processChatCommand = \case withStore $ \st -> updateFileTransferChatItemId st fileId itemId pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do - ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId + ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq = ACR _ fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName withChatLock . procCmd $ do tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 43638327de..33045999d0 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -176,7 +176,7 @@ import Simplex.Chat.Migrations.M20220321_chat_item_edited import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C @@ -1877,7 +1877,7 @@ getRcvFileTransfer_ db userId fileId = (userId, fileId) where rcvFileTransfer :: - [(FileStatus, ConnReqInvitation, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> + [(FileStatus, AConnectionRequestUri, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> Either StoreError RcvFileTransfer rcvFileTransfer [(fileStatus', fileConnReq, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] = let fileInv = FileInvitation {fileName, fileSize, fileConnReq} diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 84883ab558..54ad6267fc 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -31,7 +31,7 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) @@ -522,7 +522,7 @@ type FileTransferId = Int64 data FileInvitation = FileInvitation { fileName :: String, fileSize :: Integer, - fileConnReq :: ConnReqInvitation + fileConnReq :: AConnectionRequestUri } deriving (Eq, Show, Generic, FromJSON) diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index fba1ce62a5..2ac9801cb5 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -71,10 +71,10 @@ s ##==## msg = do s ==## msg (==#) :: ByteString -> ChatMsgEvent -> Expectation -s ==# msg = s ==## (ChatMessage Nothing msg) +s ==# msg = s ==## ChatMessage Nothing msg (#==) :: ByteString -> ChatMsgEvent -> Expectation -s #== msg = s ##== (ChatMessage Nothing msg) +s #== msg = s ##== ChatMessage Nothing msg (#==#) :: ByteString -> ChatMsgEvent -> Expectation s #==# msg = do @@ -93,23 +93,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello")) it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" - ##==## ( ChatMessage - (Just $ SharedMsgId "\1\2\3\4") - ( XMsgNew $ - MCQuote - ( QuotedMsg - (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) - $ MCText "hello there!" - ) - (MCText "hello to you too") - ) - ) + ##==## ChatMessage + (Just $ SharedMsgId "\1\2\3\4") + ( XMsgNew $ + MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + (MCText "hello to you too") + ) it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello")) + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello") it "x.file" $ "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = testConnReq} + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = ACR SCMInvitation testConnReq} it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} From 0b45ddfc796655f1a22c8776fc1e2ee7196e92c4 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 25 Mar 2022 22:26:05 +0400 Subject: [PATCH 11/18] mobile: message update (restore #460) (#469) --- .../java/chat/simplex/app/model/ChatModel.kt | 86 +++++++++++-------- .../java/chat/simplex/app/model/SimpleXAPI.kt | 54 ++++++++++-- .../chat/simplex/app/views/TerminalView.kt | 8 +- .../chat/simplex/app/views/chat/ChatView.kt | 77 ++++++++++++----- .../simplex/app/views/chat/ComposeView.kt | 25 ++++-- .../{QuotedItemView.kt => ContextItemView.kt} | 51 +++++++---- .../simplex/app/views/chat/SendMsgView.kt | 38 +++++--- .../simplex/app/views/chat/item/CIMetaView.kt | 41 +++++++-- .../app/views/chat/item/ChatItemView.kt | 32 +++++-- .../app/views/chat/item/FramedItemView.kt | 35 +++++--- .../app/views/chat/item/TextItemView.kt | 6 +- apps/ios/Shared/Model/ChatModel.swift | 14 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 44 +++++++++- .../Views/Chat/ChatItem/CIMetaView.swift | 13 ++- .../Views/Chat/ChatItem/FramedItemView.swift | 24 +++++- .../Views/Chat/ChatItem/MsgContentView.swift | 14 +-- apps/ios/Shared/Views/Chat/ChatView.swift | 52 ++++++++--- .../Chat/ComposeMessage/ComposeView.swift | 52 +++++++++-- .../Chat/ComposeMessage/ContextItemView.swift | 55 ++++++++++++ .../Chat/ComposeMessage/QuotedItemView.swift | 49 ----------- .../Chat/ComposeMessage/SendMessageView.swift | 38 ++++++-- apps/ios/Shared/Views/TerminalView.swift | 6 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 +-- src/Simplex/Chat/Store.hs | 4 +- 24 files changed, 599 insertions(+), 231 deletions(-) rename apps/android/app/src/main/java/chat/simplex/app/views/chat/{QuotedItemView.kt => ContextItemView.kt} (60%) create mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift delete mode 100644 apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 2a67c09296..7bdf80eede 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -102,6 +102,37 @@ class ChatModel(val controller: ChatController) { } } + fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean { + // update previews + val i = getChatIndex(cInfo.id) + val chat: Chat + val res: Boolean + if (i >= 0) { + chat = chats[i] + val pItem = chat.chatItems.last() + if (pItem.id == cItem.id) { + chats[i] = chat.copy(chatItems = arrayListOf(cItem)) + } + res = false + } else { + addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) + res = true + } + // update current chat + if (chatId.value == cInfo.id) { + val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } + if (itemIndex >= 0) { + chatItems[itemIndex] = cItem + return false + } else { + chatItems.add(cItem) + return true + } + } else { + return res + } + } + fun markChatItemsRead(cInfo: ChatInfo) { val chatIdx = getChatIndex(cInfo.id) // update current chat @@ -122,42 +153,13 @@ class ChatModel(val controller: ChatController) { } } -// -// func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { -// // update previews -// var res: Bool -// if let chat = getChat(cInfo.id) { -// if let pItem = chat.chatItems.last, pItem.id == cItem.id { -// chat.chatItems = [cItem] -// } -// res = false -// } else { -// addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) -// res = true -// } -// // update current chat -// if chatId == cInfo.id { -// if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { -// withAnimation(.default) { -// self.chatItems[i] = cItem -// } -// return false -// } else { -// withAnimation { chatItems.append(cItem) } -// return true -// } -// } else { -// return res -// } -// } -// -// + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) // } // } -// + private fun popChat_(i: Int) { val chat = chats.removeAt(i) chats.add(index = 0, chat) @@ -494,11 +496,14 @@ data class ChatItem ( ts: Instant = Clock.System.now(), text: String = "hello\nthere", status: CIStatus = CIStatus.SndNew(), - quotedItem: CIQuote? = null + quotedItem: CIQuote? = null, + itemDeleted: Boolean = false, + itemEdited: Boolean = false, + editable: Boolean = true ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status), + meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem ) @@ -536,18 +541,27 @@ data class CIMeta ( val itemTs: Instant, val itemText: String, val itemStatus: CIStatus, - val createdAt: Instant + val createdAt: Instant, + val itemDeleted: Boolean, + val itemEdited: Boolean, + val editable: Boolean ) { val timestampText: String get() = getTimestampText(itemTs) companion object { - fun getSample(id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew()): CIMeta = + fun getSample( + id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), + itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true + ): CIMeta = CIMeta( itemId = id, itemTs = ts, itemText = text, itemStatus = status, - createdAt = ts + createdAt = ts, + itemDeleted = itemDeleted, + itemEdited = itemEdited, + editable = editable ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index b8611633b1..c2ce6ad329 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -131,6 +131,20 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap return null } + suspend fun apiUpdateMessage(type: ChatType, id: Long, itemId: Long, mc: MsgContent): AChatItem? { + val r = sendCmd(CC.ApiUpdateMessage(type, id, itemId, mc)) + if (r is CR.ChatItemUpdated) return r.chatItem + Log.e(TAG, "apiUpdateMessage bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiDeleteMessage(type: ChatType, id: Long, itemId: Long, mode: MsgDeleteMode): AChatItem? { + val r = sendCmd(CC.ApiDeleteMessage(type, id, itemId, mode)) + if (r is CR.ChatItemDeleted) return r.chatItem + Log.e(TAG, "apiDeleteMessage bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun getUserSMPServers(): List? { val r = sendCmd(CC.GetUserSMPServers()) if (r is CR.UserSMPServers) return r.smpServers @@ -303,12 +317,23 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap ntfManager.notifyMessageReceived(cInfo, cItem) } } -// case let .chatItemUpdated(aChatItem): - // let cInfo = aChatItem.chatInfo - // let cItem = aChatItem.chatItem - // if chatModel.upsertChatItem(cInfo, cItem) { - // NtfManager.shared.notifyMessageReceived(cInfo, cItem) - // } + is CR.ChatItemStatusUpdated -> { + val cInfo = r.chatItem.chatInfo + val cItem = r.chatItem.chatItem + if (chatModel.upsertChatItem(cInfo, cItem)) { + ntfManager.notifyMessageReceived(cInfo, cItem) + } + } + is CR.ChatItemUpdated -> { + val cInfo = r.chatItem.chatInfo + val cItem = r.chatItem.chatItem + if (chatModel.upsertChatItem(cInfo, cItem)) { + ntfManager.notifyMessageReceived(cInfo, cItem) + } + } + is CR.ChatItemDeleted -> { + // TODO + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -336,6 +361,11 @@ open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val ap } } +enum class MsgDeleteMode(val mode: String) { + Broadcast("broadcast"), + Internal("internal"); +} + // ChatCommand sealed class CC { class Console(val cmd: String): CC() @@ -346,6 +376,8 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long): CC() class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiUpdateMessage(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiDeleteMessage(val type: ChatType, val id: Long, val itemId: Long, val mode: MsgDeleteMode): CC() class GetUserSMPServers(): CC() class SetUserSMPServers(val smpServers: List): CC() class AddContact: CC() @@ -368,6 +400,8 @@ sealed class CC { is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}" + is ApiUpdateMessage -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" + is ApiDeleteMessage -> "/_delete item ${chatRef(type, id)} $itemId $mode" is GetUserSMPServers -> "/smp_servers" is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}" is AddContact -> "/connect" @@ -391,6 +425,8 @@ sealed class CC { is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" is ApiSendMessageQuote -> "apiSendMessageQuote" + is ApiUpdateMessage -> "apiUpdateMessage" + is ApiDeleteMessage -> "apiDeleteMessage" is GetUserSMPServers -> "getUserSMPServers" is SetUserSMPServers -> "setUserSMPServers" is AddContact -> "addContact" @@ -474,7 +510,9 @@ sealed class CR { @Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val chatItem: AChatItem): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() @@ -512,7 +550,9 @@ sealed class CR { is GroupEmpty -> "groupEmpty" is UserContactLinkSubscribed -> "userContactLinkSubscribed" is NewChatItem -> "newChatItem" + is ChatItemStatusUpdated -> "chatItemStatusUpdated" is ChatItemUpdated -> "chatItemUpdated" + is ChatItemDeleted -> "chatItemDeleted" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -551,7 +591,9 @@ sealed class CR { is GroupEmpty -> json.encodeToString(group) is UserContactLinkSubscribed -> noDetails() is NewChatItem -> json.encodeToString(chatItem) + is ChatItemStatusUpdated -> json.encodeToString(chatItem) is ChatItemUpdated -> json.encodeToString(chatItem) + is ChatItemDeleted -> json.encodeToString(chatItem) is CmdOk -> noDetails() is ChatCmdError -> chatError.string is ChatRespError -> chatError.string diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 5a9e3c0411..3ce907afc7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views +import android.annotation.SuppressLint import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.* @@ -8,8 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -40,11 +40,11 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) { } @Composable -fun TerminalLayout(terminalItems: List , close: () -> Unit, sendCommand: (String) -> Unit) { +fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, - bottomBar = { SendMsgView(sendCommand) }, + bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index b2ce920b74..a72ac162da 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -41,6 +41,8 @@ fun ChatView(chatModel: ChatModel) { chatModel.chatId.value = null } else { val quotedItem = remember { mutableStateOf(null) } + val editingItem = remember { mutableStateOf(null) } + var msg = remember { mutableStateOf("") } BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -57,24 +59,37 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, quotedItem, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, sendMessage = { msg -> withApi { // show "in progress" val cInfo = chat.chatInfo - val newItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - quotedItemId = quotedItem.value?.meta?.itemId, - mc = MsgContent.MCText(msg) - ) - quotedItem.value = null + val ei = editingItem.value + if (ei != null) { + val updatedItem = chatModel.controller.apiUpdateMessage( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = MsgContent.MCText(msg) + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + } else { + val newItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + quotedItemId = quotedItem.value?.meta?.itemId, + mc = MsgContent.MCText(msg) + ) + if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) + } // hide "in progress" - if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) + editingItem.value = null + quotedItem.value = null } - } + }, + resetMessage = { msg.value = "" } ) } } @@ -84,23 +99,27 @@ fun ChatLayout( user: User, chat: Chat, chatItems: List, + msg: MutableState, quotedItem: MutableState, + editingItem: MutableState, back: () -> Unit, info: () -> Unit, - sendMessage: (String) -> Unit + sendMessage: (String) -> Unit, + resetMessage: () -> Unit ) { Surface( Modifier .fillMaxWidth() - .background(MaterialTheme.colors.background)) { + .background(MaterialTheme.colors.background) + ) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(quotedItem, sendMessage) }, + bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chatItems, quotedItem) + ChatItemsList(user, chatItems, msg, quotedItem, editingItem) } } } @@ -133,14 +152,19 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { ) { val cInfo = chat.chatInfo ChatInfoImage(chat, size = 40.dp) - Column(Modifier.padding(start = 8.dp), + Column( + Modifier.padding(start = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Text(cInfo.displayName, fontWeight = FontWeight.Bold, - maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + cInfo.displayName, fontWeight = FontWeight.Bold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { - Text(cInfo.fullName, - maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + cInfo.fullName, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) } } } @@ -160,7 +184,13 @@ val CIListStateSaver = run { } @Composable -fun ChatItemsList(user: User, chatItems: List, quotedItem: MutableState) { +fun ChatItemsList( + user: User, + chatItems: List, + msg: MutableState, + quotedItem: MutableState, + editingItem: MutableState +) { val listState = rememberLazyListState() val keyboardState by getKeyboardState() val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { @@ -171,7 +201,7 @@ fun ChatItemsList(user: User, chatItems: List, quotedItem: MutableStat val cxt = LocalContext.current LazyColumn(state = listState) { items(chatItems) { cItem -> - ChatItemView(user, cItem, quotedItem, cxt, uriHandler) + ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler) } val len = chatItems.count() if (len > 1 && (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount)) { @@ -217,10 +247,13 @@ fun PreviewChatLayout() { chatStats = Chat.ChatStats() ), chatItems = chatItems, + msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, + editingItem = remember { mutableStateOf(null) }, back = {}, info = {}, - sendMessage = {} + sendMessage = {}, + resetMessage = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index ebea19ef94..790b34924d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,14 +1,29 @@ package chat.simplex.app.views.chat import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.* import chat.simplex.app.model.ChatItem +// TODO ComposeState + @Composable -fun ComposeView(quotedItem: MutableState, sendMessage: (String) -> Unit) { +fun ComposeView( + msg: MutableState, + quotedItem: MutableState, + editingItem: MutableState, + sendMessage: (String) -> Unit, + resetMessage: () -> Unit +) { Column { - QuotedItemView(quotedItem) - SendMsgView(sendMessage) + when { + quotedItem.value != null -> { + ContextItemView(quotedItem) + } + editingItem.value != null -> { + ContextItemView(editingItem, editing = editingItem.value != null, resetMessage) + } + else -> {} + } + SendMsgView(msg, sendMessage, editing = editingItem.value != null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt similarity index 60% rename from apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt rename to apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 5f8f1ac54f..3690e79883 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/QuotedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -19,27 +19,38 @@ import chat.simplex.app.views.chat.item.* import kotlinx.datetime.Clock @Composable -fun QuotedItemView(quotedItem: MutableState) { - val qi = quotedItem.value - if (qi != null) { - val sent = qi.chatDir.sent +fun ContextItemView( + contextItem: MutableState, + editing: Boolean = false, + resetMessage: () -> Unit = {} +) { + val cxtItem = contextItem.value + if (cxtItem != null) { + val sent = cxtItem.chatDir.sent Row( - Modifier.padding(top = 8.dp) + Modifier + .padding(top = 8.dp) .background(if (sent) SentColorLight else ReceivedColorLight), verticalAlignment = Alignment.CenterVertically ) { Box( - Modifier.padding(start = 16.dp) + Modifier + .padding(start = 16.dp) .padding(vertical = 12.dp) .fillMaxWidth() .weight(1F) ) { - QuoteText(qi) + ContextItemText(cxtItem) } - IconButton(onClick = { quotedItem.value = null }) { + IconButton(onClick = { + contextItem.value = null + if (editing) { + resetMessage() + } + }) { Icon( Icons.Outlined.Close, - "Remove quote", + contentDescription = "Cancel", tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -49,14 +60,14 @@ fun QuotedItemView(quotedItem: MutableState) { } @Composable -private fun QuoteText(qi: ChatItem) { - val member = qi.memberDisplayName +private fun ContextItemText(cxtItem: ChatItem) { + val member = cxtItem.memberDisplayName if (member == null) { - Text(qi.content.text, maxLines = 3) + Text(cxtItem.content.text, maxLines = 3) } else { val annotatedText = buildAnnotatedString { withStyle(boldFont) { append(member) } - append(": ${qi.content.text}") + append(": ${cxtItem.content.text}") } Text(annotatedText, maxLines = 3) } @@ -64,13 +75,15 @@ private fun QuoteText(qi: ChatItem) { @Preview @Composable -fun PreviewTextItemViewEmoji() { +fun PreviewContextItemView() { SimpleXTheme { - QuotedItemView( - quotedItem = remember { - mutableStateOf(ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" - )) + ContextItemView( + contextItem = remember { + mutableStateOf( + ChatItem.getSampleData( + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" + ) + ) } ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 90248d8818..89b5851f89 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -24,15 +25,14 @@ import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* @Composable -fun SendMsgView(sendMessage: (String) -> Unit) { - var msg by remember { mutableStateOf("") } +fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editing: Boolean = false) { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) var textStyle by remember { mutableStateOf(smallFont) } BasicTextField( - value = msg, + value = msg.value, onValueChange = { - msg = it - textStyle = if(isShortEmoji(it)) { + msg.value = it + textStyle = if (isShortEmoji(it)) { if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { smallFont @@ -64,9 +64,9 @@ fun SendMsgView(sendMessage: (String) -> Unit) { ) { innerTextField() } - val color = if (msg.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray + val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray Icon( - Icons.Outlined.ArrowUpward, + if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, "Send Message", tint = Color.White, modifier = Modifier @@ -75,9 +75,9 @@ fun SendMsgView(sendMessage: (String) -> Unit) { .clip(CircleShape) .background(color) .clickable { - if (msg.isNotEmpty()) { - sendMessage(msg) - msg = "" + if (msg.value.isNotEmpty()) { + sendMessage(msg.value) + msg.value = "" textStyle = smallFont } } @@ -98,7 +98,25 @@ fun SendMsgView(sendMessage: (String) -> Unit) { fun PreviewSendMsgView() { SimpleXTheme { SendMsgView( + msg = remember { mutableStateOf("") }, sendMessage = { msg -> println(msg) } ) } } + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgViewEditing() { + SimpleXTheme { + SendMsgView( + msg = remember { mutableStateOf("") }, + sendMessage = { msg -> println(msg) }, + editing = true + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index aafcb7d041..0517b0ab10 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -1,8 +1,15 @@ package chat.simplex.app.views.chat.item +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem @@ -11,11 +18,24 @@ import kotlinx.datetime.Clock @Composable fun CIMetaView(chatItem: ChatItem) { - Text( - chatItem.timestampText, - color = HighOrLowlight, - fontSize = 14.sp - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (chatItem.meta.itemEdited) { + Icon( + Icons.Filled.Edit, + modifier = Modifier.height(12.dp), + contentDescription = "Edited", + tint = HighOrLowlight, + ) + } + Text( + chatItem.timestampText, + color = HighOrLowlight, + fontSize = 14.sp + ) + } } @Preview @@ -27,3 +47,14 @@ fun PreviewCIMetaView() { ) ) } + +@Preview +@Composable +fun PreviewCIMetaViewEdited() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + itemEdited = true + ) + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index acc249bdf7..c91f159c55 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -22,7 +23,15 @@ import chat.simplex.app.views.helpers.shareText import kotlinx.datetime.Clock @Composable -fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState, cxt: Context, uriHandler: UriHandler? = null) { +fun ChatItemView( + user: User, + cItem: ChatItem, + msg: MutableState, + quotedItem: MutableState, + editingItem: MutableState, + cxt: Context, + uriHandler: UriHandler? = null +) { val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart var showMenu by remember { mutableStateOf(false) } @@ -44,6 +53,7 @@ fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState Unit) { DropdownMenuItem(onClick) { Row { - Text(text, modifier = Modifier - .fillMaxWidth() - .weight(1F)) + Text( + text, modifier = Modifier + .fillMaxWidth() + .weight(1F) + ) Icon(icon, text, tint = HighOrLowlight) } } @@ -81,7 +101,9 @@ fun PreviewChatItemView() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), + msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, + editingItem = remember { mutableStateOf(null) }, cxt = LocalContext.current ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index abdc15effc..d5b2d9c24b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* @@ -48,7 +48,9 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { if (ci.formattedText == null && isShortEmoji(ci.content.text)) { Column( - Modifier.padding(bottom = 2.dp).fillMaxWidth(), + Modifier + .padding(bottom = 2.dp) + .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { EmojiText(ci.content.text) @@ -57,7 +59,7 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { } else { MarkdownText( ci.content, ci.formattedText, ci.memberDisplayName, - metaText = ci.timestampText, uriHandler = uriHandler, senderBold = true + metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true ) } } @@ -69,14 +71,18 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) { } } +class EditedProvider: PreviewParameterProvider { + override val values = listOf(false, true).asSequence() +} + @Preview @Composable -fun PreviewTextItemViewSnd() { +fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Boolean) { SimpleXTheme { FramedItemView( User.sampleData, ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited ) ) } @@ -84,12 +90,12 @@ fun PreviewTextItemViewSnd() { @Preview @Composable -fun PreviewTextItemViewRcv() { +fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Boolean) { SimpleXTheme { FramedItemView( User.sampleData, ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited ) ) } @@ -97,7 +103,7 @@ fun PreviewTextItemViewRcv() { @Preview @Composable -fun PreviewTextItemViewLong() { +fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boolean) { SimpleXTheme { FramedItemView( User.sampleData, @@ -105,7 +111,8 @@ fun PreviewTextItemViewLong() { 1, CIDirection.DirectSnd(), Clock.System.now(), - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + itemEdited = edited ) ) } @@ -113,7 +120,7 @@ fun PreviewTextItemViewLong() { @Preview @Composable -fun PreviewTextItemViewQuote() { +fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Boolean) { SimpleXTheme { FramedItemView( User.sampleData, @@ -122,7 +129,8 @@ fun PreviewTextItemViewQuote() { Clock.System.now(), "https://simplex.chat", CIStatus.SndSent(), - quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()) + quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()), + itemEdited = edited ) ) } @@ -130,7 +138,7 @@ fun PreviewTextItemViewQuote() { @Preview @Composable -fun PreviewTextItemViewEmoji() { +fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Boolean) { SimpleXTheme { FramedItemView( User.sampleData, @@ -139,7 +147,8 @@ fun PreviewTextItemViewEmoji() { Clock.System.now(), "👍", CIStatus.SndSent(), - quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()) + quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()), + itemEdited = edited ) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 6789833fe2..8d25881786 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -39,6 +39,7 @@ fun MarkdownText ( formattedText: List? = null, sender: String? = null, metaText: String? = null, + edited: Boolean = false, style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, @@ -46,11 +47,12 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier ) { + val reserve = if (edited) " " else " " if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) append(content.text) - if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") } + if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) } } Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) } else { @@ -71,7 +73,7 @@ fun MarkdownText ( } } } - if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") } + if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) } } if (hasLinks && uriHandler != null) { ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3836026c04..c0713686c3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -564,10 +564,10 @@ struct ChatItem: Identifiable, Decodable { } } - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil) -> ChatItem { + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status), + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), content: .sndMsgContent(msgContent: .text(text)), quotedItem: quotedItem ) @@ -598,16 +598,22 @@ struct CIMeta: Decodable { var itemText: String var itemStatus: CIStatus var createdAt: Date + var itemDeleted: Bool + var itemEdited: Bool + var editable: Bool var timestampText: Text { get { SimpleX.timestampText(itemTs) } } - static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta { + static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { CIMeta( itemId: id, itemTs: ts, itemText: text, itemStatus: status, - createdAt: ts + createdAt: ts, + itemDeleted: itemDeleted, + itemEdited: itemEdited, + editable: editable ) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index da985998c6..0f0386bf18 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -15,6 +15,11 @@ private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() private let jsonEncoder = getJSONEncoder() +enum MsgDeleteMode: String { + case mdBroadcast = "broadcast" + case mdInternal = "internal" +} + enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) @@ -23,6 +28,8 @@ enum ChatCommand { case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -47,6 +54,8 @@ enum ChatCommand { case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiUpdateMessage(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiDeleteMessage(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -74,6 +83,8 @@ enum ChatCommand { case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" case .apiSendMessageQuote: return "apiSendMessageQuote" + case .apiUpdateMessage: return "apiUpdateMessage" + case .apiDeleteMessage: return "apiDeleteMessage" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -135,7 +146,9 @@ enum ChatResponse: Decodable, Error { case groupEmpty(groupInfo: GroupInfo) case userContactLinkSubscribed case newChatItem(chatItem: AChatItem) + case chatItemStatusUpdated(chatItem: AChatItem) case chatItemUpdated(chatItem: AChatItem) + case chatItemDeleted(chatItem: AChatItem) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -173,7 +186,9 @@ enum ChatResponse: Decodable, Error { case .groupEmpty: return "groupEmpty" case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .newChatItem: return "newChatItem" + case .chatItemStatusUpdated: return "chatItemStatusUpdated" case .chatItemUpdated: return "chatItemUpdated" + case .chatItemDeleted: return "chatItemDeleted" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" @@ -214,7 +229,9 @@ enum ChatResponse: Decodable, Error { case let .groupEmpty(groupInfo): return String(describing: groupInfo) case .userContactLinkSubscribed: return noDetails case let .newChatItem(chatItem): return String(describing: chatItem) + case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) case let .chatItemUpdated(chatItem): return String(describing: chatItem) + case let .chatItemDeleted(chatItem): return String(describing: chatItem) case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -393,6 +410,18 @@ func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgCon throw r } +func apiUpdateMessage(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) async throws -> ChatItem { + let r = await chatSendCmd(.apiUpdateMessage(type: type, id: id, itemId: itemId, msg: msg), bgDelay: msgDelay) + if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } + throw r +} + +func apiDeleteMessage(type: ChatType, id: Int64, itemId: Int64, mode: MsgDeleteMode) async throws -> ChatItem { + let r = await chatSendCmd(.apiDeleteMessage(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) + if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } + throw r +} + func getUserSMPServers() throws -> [String] { let r = chatSendCmdSync(.getUserSMPServers) if case let .userSMPServers(smpServers) = r { return smpServers } @@ -601,7 +630,7 @@ func processReceivedMsg(_ res: ChatResponse) { let cItem = aChatItem.chatItem chatModel.addChatItem(cInfo, cItem) NtfManager.shared.notifyMessageReceived(cInfo, cItem) - case let .chatItemUpdated(aChatItem): + case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem if chatModel.upsertChatItem(cInfo, cItem) { @@ -614,6 +643,15 @@ func processReceivedMsg(_ res: ChatResponse) { default: break } } + case let .chatItemUpdated(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + if chatModel.upsertChatItem(cInfo, cItem) { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } + case .chatItemDeleted(_): + // TODO let .chatItemDeleted(aChatItem) + return default: logger.debug("unsupported event: \(res.responseType)") } @@ -745,6 +783,8 @@ enum ChatErrorType: Decodable { case fileSend(fileId: Int64, agentError: String) case fileRcvChunk(message: String) case fileInternal(message: String) + case invalidQuote + case invalidMessageUpdate case agentVersion case commandError(message: String) } @@ -776,6 +816,8 @@ enum StoreError: Decodable { case noMsgDelivery(connId: Int64, agentMsgId: String) case badChatItem(itemId: Int64) case chatItemNotFound(itemId: Int64) + case quotedChatItemNotFound + case chatItemSharedMsgIdNotFound(sharedMsgId: String) } enum AgentErrorType: Decodable { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 08112976fc..80ed91e072 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -13,6 +13,10 @@ struct CIMetaView: View { var body: some View { HStack(alignment: .center, spacing: 4) { + if chatItem.meta.itemEdited { + statusImage("pencil", .secondary, 9) + } + switch chatItem.meta.itemStatus { case .sndSent: statusImage("checkmark", .secondary) @@ -31,17 +35,20 @@ struct CIMetaView: View { } } - private func statusImage(_ systemName: String, _ color: Color) -> some View { + private func statusImage(_ systemName: String, _ color: Color, _ maxHeight: CGFloat = 8) -> some View { Image(systemName: systemName) .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(color) - .frame(maxHeight: 8) + .frame(maxHeight: maxHeight) } } struct CIMetaView_Previews: PreviewProvider { static var previews: some View { - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent)) + return Group { + CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent)) + CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, false, true)) + } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 90e5d4e2a6..b50abd392c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -54,7 +54,8 @@ struct FramedItemView: View { content: chatItem.content, formattedText: chatItem.formattedText, sender: chatItem.memberDisplayName, - metaText: chatItem.timestampText + metaText: chatItem.timestampText, + edited: chatItem.meta.itemEdited ) .padding(.vertical, 6) .padding(.horizontal, 12) @@ -63,14 +64,15 @@ struct FramedItemView: View { .textSelection(.enabled) } } - .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } CIMetaView(chatItem: chatItem) - .padding(.trailing, 12) + .padding(.horizontal, 12) .padding(.bottom, 6) + .overlay(DetermineWidth()) } .background(chatItemFrameColor(chatItem, colorScheme)) .cornerRadius(18) + .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } switch chatItem.meta.itemStatus { case .sndErrorAuth: @@ -110,3 +112,19 @@ struct FramedItemView_Previews: PreviewProvider { .previewLayout(.fixed(width: 360, height: 200)) } } + +struct FramedItemViewEdited_Previews: PreviewProvider { + static var previews: some View { + Group{ + FramedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, false, true)) + FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, false, true)) + FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, false, true)) + } + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 4620fb34c8..9ffea020a3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -16,20 +16,22 @@ struct MsgContentView: View { var formattedText: [FormattedText]? = nil var sender: String? = nil var metaText: Text? = nil + var edited: Bool = false - var body: some View { + var body: some View { let v = messageText(content, formattedText, sender) if let mt = metaText { - return v + reserveSpaceForMeta(mt) + return v + reserveSpaceForMeta(mt, edited) } else { return v } } - private func reserveSpaceForMeta(_ meta: Text) -> Text { - (Text(" ") + meta) - .font(.caption) - .foregroundColor(.clear) + private func reserveSpaceForMeta(_ meta: Text, _ edited: Bool) -> Text { + let reserve = edited ? " " : " " + return (Text(reserve) + meta) + .font(.caption) + .foregroundColor(.clear) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index bc67bb498e..cac7716dfc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -12,7 +12,9 @@ struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme @ObservedObject var chat: Chat + @State var message: String = "" @State var quotedItem: ChatItem? = nil + @State var editingItem: ChatItem? = nil @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @State private var showChatInfo = false @@ -31,7 +33,10 @@ struct ChatView: View { ChatItemView(chatItem: ci) .contextMenu { Button { - withAnimation { quotedItem = ci } + withAnimation { + editingItem = nil + quotedItem = ci + } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { showShareSheet(items: [ci.content.text]) @@ -39,6 +44,15 @@ struct ChatView: View { Button { UIPasteboard.general.string = ci.content.text } label: { Label("Copy", systemImage: "doc.on.doc") } + if (ci.chatDir.sent && ci.meta.editable) { + Button { + withAnimation { + quotedItem = nil + editingItem = ci + message = ci.content.text + } + } label: { Label("Edit", systemImage: "square.and.pencil") } + } } .padding(.horizontal) .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) @@ -71,8 +85,11 @@ struct ChatView: View { Spacer(minLength: 0) ComposeView( + message: $message, quotedItem: $quotedItem, + editingItem: $editingItem, sendMessage: sendMessage, + resetMessage: { message = "" }, inProgress: inProgress, keyboardVisible: $keyboardVisible ) @@ -134,18 +151,31 @@ struct ChatView: View { Task { logger.debug("ChatView sendMessage: in Task") do { - let chatItem = try await apiSendMessage( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - quotedItemId: quotedItem?.meta.itemId, - msg: .text(msg) - ) - DispatchQueue.main.async { - quotedItem = nil - chatModel.addChatItem(chat.chatInfo, chatItem) + if let ei = editingItem { + let chatItem = try await apiUpdateMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: ei.id, + msg: .text(msg) + ) + DispatchQueue.main.async { + editingItem = nil + let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem) + } + } else { + let chatItem = try await apiSendMessage( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + quotedItemId: quotedItem?.meta.itemId, + msg: .text(msg) + ) + DispatchQueue.main.async { + quotedItem = nil + chatModel.addChatItem(chat.chatInfo, chatItem) + } } } catch { - logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)") + logger.error("ChatView.sendMessage error: \(error.localizedDescription)") } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 1e02ee3eeb..3dc3e1b71a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -8,35 +8,69 @@ import SwiftUI +// TODO +//enum ComposeState { +// case plain +// case quoted(quotedItem: ChatItem) +// case editing(editingItem: ChatItem) +//} + struct ComposeView: View { + @Binding var message: String @Binding var quotedItem: ChatItem? + @Binding var editingItem: ChatItem? var sendMessage: (String) -> Void + var resetMessage: () -> Void var inProgress: Bool = false @FocusState.Binding var keyboardVisible: Bool + @State var editing: Bool = false var body: some View { VStack(spacing: 0) { - QuotedItemView(quotedItem: $quotedItem) - .transition(.move(edge: .bottom)) + if (quotedItem != nil) { + ContextItemView(contextItem: $quotedItem, editing: $editing) + } else if (editingItem != nil) { + ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) + } SendMessageView( sendMessage: sendMessage, inProgress: inProgress, - keyboardVisible: $keyboardVisible + message: $message, + keyboardVisible: $keyboardVisible, + editing: $editing ) .background(.background) } + .onChange(of: editingItem == nil) { _ in + editing = (editingItem != nil) + } } } struct ComposeView_Previews: PreviewProvider { static var previews: some View { + @State var message: String = "" @FocusState var keyboardVisible: Bool - @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var nilItem: ChatItem? = nil - return ComposeView( - quotedItem: $quotedItem, - sendMessage: { print ($0) }, - keyboardVisible: $keyboardVisible - ) + return Group { + ComposeView( + message: $message, + quotedItem: $item, + editingItem: $nilItem, + sendMessage: { print ($0) }, + resetMessage: {}, + keyboardVisible: $keyboardVisible + ) + ComposeView( + message: $message, + quotedItem: $nilItem, + editingItem: $item, + sendMessage: { print ($0) }, + resetMessage: {}, + keyboardVisible: $keyboardVisible + ) + } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift new file mode 100644 index 0000000000..d853e17026 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -0,0 +1,55 @@ +// +// ContextItemView.swift +// SimpleX +// +// Created by JRoberts on 13/03/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ContextItemView: View { + @Environment(\.colorScheme) var colorScheme + @Binding var contextItem: ChatItem? + @Binding var editing: Bool + var resetMessage: () -> Void = {} + + var body: some View { + if let cxtItem = contextItem { + HStack { + contextText(cxtItem).lineLimit(3) + Spacer() + Button { + withAnimation { + contextItem = nil + if editing { resetMessage() } + } + } label: { + Image(systemName: "multiply") + } + } + .padding(12) + .frame(maxWidth: .infinity) + .background(chatItemFrameColor(cxtItem, colorScheme)) + .padding(.top, 8) + } else { + EmptyView() + } + } + + func contextText(_ cxtItem: ChatItem) -> some View { + if let s = cxtItem.memberDisplayName { + return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)")) + } else { + return Text(cxtItem.content.text) + } + } +} + +struct ContextItemView_Previews: PreviewProvider { + static var previews: some View { + @State var contextItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var editing: Bool = false + return ContextItemView(contextItem: $contextItem, editing: $editing) + } +} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift deleted file mode 100644 index 36a49637ac..0000000000 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/QuotedItemView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// QuotedItemView.swift -// SimpleX -// -// Created by Evgeny on 13/03/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct QuotedItemView: View { - @Environment(\.colorScheme) var colorScheme - @Binding var quotedItem: ChatItem? - - var body: some View { - if let qi = quotedItem { - HStack { - quoteText(qi).lineLimit(3) - Spacer() - Button { - withAnimation { quotedItem = nil } - } label: { - Image(systemName: "multiply") - } - } - .padding(12) - .frame(maxWidth: .infinity) - .background(chatItemFrameColor(qi, colorScheme)) - .padding(.top, 8) - } else { - EmptyView() - } - } - - func quoteText(_ qi: ChatItem) -> some View { - if let s = qi.memberDisplayName { - return (Text(s).fontWeight(.medium) + Text(": \(qi.content.text)")) - } else { - return Text(qi.content.text) - } - } -} - -struct QuotedItemView_Previews: PreviewProvider { - static var previews: some View { - @State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - return QuotedItemView(quotedItem: $quotedItem) - } -} diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index af639999fc..a760195fa9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,9 +11,10 @@ import SwiftUI struct SendMessageView: View { var sendMessage: (String) -> Void var inProgress: Bool = false - @State private var message: String = "" //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + @Binding var message: String //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool + @Binding var editing: Bool @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body var maxHeight: CGFloat = 360 @@ -47,7 +48,7 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 3) } else { Button(action: submit) { - Image(systemName: "arrow.up.circle.fill") + Image(systemName: editing ? "checkmark.circle.fill" : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) } @@ -85,15 +86,34 @@ struct SendMessageView: View { struct SendMessageView_Previews: PreviewProvider { static var previews: some View { + @State var message: String = "" @FocusState var keyboardVisible: Bool + @State var editingOff: Bool = false + @State var editingOn: Bool = true + @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") + @State var nilItem: ChatItem? = nil - return VStack { - Text("") - Spacer(minLength: 0) - SendMessageView( - sendMessage: { print ($0) }, - keyboardVisible: $keyboardVisible - ) + return Group { + VStack { + Text("") + Spacer(minLength: 0) + SendMessageView( + sendMessage: { print ($0) }, + message: $message, + keyboardVisible: $keyboardVisible, + editing: $editingOff + ) + } + VStack { + Text("") + Spacer(minLength: 0) + SendMessageView( + sendMessage: { print ($0) }, + message: $message, + keyboardVisible: $keyboardVisible, + editing: $editingOn + ) + } } } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 60e54807a9..ae9321fd43 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -13,7 +13,9 @@ private let terminalFont = Font.custom("Menlo", size: 16) struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @State var inProgress: Bool = false + @State var message: String = "" @FocusState private var keyboardVisible: Bool + @State var editing: Bool = false var body: some View { VStack { @@ -54,7 +56,9 @@ struct TerminalView: View { SendMessageView( sendMessage: sendMessage, inProgress: inProgress, - keyboardVisible: $keyboardVisible + message: $message, + keyboardVisible: $keyboardVisible, + editing: $editing ) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e4598f1f1d..f56df3377e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -110,12 +110,12 @@ 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; - 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; }; - 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; + 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; + 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -196,9 +196,9 @@ 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; - 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedItemView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; + 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -440,7 +440,7 @@ children = ( 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */, 5CEACCE227DE9246000BD591 /* ComposeView.swift */, - 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */, + 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */, ); path = ComposeMessage; sourceTree = ""; @@ -623,7 +623,6 @@ 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */, - 5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */, 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, @@ -656,6 +655,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, + 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -674,7 +674,6 @@ 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */, 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */, - 5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */, 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, @@ -707,6 +706,7 @@ 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */, 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, + 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 33045999d0..1ccc0c9ef6 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2854,7 +2854,7 @@ updateDirectChatItem_ db userId contactId itemId newContent msgId = runExceptT $ |] (newContent, newText, currentTs, userId, contactId, itemId) liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs) - pure ci {content = newContent, meta = (meta ci) {itemText = newText}, formattedText = parseMaybeMarkdownList newText} + pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText} where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci @@ -2938,7 +2938,7 @@ updateGroupChatItem_ db user@User {userId} groupId itemId newContent msgId = run |] (newContent, newText, currentTs, userId, groupId, itemId) liftIO $ DB.execute db "INSERT INTO chat_item_messages (chat_item_id, message_id, created_at, updated_at) VALUES (?,?,?,?)" (itemId, msgId, currentTs, currentTs) - pure ci {content = newContent, meta = (meta ci) {itemText = newText}, formattedText = parseMaybeMarkdownList newText} + pure ci {content = newContent, meta = (meta ci) {itemText = newText, itemEdited = True}, formattedText = parseMaybeMarkdownList newText} where correctDir :: CChatItem c -> Either StoreError (ChatItem c d) correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci From 013a7322d29ac46f1ac5abf615b60b8b698f2d5c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 25 Mar 2022 20:02:40 +0000 Subject: [PATCH 12/18] ios: fix chat scrolling crashing the app (#472) --- apps/ios/Shared/Views/Helpers/DetermineWidth.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift index ba6577a9b0..d2a0aaab1d 100644 --- a/apps/ios/Shared/Views/Helpers/DetermineWidth.swift +++ b/apps/ios/Shared/Views/Helpers/DetermineWidth.swift @@ -11,12 +11,12 @@ import SwiftUI struct DetermineWidth: View { typealias Key = MaximumWidthPreferenceKey var body: some View { - GeometryReader { - proxy in + GeometryReader { proxy in Color.clear - .anchorPreference(key: Key.self, value: .bounds) { - anchor in proxy[anchor].size.width - } + .preference( + key: MaximumWidthPreferenceKey.self, + value: proxy.size.width + ) } } } From 8b2ae2d426fbcf24cc006ed154af6cc498c636e3 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 26 Mar 2022 10:49:36 +0400 Subject: [PATCH 13/18] terminal: version 1.3.4 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index ca1fb0b2d1..08291d15ec 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.3.3 +version: 1.3.4 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d81eb92431..241ed60433 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.3.3 +version: 1.3.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From bdb3bc0bd7f3df4f238ed89ea3d5254d09db913f Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 26 Mar 2022 15:08:42 +0400 Subject: [PATCH 14/18] mobile: hide edit button (#474) --- .../app/views/chat/item/ChatItemView.kt | 16 ++++++++-------- apps/ios/Shared/Views/Chat/ChatView.swift | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index c91f159c55..e5cfad3a34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -65,14 +65,14 @@ fun ChatItemView( copyText(cxt, cItem.content.text) showMenu = false }) - if (cItem.chatDir.sent && cItem.meta.editable) { - ItemAction("Edit", Icons.Filled.Edit, onClick = { - quotedItem.value = null - editingItem.value = cItem - msg.value = cItem.content.text - showMenu = false - }) - } +// if (cItem.chatDir.sent && cItem.meta.editable) { +// ItemAction("Edit", Icons.Filled.Edit, onClick = { +// quotedItem.value = null +// editingItem.value = cItem +// msg.value = cItem.content.text +// showMenu = false +// }) +// } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index cac7716dfc..c52a2e2b04 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -44,15 +44,15 @@ struct ChatView: View { Button { UIPasteboard.general.string = ci.content.text } label: { Label("Copy", systemImage: "doc.on.doc") } - if (ci.chatDir.sent && ci.meta.editable) { - Button { - withAnimation { - quotedItem = nil - editingItem = ci - message = ci.content.text - } - } label: { Label("Edit", systemImage: "square.and.pencil") } - } +// if (ci.chatDir.sent && ci.meta.editable) { +// Button { +// withAnimation { +// quotedItem = nil +// editingItem = ci +// message = ci.content.text +// } +// } label: { Label("Edit", systemImage: "square.and.pencil") } +// } } .padding(.horizontal) .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) From a81de493fe5e517762b2b8b7e38810e13f46b8d2 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 26 Mar 2022 12:23:14 +0000 Subject: [PATCH 15/18] ios: version 1.4 (30) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index f56df3377e..7ac5a13589 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -863,7 +863,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -883,7 +883,7 @@ ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -903,7 +903,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 29; + CURRENT_PROJECT_VERSION = 30; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -924,7 +924,7 @@ LIBRARY_SEARCH_PATHS = ""; "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; From a316a9575443855a22d821da3bc610b867e1552f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 26 Mar 2022 13:25:01 +0000 Subject: [PATCH 16/18] android: version 1.4 (17) --- apps/android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 7ae480de72..4a377eba51 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "chat.simplex.app" minSdk 29 targetSdk 32 - versionCode 16 - versionName "1.3" + versionCode 17 + versionName "1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { From 14a5b680d732a5165c51b4f31be8631df81b9c8f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 26 Mar 2022 13:47:47 +0000 Subject: [PATCH 17/18] core: update simplexmq (#475) * core: update simplexmq * update sha256map.nix --- cabal.project | 2 +- sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 6d3a320775..88af2f527b 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 14d76a1582ce758b2ad62203b904b223eb45eb9f + tag: 800581b2bf5dacb2134dfda751be08cbf78df978 source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index 8c86cf3aad..16c6ef8bb1 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."14d76a1582ce758b2ad62203b904b223eb45eb9f" = "1hzzpbri6afsfzchqfln2ncr12k62i0l2ddhhyspjrwbmmwkynd4"; + "https://github.com/simplex-chat/simplexmq.git"."800581b2bf5dacb2134dfda751be08cbf78df978" = "1xmn6dfwmmc84zpj9pnklxc4lh4bwwf6pv55qaqcj15crvqhvnyg"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index f9db178816..3eb93c6fa2 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 14d76a1582ce758b2ad62203b904b223eb45eb9f + commit: 800581b2bf5dacb2134dfda751be08cbf78df978 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 262c999e5c0fc9f202bafbcb8785aa1f446e7cf6 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 26 Mar 2022 18:22:45 +0400 Subject: [PATCH 18/18] terminal: version 1.4.0 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index 08291d15ec..bbbd45a0c3 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.3.4 +version: 1.4.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 241ed60433..34b6e02f15 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.3.4 +version: 1.4.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat