Merge branch 'master' into remote-desktop

This commit is contained in:
Evgeny Poberezkin 2023-10-04 16:37:15 +01:00
commit 7959c75df7
47 changed files with 704 additions and 201 deletions

View File

@ -9,6 +9,7 @@ on:
- website/** - website/**
- images/** - images/**
- blog/** - blog/**
- docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
jobs: jobs:

View File

@ -48,11 +48,6 @@
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; };
5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; };
5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */; };
5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */; };
5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625172AC1DE5900A21210 /* libgmpxx.a */; };
5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625182AC1DE5900A21210 /* libgmp.a */; };
5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625192AC1DE5900A21210 /* libffi.a */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; };
5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; };
@ -119,6 +114,11 @@
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; };
5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; };
5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; };
5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; };
5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; };
5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; };
5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; };
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
@ -293,11 +293,6 @@
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; };
5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; };
5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a"; sourceTree = "<group>"; };
5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a"; sourceTree = "<group>"; };
5C5625172AC1DE5900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C5625182AC1DE5900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C5625192AC1DE5900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; };
5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -400,6 +395,11 @@
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; }; 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = "<group>"; };
5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = "<group>"; };
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; };
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
@ -507,12 +507,12 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C56251C2AC1DE5900A21210 /* libgmpxx.a in Frameworks */, 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */,
5C56251B2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a in Frameworks */, 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */,
5C56251A2AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a in Frameworks */, 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */,
5C56251E2AC1DE5900A21210 /* libffi.a in Frameworks */, 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */,
5C56251D2AC1DE5900A21210 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -574,11 +574,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C5625192AC1DE5900A21210 /* libffi.a */, 5CC7398A2AC9D168009470A9 /* libffi.a */,
5C5625182AC1DE5900A21210 /* libgmp.a */, 5CC739892AC9D168009470A9 /* libgmp.a */,
5C5625172AC1DE5900A21210 /* libgmpxx.a */, 5CC7398B2AC9D168009470A9 /* libgmpxx.a */,
5C5625152AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7-ghc8.10.7.a */, 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */,
5C5625162AC1DE5900A21210 /* libHSsimplex-chat-5.3.1.0-625aldG8rLm27VEosiv5y7.a */, 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1486,7 +1486,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -1507,7 +1507,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1528,7 +1528,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -1549,7 +1549,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1608,7 +1608,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1621,7 +1621,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -1640,7 +1640,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@ -1653,7 +1653,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@ -1672,7 +1672,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@ -1696,7 +1696,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -1718,7 +1718,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 174; CURRENT_PROJECT_VERSION = 176;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@ -1742,7 +1742,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.3.1; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View File

@ -50,6 +50,7 @@ actual fun PlatformTextField(
userIsObserver: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit, onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
) { ) {
val cs = composeState.value val cs = composeState.value

View File

@ -5,6 +5,8 @@ import android.os.Build
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem import chat.simplex.common.model.ChatItem
@ -41,3 +43,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
showMenu.value = false showMenu.value = false
}) })
} }
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) {
clipboard.setText(AnnotatedString(cItem.content.text))
}

View File

@ -0,0 +1,39 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
@Composable
actual fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>,
stopped: Boolean,
selectedChat: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
}
if (dropdownMenuItems != null) {
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}

View File

@ -31,9 +31,9 @@ else()
set(CMAKE_BUILD_RPATH "@loader_path") set(CMAKE_BUILD_RPATH "@loader_path")
endif() endif()
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64") if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64")
set(OS_LIB_ARCH "x86_64") set(OS_LIB_ARCH "x86_64")
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64") elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64")
set(OS_LIB_ARCH "aarch64") set(OS_LIB_ARCH "aarch64")
else() else()
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}") set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
@ -55,8 +55,13 @@ add_library( # Sets the name of the library.
add_library( simplex SHARED IMPORTED ) add_library( simplex SHARED IMPORTED )
# Lib has different name because of version, find it # Lib has different name because of version, find it
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT}) FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT})
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
if(WIN32)
set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB})
else()
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
endif()
# Specifies libraries CMake should link to your target library. You # Specifies libraries CMake should link to your target library. You

View File

@ -224,6 +224,7 @@ object ChatModel {
} }
// add to current chat // add to current chat
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// Prevent situation when chat item already in the list received from backend // Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) { if (chatItems.none { it.id == cItem.id }) {
@ -231,6 +232,7 @@ object ChatModel {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
} else { } else {
chatItems.add(cItem) chatItems.add(cItem)
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
} }
} }
} }
@ -259,13 +261,16 @@ object ChatModel {
} }
// update current chat // update current chat
return if (chatId.value == cInfo.id) { return if (chatId.value == cInfo.id) {
Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
chatItems[itemIndex] = cItem chatItems[itemIndex] = cItem
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
false false
} else { } else {
chatItems.add(cItem) chatItems.add(cItem)
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
true true
} }
} }
@ -374,6 +379,7 @@ object ChatModel {
var markedRead = 0 var markedRead = 0
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
var i = 0 var i = 0
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}")
while (i < chatItems.count()) { while (i < chatItems.count()) {
val item = chatItems[i] val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
@ -388,6 +394,7 @@ object ChatModel {
} }
i += 1 i += 1
} }
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
} }
return markedRead return markedRead
} }

View File

@ -106,6 +106,7 @@ class AppPreferences {
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
@ -265,6 +266,7 @@ class AppPreferences {
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"

View File

@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import java.io.File
import java.net.URI
@Composable @Composable
expect fun PlatformTextField( expect fun PlatformTextField(
@ -14,5 +16,6 @@ expect fun PlatformTextField(
userIsObserver: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit, onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
) )

View File

@ -97,6 +97,7 @@ fun TerminalLayout(
updateLiveMessage = null, updateLiveMessage = null,
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = ::onMessageChange, onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@ -123,7 +124,18 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
} }
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } } val reversedTerminalItems by remember {
derivedStateOf {
// Such logic prevents concurrent modification
val res = ArrayList<TerminalItem>()
var i = 0
while (i < terminalItems.size) {
res.add(terminalItems[i])
i++
}
res.asReversed()
}
}
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
LazyColumn(state = listState, reverseLayout = true) { LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item -> items(reversedTerminalItems) { item ->

View File

@ -66,11 +66,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
launch { launch {
snapshotFlow { chatModel.chatId.value } snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") }
.filter { it != null && activeChat.value?.id != it } .filter { it != null && activeChat.value?.id != it }
.collect { chatId -> .collect { chatId ->
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc // Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId!!) activeChat.value = chatModel.getChat(chatId!!)
Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}")
markUnreadChatAsRead(activeChat, chatModel) markUnreadChatAsRead(activeChat, chatModel)
} }
} }
@ -89,9 +91,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") }
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
.collect { activeChat.value = it } .collect {
activeChat.value = it
Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}")
}
} }
} }
val view = LocalMultiplatformView() val view = LocalMultiplatformView()
@ -218,7 +224,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
val firstId = chatModel.chatItems.firstOrNull()?.id val firstId = chatModel.chatItems.firstOrNull()?.id
if (c != null && firstId != null) { if (c != null && firstId != null) {
withApi { withApi {
Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}")
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}")
} }
} }
}, },
@ -450,17 +458,7 @@ fun ChatLayout(
.fillMaxWidth() .fillMaxWidth()
.desktopOnExternalDrag( .desktopOnExternalDrag(
enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value,
onFiles = { paths -> onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) },
val uris = paths.map { URI.create(it) }
val groups = uris.groupBy { isImage(it) }
val images = groups[true] ?: emptyList()
val files = groups[false] ?: emptyList()
if (images.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) }
} else if (files.isNotEmpty()) {
composeState.processPickedFile(uris.first(), null)
}
},
onImage = { onImage = {
val tmpFile = File.createTempFile("image", ".bmp", tmpDir) val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit() tmpFile.deleteOnExit()

View File

@ -159,6 +159,17 @@ expect fun AttachmentSelection(
processPickedMedia: (List<URI>, String?) -> Unit processPickedMedia: (List<URI>, String?) -> Unit
) )
fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
val groups = uris.groupBy { isImage(it) }
val images = groups[true] ?: emptyList()
val files = groups[false] ?: emptyList()
if (images.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) }
} else if (files.isNotEmpty()) {
processPickedFile(uris.first(), null)
}
}
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) { fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
if (uri != null) { if (uri != null) {
val fileSize = getFileSize(uri) val fileSize = getFileSize(uri)
@ -816,6 +827,7 @@ fun ComposeView(
chatModel.removeLiveDummy() chatModel.removeLiveDummy()
}, },
editPrevMessage = ::editPrevMessage, editPrevMessage = ::editPrevMessage,
onFilesPasted = { composeState.onFilesAttached(it) },
onMessageChange = ::onMessageChange, onMessageChange = ::onMessageChange,
textStyle = textStyle textStyle = textStyle
) )

View File

@ -29,6 +29,8 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
import java.net.URI
@Composable @Composable
fun SendMsgView( fun SendMsgView(
@ -52,6 +54,7 @@ fun SendMsgView(
updateLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null,
editPrevMessage: () -> Unit, editPrevMessage: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle> textStyle: MutableState<TextStyle>
) { ) {
@ -79,7 +82,7 @@ fun SendMsgView(
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) { PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) {
if (!cs.inProgress) { if (!cs.inProgress) {
sendMessage(null) sendMessage(null)
} }
@ -612,6 +615,7 @@ fun PreviewSendMsgView() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@ -645,6 +649,7 @@ fun PreviewSendMsgViewEditing() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@ -678,6 +683,7 @@ fun PreviewSendMsgViewInProgress() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }

View File

@ -201,7 +201,7 @@ fun ChatItemView(
showMenu.value = false showMenu.value = false
}) })
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
clipboard.setText(AnnotatedString(cItem.content.text)) copyItemToClipboard(cItem, clipboard)
showMenu.value = false showMenu.value = false
}) })
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) {
@ -561,6 +561,8 @@ private fun showMsgDeliveryErrorAlert(description: String) {
) )
} }
expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager)
@Preview @Preview
@Composable @Composable
fun PreviewChatItemView() { fun PreviewChatItemView() {

View File

@ -1,7 +1,6 @@
package chat.simplex.common.views.chatlist package chat.simplex.common.views.chatlist
import SectionItemView import SectionItemView
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -14,6 +13,10 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -44,6 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
showMenu.value = false showMenu.value = false
delay(500L) delay(500L)
} }
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } }
val showChatPreviews = chatModel.showChatPreviews.value val showChatPreviews = chatModel.showChatPreviews.value
when (chat.chatInfo) { when (chat.chatInfo) {
is ChatInfo.Direct -> { is ChatInfo.Direct -> {
@ -53,7 +57,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
click = { directChatAction(chat.chatInfo, chatModel) }, click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu, showMenu,
stopped stopped,
selectedChat
) )
} }
is ChatInfo.Group -> is ChatInfo.Group ->
@ -62,7 +67,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
showMenu, showMenu,
stopped stopped,
selectedChat
) )
is ChatInfo.ContactRequest -> is ChatInfo.ContactRequest ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
@ -70,7 +76,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu, showMenu,
stopped stopped,
selectedChat
) )
is ChatInfo.ContactConnection -> is ChatInfo.ContactConnection ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
@ -84,7 +91,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}, },
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) }, dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
showMenu, showMenu,
stopped stopped,
selectedChat
) )
is ChatInfo.InvalidJSON -> is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout( ChatListNavLinkLayout(
@ -97,7 +105,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}, },
dropdownMenuItems = null, dropdownMenuItems = null,
showMenu, showMenu,
stopped stopped,
selectedChat
) )
} }
} }
@ -122,9 +131,11 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) {
} }
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) { if (chat != null) {
openChat(chat, chatModel) openChat(chat, chatModel)
Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
} }
} }
@ -628,32 +639,14 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
} }
@Composable @Composable
fun ChatListNavLinkLayout( expect fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit, chatLinkPreview: @Composable () -> Unit,
click: () -> Unit, click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?, dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>, showMenu: MutableState<Boolean>,
stopped: Boolean stopped: Boolean,
) { selectedChat: State<Boolean>
var modifier = Modifier.fillMaxWidth() )
if (!stopped) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
}
if (dropdownMenuItems != null) {
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@Preview/*( @Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES, uiMode = Configuration.UI_MODE_NIGHT_YES,
@ -690,7 +683,8 @@ fun PreviewChatListNavLinkDirect() {
click = {}, click = {},
dropdownMenuItems = null, dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }, showMenu = remember { mutableStateOf(false) },
stopped = false stopped = false,
selectedChat = remember { mutableStateOf(false) }
) )
} }
} }
@ -730,7 +724,8 @@ fun PreviewChatListNavLinkGroup() {
click = {}, click = {},
dropdownMenuItems = null, dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }, showMenu = remember { mutableStateOf(false) },
stopped = false stopped = false,
selectedChat = remember { mutableStateOf(false) }
) )
} }
} }
@ -750,7 +745,8 @@ fun PreviewChatListNavLinkContactRequest() {
click = {}, click = {},
dropdownMenuItems = null, dropdownMenuItems = null,
showMenu = remember { mutableStateOf(false) }, showMenu = remember { mutableStateOf(false) },
stopped = false stopped = false,
selectedChat = remember { mutableStateOf(false) }
) )
} }
} }

View File

@ -34,6 +34,17 @@ fun DeveloperView(
ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(it, close) })} ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(it, close) })}
SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades) SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools)
if (appPlatform.isDesktop && devTools.value) {
TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked ->
if (checked) {
withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) {
m.controller.appPrefs.terminalAlwaysVisible.set(true)
}
} else {
m.controller.appPrefs.terminalAlwaysVisible.set(false)
}
}
}
} }
SectionTextFooter( SectionTextFooter(
generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " +

View File

@ -322,6 +322,15 @@ fun ChatLockItem(
} }
} }
@Composable fun TerminalAlwaysVisibleItem(pref: SharedPreference<Boolean>, onChange: (Boolean) -> Unit) {
SettingsActionItemWithContent(painterResource(MR.images.ic_engineering), stringResource(MR.strings.terminal_always_visible), extraPadding = false) {
DefaultSwitch(
checked = remember { pref.state }.value,
onCheckedChange = onChange,
)
}
}
@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { @Composable fun InstallTerminalAppItem(uriHandler: UriHandler) {
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
Icon( Icon(

View File

@ -1056,6 +1056,7 @@
<string name="database_downgrade">Database downgrade</string> <string name="database_downgrade">Database downgrade</string>
<string name="incompatible_database_version">Incompatible database version</string> <string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string> <string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string> <string name="invalid_migration_confirmation">Invalid migration confirmation</string>
<string name="upgrade_and_open_chat">Upgrade and open chat</string> <string name="upgrade_and_open_chat">Upgrade and open chat</string>
<string name="downgrade_and_open_chat">Downgrade and open chat</string> <string name="downgrade_and_open_chat">Downgrade and open chat</string>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M46-124v-90q0-33.5 15.516-55.596Q77.03-291.692 106-305.5q54-26 114.624-42.75Q281.249-365 359-365t138.376 16.75Q558-331.5 612-305.5q28.969 13.808 44.484 35.904Q672-247.5 672-214v90H46Zm57.5-57.5h511v-32.744q0-15.256-8.75-25.006t-20.65-14.792q-42.6-19.153-95.35-36.305Q437-307.5 359-307.5t-130.75 17.153q-52.75 17.152-95.35 36.305Q121-249 112.25-239.25q-8.75 9.75-8.75 25.006v32.744Zm255.456-243q-64.456 0-105.86-41.984Q211.692-508.469 211.692-572H201.5q-8 0-14-6t-6-14q0-8 6-14t14-6h10.185q0-39.154 19.158-70.077Q250-713 282.143-733v38.459q0 6.244 4.098 10.392Q290.339-680 296.5-680q7.225 0 10.862-4.138Q311-688.275 311-694.364v-51.631q7.845-1.98 22.172-3.492Q347.5-751 360-751t26.828 1.511q14.327 1.511 22.172 3.487v51.573q0 5.929 3.638 10.179Q416.275-680 423.5-680q6.161 0 10.259-4.149 4.098-4.148 4.098-10.2V-733q32.143 20 50.3 50.923 18.158 30.923 18.158 70.077H516.5q8 0 14 6t6 14q0 8-6 14t-14 6h-10.192q0 63.531-41.448 105.516Q423.411-424.5 358.956-424.5ZM359-482q42 0 66-25t24-65H269q0 40 24 65t66 25Zm300 119.5-1.885-29q-7.115-4-14.615-9t-13.5-10l-26 14-21.5-31 26-19q-2-4-2-7.5v-15q0-3.5 2-7.5l-26-19 21.5-31 26 14q7-5.5 13.97-10.312 6.969-4.813 13.939-8.688l1.906-29h39.37l1.906 29q6.97 3.875 13.939 8.688Q721-518 728-512.5l26-14 21.5 31-26 19q2 4 2 7.5v15q0 3.5-2 7.5l26 19-21.5 31-26-14q-6 5-13.5 10t-14.5 9l-1.769 29H659Zm19.5-61q16 0 27-11t11-27q0-16-11-27t-27-11q-16 0-27 11t-11 27q0 16 11 27t27 11ZM766.349-579l-8.164-33.933Q748-617 737.179-624q-10.822-7-17.554-15L676-624l-19-33 34-27.5q-2-5-3.5-11.25T686-708q0-6 1.5-12.25t3.5-11.25L657-759l19-33 43.562 15q6.938-8 17.782-15.5Q748.188-800 758-803l8.5-34h37.151l8.164 33.933Q822-800 832.821-792.5q10.822 7.5 17.554 15.5L894-792l19 33-34 27.5q2 5 3.5 11.25T884-708q0 6-1.5 12.25T879-684.5l34 27.5-19 33-43.562-15q-6.938 8-17.782 15-10.844 7-20.656 11l-8.5 34h-37.151ZM785-650q25 0 41.5-16.5T843-708q0-25-16.5-41.5T785-766q-25 0-41.5 16.5T727-708q0 25 16.5 41.5T785-650ZM103.5-181.5h511-511Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -14,10 +14,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.defaultLocale
import chat.simplex.common.platform.desktopPlatform import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.TerminalView
import chat.simplex.common.views.helpers.FileDialogChooser import chat.simplex.common.views.helpers.FileDialogChooser
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener import java.awt.event.WindowFocusListener
@ -118,6 +121,18 @@ fun showApp() = application {
} }
} }
} }
// Reload all strings in all @Composable's after language change at runtime
if (remember { ChatController.appPrefs.terminalAlwaysVisible.state }.value && remember { ChatController.appPrefs.appLanguage.state }.value != "") {
var hiddenUntilRestart by remember { mutableStateOf(false) }
if (!hiddenUntilRestart) {
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp)
Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) {
SimpleXTheme {
TerminalView(ChatModel) { hiddenUntilRestart = true }
}
}
}
}
} }
class SimplexWindowState { class SimplexWindowState {

View File

@ -16,6 +16,7 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co
MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath);
fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64 fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64
fun isWindows() = this == WINDOWS_X86_64
fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64 fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64
} }

View File

@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.* import androidx.compose.ui.platform.*
@ -27,6 +26,9 @@ import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.io.File
import java.net.URI
import kotlin.io.path.*
import kotlin.math.min import kotlin.math.min
import kotlin.text.substring import kotlin.text.substring
@ -39,6 +41,7 @@ actual fun PlatformTextField(
userIsObserver: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit, onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
) { ) {
val cs = composeState.value val cs = composeState.value
@ -63,10 +66,20 @@ actual fun PlatformTextField(
val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) } val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) }
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) }
val textFieldValue = textFieldValueState.copy(text = cs.message) val textFieldValue = textFieldValueState.copy(text = cs.message)
val clipboard = LocalClipboardManager.current
BasicTextField( BasicTextField(
value = textFieldValue, value = textFieldValue,
onValueChange = { onValueChange = onValueChange@ {
if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it.text != "")) { if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it.text != "")) {
val diff = textFieldValueState.selection.length + (it.text.length - textFieldValueState.text.length)
if (diff > 1 && it.text != textFieldValueState.text && it.selection.max - diff >= 0) {
val pasted = it.text.substring(it.selection.max - diff, it.selection.max)
val files = parseToFiles(AnnotatedString(pasted))
if (files.isNotEmpty()) {
onFilesPasted(files)
return@onValueChange
}
}
textFieldValueState = it textFieldValueState = it
onMessageChange(it.text) onMessageChange(it.text)
} }
@ -98,6 +111,12 @@ actual fun PlatformTextField(
} else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) {
onUpArrow() onUpArrow()
true true
} else if (it.key == Key.V &&
it.type == KeyEventType.KeyDown &&
((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) &&
parseToFiles(clipboard.getText()).isNotEmpty()) {
onFilesPasted(parseToFiles(clipboard.getText()))
true
} }
else false else false
}, },
@ -142,3 +161,19 @@ private fun ComposeOverlay(textId: StringResource, textStyle: MutableState<TextS
style = textStyle.value.copy(fontStyle = FontStyle.Italic) style = textStyle.value.copy(fontStyle = FontStyle.Italic)
) )
} }
private fun parseToFiles(text: AnnotatedString?): List<URI> {
text ?: return emptyList()
val files = ArrayList<URI>()
text.lines().forEach {
try {
val uri = File(it.removePrefix("\"").removeSuffix("\"")).toURI()
val path = uri.toPath()
if (!path.exists() || !path.isAbsolute || path.isDirectory()) return emptyList()
files.add(uri)
} catch (e: Exception) {
return emptyList()
}
}
return files
}

View File

@ -54,9 +54,9 @@ actual object AudioPlayer: AudioPlayerInterface {
if (fileSource.cryptoArgs != null) { if (fileSource.cryptoArgs != null) {
val tmpFile = fileSource.createTmpFileIfNeeded() val tmpFile = fileSource.createTmpFileIfNeeded()
decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath) decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath)
player.media().prepare("file://${tmpFile.absolutePath}") player.media().prepare(tmpFile.toURI().toString().replaceFirst("file:", "file://"))
} else { } else {
player.media().prepare("file://$absoluteFilePath") player.media().prepare(File(absoluteFilePath).toURI().toString().replaceFirst("file:", "file://"))
} }
}.onFailure { }.onFailure {
Log.e(TAG, it.stackTraceToString()) Log.e(TAG, it.stackTraceToString())
@ -171,7 +171,7 @@ actual object AudioPlayer: AudioPlayerInterface {
var res: Int? = null var res: Int? = null
try { try {
val helperPlayer = AudioPlayerComponent().mediaPlayer() val helperPlayer = AudioPlayerComponent().mediaPlayer()
helperPlayer.media().startPaused("file://$unencryptedFilePath") helperPlayer.media().startPaused(File(unencryptedFilePath).toURI().toString().replaceFirst("file:", "file://"))
res = helperPlayer.duration res = helperPlayer.duration
helperPlayer.stop() helperPlayer.stop()
helperPlayer.release() helperPlayer.release()

View File

@ -7,17 +7,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatItem import chat.simplex.common.model.*
import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.*
import chat.simplex.common.platform.FileChooserLauncher
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.ui.theme.EmojiFont
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import java.io.File
import java.util.*
@Composable @Composable
actual fun ReactionIcon(text: String, fontSize: TextUnit) { actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@ -39,3 +41,23 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
showMenu.value = false showMenu.value = false
}) })
} }
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) {
val fileSource = getLoadedFileSource(cItem.file)
if (fileSource != null) {
val filePath: String = if (fileSource.cryptoArgs != null) {
val tmpFile = File(tmpDir, fileSource.filePath)
tmpFile.deleteOnExit()
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
tmpFile.absolutePath
} else {
getAppFilePath(fileSource.filePath)
}
when {
desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\""))
else -> clipboard.setText(AnnotatedString(filePath))
}
} else {
clipboard.setText(AnnotatedString(cItem.content.text))
}
}

View File

@ -0,0 +1,60 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
private object NoIndication : Indication {
private object NoIndicationInstance : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
}
}
@Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
return NoIndicationInstance
}
}
@Composable
actual fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>,
stopped: Boolean,
selectedChat: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier
.background(color = if (selectedChat.value) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
CompositionLocalProvider(
LocalIndication provides if (selectedChat.value && !stopped) NoIndication else LocalIndication.current
) {
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
}
if (dropdownMenuItems != null) {
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
}
}
}
Divider()
}

View File

@ -10,12 +10,12 @@ import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.* import androidx.compose.ui.window.*
import chat.simplex.common.DialogParams import chat.simplex.common.DialogParams
import chat.simplex.common.platform.desktopPlatform
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.awt.FileDialog import java.awt.FileDialog
import java.io.File import java.io.File
import java.util.*
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileFilter
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
@ -53,7 +53,7 @@ fun FrameWindowScope.FileDialogChooser(
params: DialogParams, params: DialogParams,
onResult: (result: List<File>) -> Unit onResult: (result: List<File>) -> Unit
) { ) {
if (isLinux()) { if (desktopPlatform.isLinux() || desktopPlatform.isWindows()) {
FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult) FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult)
} else { } else {
FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult) FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult)
@ -121,7 +121,7 @@ fun FrameWindowScope.FileDialogChooserMultiple(
} }
/* /*
* Has graphic glitches on many Linux distributions, so use only on non-Linux systems * Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows
* */ * */
@Composable @Composable
private fun FrameWindowScope.FileDialogAwt( private fun FrameWindowScope.FileDialogAwt(
@ -159,5 +159,3 @@ private fun FrameWindowScope.FileDialogAwt(
}, },
dispose = FileDialog::dispose dispose = FileDialog::dispose
) )
fun isLinux(): Boolean = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) == "linux"

View File

@ -88,7 +88,7 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
} }
actual fun getAppFileUri(fileName: String): URI = actual fun getAppFileUri(fileName: String): URI =
URI("file:" + appFilesDir.absolutePath + File.separator + fileName) URI(appFilesDir.toURI().toString() + "/" + fileName)
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? { actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
val filePath = getLoadedFilePath(file) val filePath = getLoadedFilePath(file)

View File

@ -63,9 +63,10 @@ compose {
windows { windows {
packageName = "SimpleX" packageName = "SimpleX"
iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico")) iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico"))
console = true console = false
perUserInstall = true perUserInstall = false
dirChooser = true dirChooser = true
shortcut = true
} }
macOS { macOS {
packageName = "SimpleX" packageName = "SimpleX"
@ -119,9 +120,9 @@ cmake {
/*machines.customMachines.register("linux-aarch64") { /*machines.customMachines.register("linux-aarch64") {
toolchainFile.set(project.file("$cppPath/toolchains/aarch64-linux-gnu-gcc.cmake")) toolchainFile.set(project.file("$cppPath/toolchains/aarch64-linux-gnu-gcc.cmake"))
}*/ }*/
machines.customMachines.register("win-amd64") { /*machines.customMachines.register("win-amd64") {
toolchainFile.set(project.file("$cppPath/toolchains/x86_64-windows-mingw32-gcc.cmake")) toolchainFile.set(project.file("$cppPath/toolchains/x86_64-windows-mingw32-gcc.cmake"))
} }*/
if (machines.host.name == "mac-amd64") { if (machines.host.name == "mac-amd64") {
machines.customMachines.register("mac-amd64") { machines.customMachines.register("mac-amd64") {
toolchainFile.set(project.file("$cppPath/toolchains/x86_64-mac-apple-darwin-gcc.cmake")) toolchainFile.set(project.file("$cppPath/toolchains/x86_64-mac-apple-darwin-gcc.cmake"))
@ -139,6 +140,9 @@ cmake {
val main by creating { val main by creating {
cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt")) cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt"))
targetMachines.addAll(compileMachineTargets.toSet()) targetMachines.addAll(compileMachineTargets.toSet())
if (machines.host.name.contains("win")) {
cmakeArgs.add("-G MinGW Makefiles")
}
} }
} }
} }
@ -191,7 +195,7 @@ afterEvaluate {
copyIfNeeded(destinationDir, copyDetails) copyIfNeeded(destinationDir, copyDetails)
} }
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
into("src/jvmMain/resources/libs/windows-x86_64") into("src/jvmMain/resources/libs/windows-x86_64")
include("*.dll") include("*.dll")
eachFile { eachFile {

View File

@ -18,13 +18,15 @@ fun main() {
@Suppress("UnsafeDynamicallyLoadedCode") @Suppress("UnsafeDynamicallyLoadedCode")
private fun initHaskell() { private fun initHaskell() {
val libApp = "libapp-lib.${desktopPlatform.libExtension}"
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
System.load(File(libsTmpDir, libApp).absolutePath)
vlcDir.deleteRecursively() vlcDir.deleteRecursively()
Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING)
if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) {
windowsLoadRequiredLibs(libsTmpDir)
} else {
System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath)
}
// No picture without preloading it, only sound. However, with libs from AppImage it works without preloading // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading
//val libXcb = "libvlc_xcb_events.so.0.0.0" //val libXcb = "libvlc_xcb_events.so.0.0.0"
//System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath)
@ -55,3 +57,25 @@ private fun copyResources(from: String, to: Path) {
} }
}) })
} }
private fun windowsLoadRequiredLibs(libsTmpDir: File) {
val mainLibs = arrayOf(
"libcrypto-3-x64.dll",
"libffi-8.dll",
"libgmp-10.dll",
"libsimplex.dll",
"libapp-lib.dll"
)
mainLibs.forEach {
System.load(File(libsTmpDir, it).absolutePath)
}
val vlcLibs = arrayOf(
"libvlccore.dll",
"libvlc.dll",
"axvlc.dll",
"npvlc.dll"
)
vlcLibs.forEach {
System.load(File(vlcDir, it).absolutePath)
}
}

View File

@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
android.enableJetifier=true android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.androidSourceSetLayoutVersion=2
android.version_name=5.3.1 android.version_name=5.4-beta.0
android.version_code=154 android.version_code=156
desktop.version_name=5.3.1 desktop.version_name=5.4-beta.0
desktop.version_code=11 desktop.version_code=12
kotlin.version=1.8.20 kotlin.version=1.8.20
gradle.plugin.version=7.4.2 gradle.plugin.version=7.4.2

View File

@ -96,7 +96,7 @@ Incognito mode was [added a year ago](./20220901-simplex-chat-v3.2-incognito-mod
It is now simpler to use - you can decide whether to connect to a contact or join a group using your main profile at a point when you create an invitation link or connect via a link or QR code. It is now simpler to use - you can decide whether to connect to a contact or join a group using your main profile at a point when you create an invitation link or connect via a link or QR code.
When you are connecting to people your know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile. When you are connecting to people you know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile.
## SimpleX platform ## SimpleX platform

View File

@ -1,13 +1,15 @@
--- ---
title: Download SimpleX apps title: Download SimpleX apps
permalink: /downloads/index.html permalink: /downloads/index.html
revision: 20.09.2023 revision: 01.10.2023
--- ---
| Updated 20.09.2023 | Languages: EN | | Updated 01.10.2023 | Languages: EN |
# Download SimpleX apps # Download SimpleX apps
The latest version is v5.3.1. The latest stable version is v5.3.1.
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
- [desktop](#desktop-app) - [desktop](#desktop-app)
- [mobile](#mobile-apps) - [mobile](#mobile-apps)
@ -23,7 +25,7 @@ Using the same profile as on mobile device is not yet supported you need to
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
**Windows**: coming soon. **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA).
## Mobile apps ## Mobile apps

View File

@ -1,5 +1,5 @@
name: simplex-chat name: simplex-chat
version: 5.3.1.0 version: 5.4.0.0
#synopsis: #synopsis:
#description: #description:
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme

View File

@ -7,7 +7,7 @@ function readlink() {
} }
if [ -z "${1}" ]; then if [ -z "${1}" ]; then
echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download_libs.sh https://something.com/job/something/{master,stable}" echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-libs.sh https://something.com/job/something/{master,stable}"
exit 1 exit 1
fi fi

View File

@ -0,0 +1,22 @@
#!/bin/bash
set -e
function readlink() {
echo "$(cd "$(dirname "$1")"; pwd -P)"
}
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
OS=windows
ARCH=`uname -a | rev | cut -d' ' -f2 | rev`
JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL}
cd $root_dir
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/
rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
scripts/desktop/download-lib-windows.sh $JOB_REPO
scripts/desktop/prepare-vlc-windows.sh

View File

@ -0,0 +1,27 @@
#!/bin/bash
set -e
function readlink() {
echo "$(cd "$(dirname "$1")"; pwd -P)"
}
if [ -z "${1}" ]; then
echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}"
exit 1
fi
job_repo=$1
arch=x86_64
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/"
mkdir -p "$output_dir"/deps 2> /dev/null
curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \
$WINDIR\\System32\\tar.exe -xf libsimplex.zip && \
mv libsimplex.dll "$output_dir" && \
mv libcrypto*.dll "$output_dir/deps" && \
mv libffi*.dll "$output_dir/deps" && \
mv libgmp*.dll "$output_dir/deps" && \
rm libsimplex.zip

View File

@ -0,0 +1,25 @@
#!/bin/bash
set -e
function readlink() {
echo "$(cd "$(dirname "$1")"; pwd -P)"
}
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc
rm -rf $vlc_dir
mkdir -p $vlc_dir/vlc || exit 0
cd /tmp
mkdir tmp 2>/dev/null || true
cd tmp
curl https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc
$WINDIR\\System32\\tar.exe -xf vlc
cd vlc-*
# Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy
find plugins | grep ".dll" | xargs touch -m -d "1970-01-01T00:00:00Z"
./vlc-cache-gen plugins
cp *.dll $vlc_dir/
cp -r -p plugins/ $vlc_dir/vlc/plugins
cd ../../
rm -rf tmp

View File

@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack -- see: https://github.com/sol/hpack
name: simplex-chat name: simplex-chat
version: 5.3.1.0 version: 5.4.0.0
category: Web, System, Services, Cryptography category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat author: simplex.chat
@ -492,6 +492,7 @@ test-suite simplex-chat-test
ProtocolTests ProtocolTests
RemoteTests RemoteTests
SchemaDump SchemaDump
ValidNames
ViewTests ViewTests
WebRTCTests WebRTCTests
Broadcast.Bot Broadcast.Bot

View File

@ -32,7 +32,7 @@ import Data.Bifunctor (bimap, first)
import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString) import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace, toLower) import Data.Char
import Data.Constraint (Dict (..)) import Data.Constraint (Dict (..))
import Data.Either (fromRight, rights) import Data.Either (fromRight, rights)
import Data.Fixed (div') import Data.Fixed (div')
@ -369,6 +369,7 @@ processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse
processChatCommand = \case processChatCommand = \case
ShowActiveUser -> withUser' $ pure . CRActiveUser ShowActiveUser -> withUser' $ pure . CRActiveUser
CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do
forM_ profile $ \Profile {displayName} -> checkValidName displayName
p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile
u <- asks currentUser u <- asks currentUser
(smp, smpServers) <- chooseServers SPSMP (smp, smpServers) <- chooseServers SPSMP
@ -907,7 +908,8 @@ processChatCommand = \case
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
withChatLock "deleteChat direct" . procCmd $ do withChatLock "deleteChat direct" . procCmd $ do
deleteFilesAndConns user filesInfo deleteFilesAndConns user filesInfo
when (contactActive ct && notify) . void $ sendDirectContactMessage ct XDirectDel when (isReady ct && contactActive ct && notify) $
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct)
deleteAgentConnectionsAsync user contactConnIds deleteAgentConnectionsAsync user contactConnIds
-- functions below are called in separate transactions to prevent crashes on android -- functions below are called in separate transactions to prevent crashes on android
@ -1466,7 +1468,8 @@ processChatCommand = \case
chatRef <- getChatRef user chatName chatRef <- getChatRef user chatName
chatItemId <- getChatItemIdByText user chatRef msg chatItemId <- getChatItemIdByText user chatRef msg
processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction
APINewGroup userId gProfile -> withUserId userId $ \user -> do APINewGroup userId gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do
checkValidName displayName
gVar <- asks idsDrg gVar <- asks idsDrg
groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile
pure $ CRGroupCreated user groupInfo pure $ CRGroupCreated user groupInfo
@ -1983,9 +1986,10 @@ processChatCommand = \case
updateProfile :: User -> Profile -> m ChatResponse updateProfile :: User -> Profile -> m ChatResponse
updateProfile user p' = updateProfile_ user p' $ withStore $ \db -> updateUserProfile db user p' updateProfile user p' = updateProfile_ user p' $ withStore $ \db -> updateUserProfile db user p'
updateProfile_ :: User -> Profile -> m User -> m ChatResponse updateProfile_ :: User -> Profile -> m User -> m ChatResponse
updateProfile_ user@User {profile = p} p' updateUser updateProfile_ user@User {profile = p@LocalProfile {displayName = n}} p'@Profile {displayName = n'} updateUser
| p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user
| otherwise = do | otherwise = do
when (n /= n') $ checkValidName n'
-- read contacts before user update to correctly merge preferences -- read contacts before user update to correctly merge preferences
-- [incognito] filter out contacts with whom user has incognito connections -- [incognito] filter out contacts with whom user has incognito connections
contacts <- contacts <-
@ -2027,8 +2031,9 @@ processChatCommand = \case
when (directOrUsed ct') $ createSndFeatureItems user ct ct' when (directOrUsed ct') $ createSndFeatureItems user ct ct'
pure $ CRContactPrefsUpdated user ct ct' pure $ CRContactPrefsUpdated user ct ct'
runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse
runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p@GroupProfile {displayName = n}} ms) p'@GroupProfile {displayName = n'} = do
assertUserGroupRole g GROwner assertUserGroupRole g GROwner
when (n /= n') $ checkValidName n'
g' <- withStore $ \db -> updateGroupProfile db user g p' g' <- withStore $ \db -> updateGroupProfile db user g p'
(msg, _) <- sendGroupMessage user g' ms (XGrpInfo p') (msg, _) <- sendGroupMessage user g' ms (XGrpInfo p')
let cd = CDGroupSnd g' let cd = CDGroupSnd g'
@ -2037,6 +2042,10 @@ processChatCommand = \case
toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci)
createGroupFeatureChangedItems user cd CISndGroupFeature g g' createGroupFeatureChangedItems user cd CISndGroupFeature g g'
pure $ CRGroupUpdated user g g' Nothing pure $ CRGroupUpdated user g g' Nothing
checkValidName :: GroupName -> m ()
checkValidName displayName = do
let validName = T.pack $ mkValidName $ T.unpack displayName
when (displayName /= validName) $ throwChatError CEInvalidDisplayName {displayName, validName}
assertUserGroupRole :: GroupInfo -> GroupMemberRole -> m () assertUserGroupRole :: GroupInfo -> GroupMemberRole -> m ()
assertUserGroupRole g@GroupInfo {membership} requiredRole = do assertUserGroupRole g@GroupInfo {membership} requiredRole = do
when (membership.memberRole < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole when (membership.memberRole < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole
@ -5266,8 +5275,7 @@ getCreateActiveUser st testView = do
where where
loop = do loop = do
displayName <- getContactName displayName <- getContactName
fullName <- T.pack <$> getWithPrompt "full name (optional)" withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case
withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName, image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case
Left SEDuplicateName -> do Left SEDuplicateName -> do
putStrLn "chosen display name is already used by another profile on this device, choose another one" putStrLn "chosen display name is already used by another profile on this device, choose another one"
loop loop
@ -5297,10 +5305,13 @@ getCreateActiveUser st testView = do
T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")" T.unpack $ localDisplayName <> if T.null fullName || localDisplayName == fullName then "" else " (" <> fullName <> ")"
getContactName :: IO ContactName getContactName :: IO ContactName
getContactName = do getContactName = do
displayName <- getWithPrompt "display name (no spaces)" displayName <- getWithPrompt "display name"
if null displayName || isJust (find (== ' ') displayName) let validName = mkValidName displayName
then putStrLn "display name has space(s), choose another one" >> getContactName if
else pure $ T.pack displayName | null displayName -> putStrLn "display name can't be empty" >> getContactName
| null validName -> putStrLn "display name is invalid, please choose another" >> getContactName
| displayName /= validName -> putStrLn ("display name is invalid, you could use this one: " <> validName) >> getContactName
| otherwise -> pure $ T.pack displayName
getWithPrompt :: String -> IO String getWithPrompt :: String -> IO String
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
@ -5644,7 +5655,13 @@ chatCommandP =
mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString
msgContentP = "text " *> mcTextP <|> "json " *> jsonP msgContentP = "text " *> mcTextP <|> "json " *> jsonP
ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace)
where
takeNameTill p =
A.peekChar' >>= \c ->
if refChar c then A.takeTill p else fail "invalid first character in display name"
quoted cs = A.choice [A.char c *> takeNameTill (== c) <* A.char c | c <- cs]
refChar c = c > ' ' && c /= '#' && c /= '@'
sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP
quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space
reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar))
@ -5657,7 +5674,6 @@ chatCommandP =
'*' -> head "❤️" '*' -> head "❤️"
'^' -> '🚀' '^' -> '🚀'
c -> c c -> c
refChar c = c > ' ' && c /= '#' && c /= '@'
liveMessageP = " live=" *> onOffP <|> pure False liveMessageP = " live=" *> onOffP <|> pure False
sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing
receiptSettings = do receiptSettings = do
@ -5752,3 +5768,16 @@ timeItToView s action = do
let diff = diffToMilliseconds $ diffUTCTime t2 t1 let diff = diffToMilliseconds $ diffUTCTime t2 t1
toView $ CRTimedAction s diff toView $ CRTimedAction s diff
pure a pure a
mkValidName :: String -> String
mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL')
where
addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev)
where
c' = if isSpace c then ' ' else c
validChar
| prev == '\NUL' || isSpace prev = validFirstChar
| isPunctuation prev = validFirstChar || isSpace c
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
validFirstChar = isLetter c || isNumber c || isSymbol c
notProhibited = c `notElem` ("@#'\"`" :: String)

View File

@ -939,6 +939,7 @@ data ChatErrorType
| CEEmptyUserPassword {userId :: UserId} | CEEmptyUserPassword {userId :: UserId}
| CEUserAlreadyHidden {userId :: UserId} | CEUserAlreadyHidden {userId :: UserId}
| CEUserNotHidden {userId :: UserId} | CEUserNotHidden {userId :: UserId}
| CEInvalidDisplayName {displayName :: Text, validName :: Text}
| CEChatNotStarted | CEChatNotStarted
| CEChatNotStopped | CEChatNotStopped
| CEChatStoreChanged | CEChatStoreChanged

View File

@ -69,6 +69,8 @@ foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSON
foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString
foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString
foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString
@ -136,6 +138,10 @@ cChatPasswordHash cPwd cSalt = do
salt <- B.packCString cSalt salt <- B.packCString cSalt
newCStringFromBS $ chatPasswordHash pwd salt newCStringFromBS $ chatPasswordHash pwd salt
-- This function supports utf8 strings
cChatValidName :: CString -> IO CString
cChatValidName cName = newCString . mkValidName =<< peekCString cName
mobileChatOpts :: String -> String -> ChatOpts mobileChatOpts :: String -> String -> ChatOpts
mobileChatOpts dbFilePrefix dbKey = mobileChatOpts dbFilePrefix dbKey =
ChatOpts ChatOpts

View File

@ -15,7 +15,7 @@ import Data.Aeson (ToJSON)
import qualified Data.Aeson as J import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (toUpper) import Data.Char (isSpace, toUpper)
import Data.Function (on) import Data.Function (on)
import Data.Int (Int64) import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn) import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
@ -225,7 +225,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"]
CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"]
CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"]
CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the local copy of the group"]
CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m
CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupProfile u g -> ttyUser u $ viewGroupProfile g
CRGroupDescription u g -> ttyUser u $ viewGroupDescription g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g
@ -692,10 +692,7 @@ viewContactNotFound cName suspectedMember =
["no contact " <> ttyContact cName <> useMessageMember] ["no contact " <> ttyContact cName <> useMessageMember]
where where
useMessageMember = case suspectedMember of useMessageMember = case suspectedMember of
Just (g, m) -> do Just (g, m) -> ", use " <> highlight ("@#" <> viewGroupName g <> " " <> viewMemberName m <> " <your message>")
let GroupInfo {localDisplayName = gName} = g
GroupMember {localDisplayName = mName} = m
", use " <> highlight' ("@#" <> T.unpack gName <> " " <> T.unpack mName <> " <your message>")
_ -> "" _ -> ""
viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared :: AChatInfo -> [StyledString]
@ -750,14 +747,14 @@ groupLink_ intro g cReq mRole =
(plain . strEncode) cReq, (plain . strEncode) cReq,
"", "",
"Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c <group_link_above>", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c <group_link_above>",
"to show it again: " <> highlight ("/show link #" <> groupName' g), "to show it again: " <> highlight ("/show link #" <> viewGroupName g),
"to delete it: " <> highlight ("/delete link #" <> groupName' g) <> " (joined members will remain connected to you)" "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)"
] ]
viewGroupLinkDeleted :: GroupInfo -> [StyledString] viewGroupLinkDeleted :: GroupInfo -> [StyledString]
viewGroupLinkDeleted g = viewGroupLinkDeleted g =
[ "Group link is deleted - joined members will remain connected.", [ "Group link is deleted - joined members will remain connected.",
"To create a new group link use " <> highlight ("/create link #" <> groupName' g) "To create a new group link use " <> highlight ("/create link #" <> viewGroupName g)
] ]
viewSentInvitation :: Maybe Profile -> Bool -> [StyledString] viewSentInvitation :: Maybe Profile -> Bool -> [StyledString]
@ -774,20 +771,20 @@ viewSentInvitation incognitoProfile testView =
viewReceivedContactRequest :: ContactName -> Profile -> [StyledString] viewReceivedContactRequest :: ContactName -> Profile -> [StyledString]
viewReceivedContactRequest c Profile {fullName} = viewReceivedContactRequest c Profile {fullName} =
[ ttyFullName c fullName <> " wants to connect to you!", [ ttyFullName c fullName <> " wants to connect to you!",
"to accept: " <> highlight ("/ac " <> c), "to accept: " <> highlight ("/ac " <> viewName c),
"to reject: " <> highlight ("/rc " <> c) <> " (the sender will NOT be notified)" "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)"
] ]
viewGroupCreated :: GroupInfo -> [StyledString] viewGroupCreated :: GroupInfo -> [StyledString]
viewGroupCreated g@GroupInfo {localDisplayName = n} = viewGroupCreated g =
[ "group " <> ttyFullGroup g <> " is created", [ "group " <> ttyFullGroup g <> " is created",
"to add members use " <> highlight ("/a " <> n <> " <name>") <> " or " <> highlight ("/create link #" <> n) "to add members use " <> highlight ("/a " <> viewGroupName g <> " <name>") <> " or " <> highlight ("/create link #" <> viewGroupName g)
] ]
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
viewCannotResendInvitation GroupInfo {localDisplayName = gn} c = viewCannotResendInvitation g c =
[ ttyContact c <> " is already invited to group " <> ttyGroup gn, [ ttyContact c <> " is already invited to group " <> ttyGroup' g,
"to re-send invitation: " <> highlight ("/rm " <> gn <> " " <> c) <> ", " <> highlight ("/a " <> gn <> " " <> c) "to re-send invitation: " <> highlight ("/rm " <> viewGroupName g <> " " <> c) <> ", " <> highlight ("/a " <> viewGroupName g <> " " <> viewName c)
] ]
viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString]
@ -808,11 +805,11 @@ viewReceivedGroupInvitation :: GroupInfo -> Contact -> GroupMemberRole -> [Style
viewReceivedGroupInvitation g c role = viewReceivedGroupInvitation g c role =
ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) : ttyFullGroup g <> ": " <> ttyContact' c <> " invites you to join the group as " <> plain (strEncode role) :
case incognitoMembershipProfile g of case incognitoMembershipProfile g of
Just mp -> ["use " <> highlight ("/j " <> groupName' g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)] Just mp -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to join incognito as " <> incognitoProfile' (fromLocalProfile mp)]
Nothing -> ["use " <> highlight ("/j " <> groupName' g) <> " to accept"] Nothing -> ["use " <> highlight ("/j " <> viewGroupName g) <> " to accept"]
groupPreserved :: GroupInfo -> [StyledString] groupPreserved :: GroupInfo -> [StyledString]
groupPreserved g = ["use " <> highlight ("/d #" <> groupName' g) <> " to delete the group"] groupPreserved g = ["use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group"]
connectedMember :: GroupMember -> StyledString connectedMember :: GroupMember -> StyledString
connectedMember m = case memberCategory m of connectedMember m = case memberCategory m of
@ -863,7 +860,7 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt
_ -> "" _ -> ""
viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString]
viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView = viewContactConnected ct userIncognitoProfile testView =
case userIncognitoProfile of case userIncognitoProfile of
Just profile -> Just profile ->
if testView if testView
@ -872,7 +869,7 @@ viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView
where where
message = message =
[ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile, [ ttyFullContact ct <> ": contact is connected, your incognito profile for this contact is " <> incognitoProfile' profile,
"use " <> highlight ("/i " <> localDisplayName) <> " to print out this incognito profile again" "use " <> highlight ("/i " <> viewContactName ct) <> " to print out this incognito profile again"
] ]
Nothing -> Nothing ->
[ttyFullContact ct <> ": contact is connected"] [ttyFullContact ct <> ": contact is connected"]
@ -883,10 +880,10 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs
where where
ldn_ :: GroupInfo -> Text ldn_ :: GroupInfo -> Text
ldn_ g = T.toLower g.localDisplayName ldn_ g = T.toLower g.localDisplayName
groupSS (g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings}, GroupSummary {currentMembers}) = groupSS (g@GroupInfo {membership, chatSettings}, GroupSummary {currentMembers}) =
case memberStatus membership of case memberStatus membership of
GSMemInvited -> groupInvitation' g GSMemInvited -> groupInvitation' g
s -> membershipIncognito g <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s
where where
viewMemberStatus = \case viewMemberStatus = \case
GSMemRemoved -> delete "you are removed" GSMemRemoved -> delete "you are removed"
@ -894,18 +891,18 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs
GSMemGroupDeleted -> delete "group deleted" GSMemGroupDeleted -> delete "group deleted"
_ _
| enableNtfs chatSettings -> " (" <> memberCount <> ")" | enableNtfs chatSettings -> " (" <> memberCount <> ")"
| otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> ldn) <> ")" | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")"
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")"
memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s"
groupInvitation' :: GroupInfo -> StyledString groupInvitation' :: GroupInfo -> StyledString
groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} = groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} =
highlight ("#" <> ldn) highlight ("#" <> viewName ldn)
<> optFullName ldn fullName <> optFullName ldn fullName
<> " - you are invited (" <> " - you are invited ("
<> highlight ("/j " <> ldn) <> highlight ("/j " <> viewName ldn)
<> joinText <> joinText
<> highlight ("/d #" <> ldn) <> highlight ("/d #" <> viewName ldn)
<> " to delete invitation)" <> " to delete invitation)"
where where
joinText = case incognitoMembershipProfile g of joinText = case incognitoMembershipProfile g of
@ -913,21 +910,21 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil
Nothing -> " to join, " Nothing -> " to join, "
viewContactsMerged :: Contact -> Contact -> [StyledString] viewContactsMerged :: Contact -> Contact -> [StyledString]
viewContactsMerged _into@Contact {localDisplayName = c1} _merged@Contact {localDisplayName = c2} = viewContactsMerged c1 c2 =
[ "contact " <> ttyContact c2 <> " is merged into " <> ttyContact c1, [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1,
"use " <> ttyToContact c1 <> highlight' "<message>" <> " to send messages" "use " <> ttyToContact' c1 <> highlight' "<message>" <> " to send messages"
] ]
viewUserProfile :: Profile -> [StyledString] viewUserProfile :: Profile -> [StyledString]
viewUserProfile Profile {displayName, fullName} = viewUserProfile Profile {displayName, fullName} =
[ "user profile: " <> ttyFullName displayName fullName, [ "user profile: " <> ttyFullName displayName fullName,
"use " <> highlight' "/p <display name> [<full name>]" <> " to change it", "use " <> highlight' "/p <display name>" <> " to change it",
"(the updated profile will be sent to all your contacts)" "(the updated profile will be sent to all your contacts)"
] ]
viewUserPrivacy :: User -> User -> [StyledString] viewUserPrivacy :: User -> User -> [StyledString]
viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', showNtfs, viewPwdHash} = viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', showNtfs, viewPwdHash} =
[ (if userId == userId' then "current " else "") <> "user " <> plain n' <> ":", [ plain $ (if userId == userId' then "current " else "") <> "user " <> viewName n' <> ":",
"messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)", "messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)",
"profile is " <> if isJust viewPwdHash then "hidden" else "visible" "profile is " <> if isJust viewPwdHash then "hidden" else "visible"
] ]
@ -1073,18 +1070,18 @@ viewGroupMemberSwitch g m (SwitchProgress qd phase _) = case qd of
QDSnd -> [ttyGroup' g <> ": " <> ttyMember m <> " " <> viewSwitchPhase phase <> " for you"] QDSnd -> [ttyGroup' g <> ": " <> ttyMember m <> " " <> viewSwitchPhase phase <> " for you"]
viewContactRatchetSync :: Contact -> RatchetSyncProgress -> [StyledString] viewContactRatchetSync :: Contact -> RatchetSyncProgress -> [StyledString]
viewContactRatchetSync ct@Contact {localDisplayName = c} RatchetSyncProgress {ratchetSyncStatus = rss} = viewContactRatchetSync ct RatchetSyncProgress {ratchetSyncStatus = rss} =
[ttyContact' ct <> ": " <> (plain . ratchetSyncStatusToText) rss] [ttyContact' ct <> ": " <> (plain . ratchetSyncStatusToText) rss]
<> help <> help
where where
help = ["use " <> highlight ("/sync " <> c) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] help = ["use " <> highlight ("/sync " <> viewContactName ct) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]]
viewGroupMemberRatchetSync :: GroupInfo -> GroupMember -> RatchetSyncProgress -> [StyledString] viewGroupMemberRatchetSync :: GroupInfo -> GroupMember -> RatchetSyncProgress -> [StyledString]
viewGroupMemberRatchetSync g m@GroupMember {localDisplayName = n} RatchetSyncProgress {ratchetSyncStatus = rss} = viewGroupMemberRatchetSync g m RatchetSyncProgress {ratchetSyncStatus = rss} =
[ttyGroup' g <> " " <> ttyMember m <> ": " <> (plain . ratchetSyncStatusToText) rss] [ttyGroup' g <> " " <> ttyMember m <> ": " <> (plain . ratchetSyncStatusToText) rss]
<> help <> help
where where
help = ["use " <> highlight ("/sync #" <> groupName' g <> " " <> n) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]] help = ["use " <> highlight ("/sync #" <> viewGroupName g <> " " <> viewMemberName m) <> " to synchronize" | rss `elem` [RSAllowed, RSRequired]]
viewContactVerificationReset :: Contact -> [StyledString] viewContactVerificationReset :: Contact -> [StyledString]
viewContactVerificationReset ct = viewContactVerificationReset ct =
@ -1095,10 +1092,10 @@ viewGroupMemberVerificationReset g m =
[ttyGroup' g <> " " <> ttyMember m <> ": security code changed"] [ttyGroup' g <> " " <> ttyMember m <> ": security code changed"]
viewContactCode :: Contact -> Text -> Bool -> [StyledString] viewContactCode :: Contact -> Text -> Bool -> [StyledString]
viewContactCode ct@Contact {localDisplayName = c} = viewSecurityCode (ttyContact' ct) ("/verify " <> c <> " <code from your contact>") viewContactCode ct = viewSecurityCode (ttyContact' ct) ("/verify " <> viewContactName ct <> " <code from your contact>")
viewGroupMemberCode :: GroupInfo -> GroupMember -> Text -> Bool -> [StyledString] viewGroupMemberCode :: GroupInfo -> GroupMember -> Text -> Bool -> [StyledString]
viewGroupMemberCode g m@GroupMember {localDisplayName = n} = viewSecurityCode (ttyGroup' g <> " " <> ttyMember m) ("/verify #" <> groupName' g <> " " <> n <> " <code from your contact>") viewGroupMemberCode g m = viewSecurityCode (ttyGroup' g <> " " <> ttyMember m) ("/verify #" <> viewGroupName g <> " " <> viewMemberName m <> " <code from your contact>")
viewSecurityCode :: StyledString -> Text -> Text -> Bool -> [StyledString] viewSecurityCode :: StyledString -> Text -> Text -> Bool -> [StyledString]
viewSecurityCode name cmd code testView viewSecurityCode name cmd code testView
@ -1224,9 +1221,9 @@ bold' :: String -> StyledString
bold' = styled Bold bold' = styled Bold
viewContactAliasUpdated :: Contact -> [StyledString] viewContactAliasUpdated :: Contact -> [StyledString]
viewContactAliasUpdated Contact {localDisplayName = n, profile = LocalProfile {localAlias}} viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}}
| localAlias == "" = ["contact " <> ttyContact n <> " alias removed"] | localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"]
| otherwise = ["contact " <> ttyContact n <> " alias updated: " <> plain localAlias] | otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias]
viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString] viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString]
viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias}
@ -1403,10 +1400,10 @@ savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, f
savingFile' _ _ = ["saving file"] -- shouldn't happen savingFile' _ _ = ["saving file"] -- shouldn't happen
receivingFile_' :: StyledString -> AChatItem -> [StyledString] receivingFile_' :: StyledString -> AChatItem -> [StyledString]
receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) =
[status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c]
receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) =
[status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact m] [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m]
receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
@ -1599,6 +1596,9 @@ viewChatError logLevel = \case
CEEmptyUserPassword _ -> ["user password is required"] CEEmptyUserPassword _ -> ["user password is required"]
CEUserAlreadyHidden _ -> ["user is already hidden"] CEUserAlreadyHidden _ -> ["user is already hidden"]
CEUserNotHidden _ -> ["user is not hidden"] CEUserNotHidden _ -> ["user is not hidden"]
CEInvalidDisplayName {displayName, validName} -> map plain $
["invalid display name: " <> viewName displayName]
<> ["you could use this one: " <> viewName validName | not (T.null validName)]
CEChatNotStarted -> ["error: chat not started"] CEChatNotStarted -> ["error: chat not started"]
CEChatNotStopped -> ["error: chat not stopped"] CEChatNotStopped -> ["error: chat not stopped"]
CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"]
@ -1611,8 +1611,8 @@ viewChatError logLevel = \case
] ]
CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotFound cName m_ -> viewContactNotFound cName m_
CEContactNotReady c -> [ttyContact' c <> ": not ready"] CEContactNotReady c -> [ttyContact' c <> ": not ready"]
CEContactDisabled ct -> [ttyContact' ct <> ": disabled, to enable: " <> highlight ("/enable " <> viewContactName ct) <> ", to delete: " <> highlight ("/d " <> viewContactName ct)]
CEContactNotActive c -> [ttyContact' c <> ": not active"] CEContactNotActive c -> [ttyContact' c <> ": not active"]
CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)]
CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning]
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"] CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
@ -1624,7 +1624,7 @@ viewChatError logLevel = \case
CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"]
CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"]
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]
CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> groupName' g)] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> viewGroupName g)]
CEGroupMemberNotActive -> ["your group connection is not active yet, try later"] CEGroupMemberNotActive -> ["your group connection is not active yet, try later"]
CEGroupMemberUserRemoved -> ["you are no longer a member of the group"] CEGroupMemberUserRemoved -> ["you are no longer a member of the group"]
CEGroupMemberNotFound -> ["group doesn't have this member"] CEGroupMemberNotFound -> ["group doesn't have this member"]
@ -1684,8 +1684,8 @@ viewChatError logLevel = \case
SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file
SEConnectionNotFound agentConnId -> ["event connection not found, agent ID: " <> sShow agentConnId | logLevel <= CLLWarning] -- mutes delete group error SEConnectionNotFound agentConnId -> ["event connection not found, agent ID: " <> sShow agentConnId | logLevel <= CLLWarning] -- mutes delete group error
SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text] SEChatItemNotFoundByText text -> ["message not found by text: " <> plain text]
SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> groupName' g)] SEDuplicateGroupLink g -> ["you already have link for this group, to show: " <> highlight ("/show link #" <> viewGroupName g)]
SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> groupName' g)] SEGroupLinkNotFound g -> ["no group link, to create: " <> highlight ("/create link #" <> viewGroupName g)]
e -> ["chat db error: " <> sShow e] e -> ["chat db error: " <> sShow e]
ChatErrorDatabase err -> case err of ChatErrorDatabase err -> case err of
DBErrorEncrypted -> ["error: chat database is already encrypted"] DBErrorEncrypted -> ["error: chat database is already encrypted"]
@ -1732,8 +1732,8 @@ viewChatError logLevel = \case
viewConnectionEntityDisabled :: ConnectionEntity -> [StyledString] viewConnectionEntityDisabled :: ConnectionEntity -> [StyledString]
viewConnectionEntityDisabled entity = case entity of viewConnectionEntityDisabled entity = case entity of
RcvDirectMsgConnection _ (Just Contact {localDisplayName = c}) -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] RcvDirectMsgConnection _ (Just c) -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable " <> viewContactName c) <> ", to delete: " <> highlight ("/d " <> viewContactName c)]
RcvGroupMsgConnection _ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable #" <> g <> " " <> m)] RcvGroupMsgConnection _ g m -> ["[" <> entityLabel <> "] connection is disabled, to enable: " <> highlight ("/enable #" <> viewGroupName g <> " " <> viewMemberName m)]
_ -> ["[" <> entityLabel <> "] connection is disabled"] _ -> ["[" <> entityLabel <> "] connection is disabled"]
where where
entityLabel = connEntityLabel entity entityLabel = connEntityLabel entity
@ -1748,7 +1748,7 @@ connEntityLabel = \case
UserContactConnection _ UserContact {} -> "contact address" UserContactConnection _ UserContact {} -> "contact address"
ttyContact :: ContactName -> StyledString ttyContact :: ContactName -> StyledString
ttyContact = styled $ colored Green ttyContact = styled (colored Green) . viewName
ttyContact' :: Contact -> StyledString ttyContact' :: Contact -> StyledString
ttyContact' Contact {localDisplayName = c} = ttyContact c ttyContact' Contact {localDisplayName = c} = ttyContact c
@ -1768,37 +1768,46 @@ ttyFullName :: ContactName -> Text -> StyledString
ttyFullName c fullName = ttyContact c <> optFullName c fullName ttyFullName c fullName = ttyContact c <> optFullName c fullName
ttyToContact :: ContactName -> StyledString ttyToContact :: ContactName -> StyledString
ttyToContact c = ttyTo $ "@" <> c <> " " ttyToContact c = ttyTo $ "@" <> viewName c <> " "
ttyToContact' :: Contact -> StyledString ttyToContact' :: Contact -> StyledString
ttyToContact' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyToContact c ttyToContact' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyToContact c
ttyToContactEdited' :: Contact -> StyledString ttyToContactEdited' :: Contact -> StyledString
ttyToContactEdited' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyTo ("@" <> c <> " [edited] ") ttyToContactEdited' ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyTo ("@" <> viewName c <> " [edited] ")
ttyQuotedContact :: Contact -> StyledString ttyQuotedContact :: Contact -> StyledString
ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">" ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ viewName c <> ">"
ttyQuotedMember :: Maybe GroupMember -> StyledString ttyQuotedMember :: Maybe GroupMember -> StyledString
ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (viewName c)
ttyQuotedMember _ = "> " <> ttyFrom "?" ttyQuotedMember _ = "> " <> ttyFrom "?"
ttyFromContact :: Contact -> StyledString ttyFromContact :: Contact -> StyledString
ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (c <> "> ") ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ")
ttyFromContactEdited :: Contact -> StyledString ttyFromContactEdited :: Contact -> StyledString
ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (c <> "> [edited] ") ttyFromContactEdited ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> [edited] ")
ttyFromContactDeleted :: Contact -> Maybe Text -> StyledString ttyFromContactDeleted :: Contact -> Maybe Text -> StyledString
ttyFromContactDeleted ct@Contact {localDisplayName = c} deletedText_ = ttyFromContactDeleted ct@Contact {localDisplayName = c} deletedText_ =
ctIncognito ct <> ttyFrom (c <> "> " <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) ctIncognito ct <> ttyFrom (viewName c <> "> " <> maybe "" (\t -> "[" <> t <> "] ") deletedText_)
ttyGroup :: GroupName -> StyledString ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (colored Blue) $ "#" <> g ttyGroup g = styled (colored Blue) $ "#" <> viewName g
ttyGroup' :: GroupInfo -> StyledString ttyGroup' :: GroupInfo -> StyledString
ttyGroup' = ttyGroup . groupName' ttyGroup' = ttyGroup . groupName'
viewContactName :: Contact -> Text
viewContactName = viewName . localDisplayName'
viewGroupName :: GroupInfo -> Text
viewGroupName = viewName . groupName'
viewMemberName :: GroupMember -> Text
viewMemberName GroupMember {localDisplayName = n} = viewName n
ttyGroups :: [GroupName] -> StyledString ttyGroups :: [GroupName] -> StyledString
ttyGroups [] = "" ttyGroups [] = ""
ttyGroups [g] = ttyGroup g ttyGroups [g] = ttyGroup g
@ -1819,8 +1828,7 @@ ttyFromGroupDeleted g m deletedText_ =
membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) membershipIncognito g <> ttyFrom (fromGroup_ g m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_)
fromGroup_ :: GroupInfo -> GroupMember -> Text fromGroup_ :: GroupInfo -> GroupMember -> Text
fromGroup_ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} = fromGroup_ g m = "#" <> viewGroupName g <> " " <> viewMemberName m <> "> "
"#" <> g <> " " <> m <> "> "
ttyFrom :: Text -> StyledString ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow ttyFrom = styled $ colored Yellow
@ -1829,12 +1837,13 @@ ttyTo :: Text -> StyledString
ttyTo = styled $ colored Cyan ttyTo = styled $ colored Cyan
ttyToGroup :: GroupInfo -> StyledString ttyToGroup :: GroupInfo -> StyledString
ttyToGroup g@GroupInfo {localDisplayName = n} = ttyToGroup g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " ")
membershipIncognito g <> ttyTo ("#" <> n <> " ")
ttyToGroupEdited :: GroupInfo -> StyledString ttyToGroupEdited :: GroupInfo -> StyledString
ttyToGroupEdited g@GroupInfo {localDisplayName = n} = ttyToGroupEdited g = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " [edited] ")
membershipIncognito g <> ttyTo ("#" <> n <> " [edited] ")
viewName :: Text -> Text
viewName s = if T.any isSpace s then "'" <> s <> "'" else s
ttyFilePath :: FilePath -> StyledString ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain ttyFilePath = plain

View File

@ -8,7 +8,7 @@ import ChatTests.Utils
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_) import Control.Concurrent.Async (concurrently_)
import qualified Data.Text as T import qualified Data.Text as T
import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..)) import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..))
import System.Directory (copyFile, createDirectoryIfMissing) import System.Directory (copyFile, createDirectoryIfMissing)
import Test.Hspec import Test.Hspec
@ -17,6 +17,7 @@ chatProfileTests = do
describe "user profiles" $ do describe "user profiles" $ do
it "update user profile and notify contacts" testUpdateProfile it "update user profile and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage it "update user profile with image" testUpdateProfileImage
it "use multiword profile names" testMultiWordProfileNames
describe "user contact link" $ do describe "user contact link" $ do
it "create and connect via contact link" testUserContactLink it "create and connect via contact link" testUserContactLink
it "add contact link to profile" testProfileLink it "add contact link to profile" testProfileLink
@ -62,7 +63,7 @@ testUpdateProfile =
createGroup3 "team" alice bob cath createGroup3 "team" alice bob cath
alice ##> "/p" alice ##> "/p"
alice <## "user profile: alice (Alice)" alice <## "user profile: alice (Alice)"
alice <## "use /p <display name> [<full name>] to change it" alice <## "use /p <display name> to change it"
alice <## "(the updated profile will be sent to all your contacts)" alice <## "(the updated profile will be sent to all your contacts)"
alice ##> "/p alice" alice ##> "/p alice"
concurrentlyN_ concurrentlyN_
@ -117,6 +118,76 @@ testUpdateProfileImage =
bob <## "use @alice2 <message> to send messages" bob <## "use @alice2 <message> to send messages"
(bob </) (bob </)
testMultiWordProfileNames :: HasCallStack => FilePath -> IO ()
testMultiWordProfileNames =
testChat3 aliceProfile' bobProfile' cathProfile' $
\alice bob cath -> do
alice ##> "/c"
inv <- getInvitation alice
bob ##> ("/c " <> inv)
bob <## "confirmation sent!"
concurrently_
(bob <## "'Alice Jones': contact is connected")
(alice <## "'Bob James': contact is connected")
alice #> "@'Bob James' hi"
bob <# "'Alice Jones'> hi"
alice ##> "/g 'Our Team'"
alice <## "group #'Our Team' is created"
alice <## "to add members use /a 'Our Team' <name> or /create link #'Our Team'"
alice ##> "/a 'Our Team' 'Bob James' admin"
alice <## "invitation to join the group #'Our Team' sent to 'Bob James'"
bob <## "#'Our Team': 'Alice Jones' invites you to join the group as admin"
bob <## "use /j 'Our Team' to accept"
bob ##> "/j 'Our Team'"
bob <## "#'Our Team': you joined the group"
alice <## "#'Our Team': 'Bob James' joined the group"
bob ##> "/c"
inv' <- getInvitation bob
cath ##> ("/c " <> inv')
cath <## "confirmation sent!"
concurrently_
(cath <## "'Bob James': contact is connected")
(bob <## "'Cath Johnson': contact is connected")
bob ##> "/a 'Our Team' 'Cath Johnson'"
bob <## "invitation to join the group #'Our Team' sent to 'Cath Johnson'"
cath <## "#'Our Team': 'Bob James' invites you to join the group as member"
cath <## "use /j 'Our Team' to accept"
cath ##> "/j 'Our Team'"
concurrentlyN_
[ bob <## "#'Our Team': 'Cath Johnson' joined the group",
do
cath <## "#'Our Team': you joined the group"
cath <## "#'Our Team': member 'Alice Jones' is connected",
do
alice <## "#'Our Team': 'Bob James' added 'Cath Johnson' to the group (connecting...)"
alice <## "#'Our Team': new member 'Cath Johnson' is connected"
]
bob #> "#'Our Team' hi"
alice <# "#'Our Team' 'Bob James'> hi"
cath <# "#'Our Team' 'Bob James'> hi"
alice `send` "@'Cath Johnson' hello"
alice <## "member #'Our Team' 'Cath Johnson' does not have direct connection, creating"
alice <## "contact for member #'Our Team' 'Cath Johnson' is created"
alice <## "sent invitation to connect directly to member #'Our Team' 'Cath Johnson'"
alice <# "@'Cath Johnson' hello"
cath <## "#'Our Team' 'Alice Jones' is creating direct contact 'Alice Jones' with you"
cath <# "'Alice Jones'> hello"
cath <## "'Alice Jones': contact is connected"
alice <## "'Cath Johnson': contact is connected"
cath ##> "/p 'Cath J'"
cath <## "user profile is changed to 'Cath J' (your 2 contacts are notified)"
alice <## "contact 'Cath Johnson' changed to 'Cath J'"
alice <## "use @'Cath J' <message> to send messages"
bob <## "contact 'Cath Johnson' changed to 'Cath J'"
bob <## "use @'Cath J' <message> to send messages"
alice #> "@'Cath J' hi"
cath <# "'Alice Jones'> hi"
where
aliceProfile' = baseProfile {displayName = "Alice Jones"}
bobProfile' = baseProfile {displayName = "Bob James"}
cathProfile' = baseProfile {displayName = "Cath Johnson"}
baseProfile = Profile {displayName = "", fullName = "", image = Nothing, contactLink = Nothing, preferences = defaultPrefs}
testUserContactLink :: HasCallStack => FilePath -> IO () testUserContactLink :: HasCallStack => FilePath -> IO ()
testUserContactLink = testUserContactLink =
testChat3 aliceProfile bobProfile cathProfile $ testChat3 aliceProfile bobProfile cathProfile $

View File

@ -435,7 +435,7 @@ lastItemId cc = do
showActiveUser :: HasCallStack => TestCC -> String -> Expectation showActiveUser :: HasCallStack => TestCC -> String -> Expectation
showActiveUser cc name = do showActiveUser cc name = do
cc <## ("user profile: " <> name) cc <## ("user profile: " <> name)
cc <## "use /p <display name> [<full name>] to change it" cc <## "use /p <display name> to change it"
cc <## "(the updated profile will be sent to all your contacts)" cc <## "(the updated profile will be sent to all your contacts)"
connectUsers :: HasCallStack => TestCC -> TestCC -> IO () connectUsers :: HasCallStack => TestCC -> TestCC -> IO ()

View File

@ -61,6 +61,8 @@ mobileTests = do
it "utf8 name 1" $ testFileEncryptionCApi "тест" it "utf8 name 1" $ testFileEncryptionCApi "тест"
it "utf8 name 2" $ testFileEncryptionCApi "👍" it "utf8 name 2" $ testFileEncryptionCApi "👍"
it "no exception on missing file" testMissingFileEncryptionCApi it "no exception on missing file" testMissingFileEncryptionCApi
describe "validate name" $ do
it "should convert invalid name to a valid name" testValidNameCApi
noActiveUser :: LB.ByteString noActiveUser :: LB.ByteString
#if defined(darwin_HOST_OS) && defined(swiftJSON) #if defined(darwin_HOST_OS) && defined(swiftJSON)
@ -266,6 +268,14 @@ testMissingFileEncryptionCApi tmp = do
err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath' err' <- peekCAString =<< cChatDecryptFile cToPath cKey cNonce cToPath'
err' `shouldContain` toPath err' `shouldContain` toPath
testValidNameCApi :: FilePath -> IO ()
testValidNameCApi _ = do
let goodName = "Джон Доу 👍"
cName1 <- cChatValidName =<< newCString goodName
peekCString cName1 `shouldReturn` goodName
cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 "
peekCString cName2 `shouldReturn` goodName
jDecode :: FromJSON a => String -> IO (Maybe a) jDecode :: FromJSON a => String -> IO (Maybe a)
jDecode = pure . J.decode . LB.pack jDecode = pure . J.decode . LB.pack

View File

@ -13,6 +13,7 @@ import SchemaDump
import Test.Hspec import Test.Hspec
import UnliftIO.Temporary (withTempDirectory) import UnliftIO.Temporary (withTempDirectory)
import ViewTests import ViewTests
import ValidNames
import WebRTCTests import WebRTCTests
main :: IO () main :: IO ()
@ -24,6 +25,7 @@ main = do
describe "SimpleX chat view" viewTests describe "SimpleX chat view" viewTests
describe "SimpleX chat protocol" protocolTests describe "SimpleX chat protocol" protocolTests
describe "WebRTC encryption" webRTCTests describe "WebRTC encryption" webRTCTests
describe "Valid names" validNameTests
around testBracket $ do around testBracket $ do
describe "Mobile API Tests" mobileTests describe "Mobile API Tests" mobileTests
describe "SimpleX chat client" chatTests describe "SimpleX chat client" chatTests

27
tests/ValidNames.hs Normal file
View File

@ -0,0 +1,27 @@
module ValidNames where
import Simplex.Chat
import Test.Hspec
validNameTests :: Spec
validNameTests = describe "valid chat names" $ do
it "should keep valid and fix invalid names" testMkValidName
testMkValidName :: IO ()
testMkValidName = do
mkValidName "alice" `shouldBe` "alice"
mkValidName "алиса" `shouldBe` "алиса"
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "J.Doe" `shouldBe` "J.Doe"
mkValidName "J. Doe" `shouldBe` "J. Doe"
mkValidName "J..Doe" `shouldBe` "J.Doe"
mkValidName "J ..Doe" `shouldBe` "J Doe"
mkValidName "J . . Doe" `shouldBe` "J Doe"
mkValidName "@alice" `shouldBe` "alice"
mkValidName "#alice" `shouldBe` "alice"
mkValidName " alice" `shouldBe` "alice"
mkValidName "alice " `shouldBe` "alice"
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "'John Doe'" `shouldBe` "John Doe"
mkValidName "\"John Doe\"" `shouldBe` "John Doe"
mkValidName "`John Doe`" `shouldBe` "John Doe"