From 309fdf422fe894c42894f0f68dad03d681642a5c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 4 Apr 2022 12:33:28 +0100 Subject: [PATCH 01/37] ios: scripts (#501) --- apps/ios/prepare-x86_64.sh | 13 +++++++++++++ apps/ios/prepare.sh | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100755 apps/ios/prepare-x86_64.sh create mode 100755 apps/ios/prepare.sh diff --git a/apps/ios/prepare-x86_64.sh b/apps/ios/prepare-x86_64.sh new file mode 100755 index 0000000000..c6848db29a --- /dev/null +++ b/apps/ios/prepare-x86_64.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# the binaries folders should be in ~/Downloads folder +rm -rf ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +mkdir -p ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +cp ~/Downloads/pkg-ios-aarch64/* ./apps/ios/Libraries/mac-aarch64 +cp ~/Downloads/pkg-ios-x86_64/* ./apps/ios/Libraries/mac-x86_64 +chmod +w ./apps/ios/Libraries/mac-aarch64/* +chmod +w ./apps/ios/Libraries/mac-x86_64/* +cp ./apps/ios/Libraries/mac-aarch64/* ./apps/ios/Libraries/ios +cp ./apps/ios/Libraries/mac-x86_64/* ./apps/ios/Libraries/sim +for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l +for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l diff --git a/apps/ios/prepare.sh b/apps/ios/prepare.sh new file mode 100755 index 0000000000..3f8da7c90a --- /dev/null +++ b/apps/ios/prepare.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# the binaries folder should be in ~/Downloads folder +rm -rf ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +cp ~/Downloads/pkg-ios-aarch64/* ./apps/ios/Libraries/mac +chmod +w ./apps/ios/Libraries/mac/* +cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/ios +cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/sim +for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l +for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l From 0ecaa59df6231c381e5d5a7d7f6c6dcd5786ed67 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 4 Apr 2022 19:19:54 +0100 Subject: [PATCH 02/37] ios: update image picker (#495) * switch to PHPicker for photos. TODO add back camera functionality. [rough] * add back camera selection option * remove force unwrap of optional * response to comments * rerun tests * refactor naming --- .../Shared/Views/Helpers/ImagePicker.swift | 92 +++++++++++++++---- .../Views/UserSettings/UserProfile.swift | 25 +++-- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 5bd16f693b..8c2b68b8bc 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -7,44 +7,102 @@ // import SwiftUI +import PhotosUI -struct ImagePicker: UIViewControllerRepresentable { - @Environment(\.presentationMode) var presentationMode - var source: UIImagePickerController.SourceType +enum ImageSource { + case imageLibrary + case camera +} + +struct LibraryImagePicker: UIViewControllerRepresentable { + typealias UIViewControllerType = PHPickerViewController @Binding var image: UIImage? - @Binding var imageUrl: URL? - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { + var didFinishPicking: (_ didSelectItems: Bool) -> Void + + class Coordinator: PHPickerViewControllerDelegate { + let parent: LibraryImagePicker + + init(_ parent: LibraryImagePicker) { self.parent = parent } - + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.didFinishPicking(!results.isEmpty) + guard !results.isEmpty else { + return + } + + if let chosenImageProvider = results.first?.itemProvider { + if chosenImageProvider.canLoadObject(ofClass: UIImage.self) { + chosenImageProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + DispatchQueue.main.async { + self?.loadImage(object: image, error: error) + } + } + } + } + } + + func loadImage(object: Any?, error: Error? = nil) { + if let error = error { + logger.error("Couldn't load image with error: \(error.localizedDescription)") + } + parent.image = object as? UIImage + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let controller = PHPickerViewController(configuration: config) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + + } +} + + +struct CameraImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) var presentationMode + @Binding var image: UIImage? + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraImagePicker + + init(_ parent: CameraImagePicker) { + self.parent = parent + } + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let uiImage = info[.originalImage] as? UIImage { - parent.imageUrl = info[.imageURL] as? URL parent.image = uiImage } parent.presentationMode.wrappedValue.dismiss() } } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { let picker = UIImagePickerController() - picker.sourceType = source + picker.sourceType = .camera picker.allowsEditing = false picker.delegate = context.coordinator return picker } - func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { - + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 4e5d62bf31..df2e64c4ba 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -14,9 +14,8 @@ struct UserProfile: View { @State private var editProfile = false @State private var showChooseSource = false @State private var showImagePicker = false - @State private var imageSource: UIImagePickerController.SourceType = .photoLibrary - @State private var pickedImage: UIImage? = nil - @State private var tmpImageUrl: URL? = nil + @State private var imageSource: ImageSource = .imageLibrary + @State private var chosenImage: UIImage? = nil var body: some View { let user: User = chatModel.currentUser! @@ -84,14 +83,21 @@ struct UserProfile: View { showImagePicker = true } Button("Choose from library") { - imageSource = .photoLibrary + imageSource = .imageLibrary showImagePicker = true } } .sheet(isPresented: $showImagePicker) { - ImagePicker(source: imageSource, image: $pickedImage, imageUrl: $tmpImageUrl) + switch imageSource { + case .imageLibrary: + LibraryImagePicker(image: $chosenImage) { + didSelectItem in showImagePicker = false + } + case .camera: + CameraImagePicker(image: $chosenImage) + } } - .onChange(of: pickedImage) { image in + .onChange(of: chosenImage) { image in if let image = image, let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) { let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())" @@ -100,13 +106,6 @@ struct UserProfile: View { } else { logger.error("UserProfile: resized image is too big \(imageStr.count)") } - if let tmpImageUrl = tmpImageUrl { - do { - try FileManager.default.removeItem(at: tmpImageUrl) - } catch { - logger.error("UserProfile: file deletion error \(error.localizedDescription)") - } - } } else { profile.image = nil } From 7012005feb34f1290643137de23c8eec1e7011b5 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 4 Apr 2022 19:51:49 +0100 Subject: [PATCH 03/37] core: MsgContent for link previews, API to parse markdown (#504) --- src/Simplex/Chat.hs | 4 +++- src/Simplex/Chat/Controller.hs | 5 ++++- src/Simplex/Chat/Protocol.hs | 23 ++++++++++++++++++++++- src/Simplex/Chat/Store.hs | 16 ++++++++-------- src/Simplex/Chat/Types.hs | 20 ++++++++++---------- src/Simplex/Chat/View.hs | 1 + tests/ChatTests.hs | 4 ++-- tests/ProtocolTests.hs | 5 ++++- 8 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bbe08d5934..8464195b40 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -301,6 +301,7 @@ processChatCommand = \case withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq APIUpdateProfile profile -> withUser (`updateProfile` profile) + APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers @@ -1662,6 +1663,7 @@ chatCommandP = <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) + <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -1707,7 +1709,7 @@ chatCommandP = <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown <|> ("/welcome" <|> "/w") $> Welcome - <|> "/profile_image " *> (UpdateProfileImage . Just . ProfileImage <$> imageP) + <|> "/profile_image " *> (UpdateProfileImage . Just . ImageData <$> imageP) <|> "/profile_image" $> UpdateProfileImage Nothing <|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames) <|> ("/profile" <|> "/p") $> ShowProfile diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 132cc5ef5e..99a9fb1f72 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -25,6 +25,7 @@ import Data.Version (showVersion) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) @@ -102,6 +103,7 @@ data ChatCommand | APIAcceptContact Int64 | APIRejectContact Int64 | APIUpdateProfile Profile + | APIParseMarkdown Text | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection @@ -142,7 +144,7 @@ data ChatCommand | FileStatus FileTransferId | ShowProfile | UpdateProfile ContactName Text - | UpdateProfileImage (Maybe ProfileImage) + | UpdateProfileImage (Maybe ImageData) | QuitChat | ShowVersion deriving (Show) @@ -153,6 +155,7 @@ data ChatResponse | CRChatRunning | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} + | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} | CRChatItemStatusUpdated {chatItem :: AChatItem} diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 04df7a9a5c..dfa4497b9d 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -147,14 +147,16 @@ cmToQuotedMsg = \case XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCUnknown_ Text instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" + MCLink_ -> "link" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ + "link" -> Right MCLink_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -177,19 +179,32 @@ mcContent = \case MCQuote _ c -> c MCForward c -> c +data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData} + deriving (Eq, Show, Generic) + +instance FromJSON LinkPreview where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON LinkPreview where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + data MsgContent = MCText Text + | MCLink {text :: Text, preview :: LinkPreview} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) msgContentText :: MsgContent -> Text msgContentText = \case MCText t -> t + MCLink {text} -> text MCUnknown {text} -> text msgContentTag :: MsgContent -> MsgContentTag msgContentTag = \case MCText _ -> MCText_ + MCLink {} -> MCLink_ MCUnknown {tag} -> MCUnknown_ tag parseMsgContainer :: J.Object -> JT.Parser MsgContainer @@ -204,6 +219,10 @@ instance FromJSON MsgContent where parseJSON (J.Object v) = v .: "type" >>= \case MCText_ -> MCText <$> v .: "text" + MCLink_ -> do + text <- v .: "text" + preview <- v .: "preview" + pure MCLink {text, preview} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -223,9 +242,11 @@ instance ToJSON MsgContent where toJSON = \case MCUnknown {json} -> J.Object json MCText t -> J.object ["type" .= MCText_, "text" .= t] + MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t + MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview instance ToField MsgContent where toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index ed38209ed1..df9fb5b09c 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -269,7 +269,7 @@ getUsers st = JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ProfileImage) -> User +toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ImageData) -> User toUser (userId, userContactId, activeUser, displayName, fullName, image) = let profile = Profile {displayName, fullName, image} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} @@ -482,7 +482,7 @@ updateContact_ db userId contactId displayName newName updatedAt = do (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) -type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage, UTCTime) +type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, UTCTime) toContact :: ContactRow :. ConnectionRow -> Contact toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) = @@ -758,7 +758,7 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ProfileImage, UTCTime, Maybe XContactId) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, UTCTime, Maybe XContactId) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do @@ -1092,7 +1092,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime)] -> Either StoreError Contact toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] = let profile = Profile {displayName, fullName, image} in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} @@ -1286,7 +1286,7 @@ getGroupInfoByName st user gName = gId <- ExceptT $ getGroupIdByName_ db user gName ExceptT $ getGroupInfo_ db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ProfileImage, UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) = @@ -1344,9 +1344,9 @@ getGroupInvitation st user localDisplayName = findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact _ = const Nothing -type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage) +type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData) -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ProfileImage) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ImageData) toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) = @@ -1724,7 +1724,7 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: [(Int64, ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact + toContact' :: [(Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] = let profile = Profile {displayName, fullName, image} activeConn = toConnection connRow diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 54ad6267fc..a25a674e6a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -171,7 +171,7 @@ groupName' GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, fullName :: Text, - image :: Maybe ProfileImage + image :: Maybe ImageData } deriving (Eq, Show, Generic, FromJSON) @@ -182,7 +182,7 @@ instance ToJSON Profile where data GroupProfile = GroupProfile { displayName :: GroupName, fullName :: Text, - image :: Maybe ProfileImage + image :: Maybe ImageData } deriving (Eq, Show, Generic, FromJSON) @@ -190,19 +190,19 @@ instance ToJSON GroupProfile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -newtype ProfileImage = ProfileImage Text +newtype ImageData = ImageData Text deriving (Eq, Show) -instance FromJSON ProfileImage where - parseJSON = fmap ProfileImage . J.parseJSON +instance FromJSON ImageData where + parseJSON = fmap ImageData . J.parseJSON -instance ToJSON ProfileImage where - toJSON (ProfileImage t) = J.toJSON t - toEncoding (ProfileImage t) = J.toEncoding t +instance ToJSON ImageData where + toJSON (ImageData t) = J.toJSON t + toEncoding (ImageData t) = J.toEncoding t -instance ToField ProfileImage where toField (ProfileImage t) = toField t +instance ToField ImageData where toField (ImageData t) = toField t -instance FromField ProfileImage where fromField = fmap ProfileImage . fromField +instance FromField ImageData where fromField = fmap ImageData . fromField data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f6cd89f824..d6f230b4e2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,6 +46,7 @@ responseToView testView = \case CRChatRunning -> [] CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRChatItemStatusUpdated _ -> [] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index ad0e238e04..7a3492af27 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -13,7 +13,7 @@ import qualified Data.ByteString as B import Data.Char (isDigit) import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..)) +import Simplex.Chat.Types (ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) import System.Directory (doesFileExist) import Test.Hspec @@ -22,7 +22,7 @@ aliceProfile :: Profile aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing} bobProfile :: Profile -bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ProfileImage "data:image/png;base64,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")} +bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC")} cathProfile :: Profile cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing} diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2ac9801cb5..344c7e0bb7 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -82,7 +82,7 @@ s #==# msg = do s ==# msg testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")} +testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing} @@ -90,6 +90,9 @@ testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple $ MCText "hello") + it "x.msg.new" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" + #==# XMsgNew (MCSimple $ MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA"}) it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello")) it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" From a17ddede5333ad55dede5a9b0a065ba88644788f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 4 Apr 2022 20:44:38 +0100 Subject: [PATCH 04/37] ios: update binaries --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 75 +++++++++------------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 537fe74814..eb0b72e9cf 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -25,20 +25,20 @@ 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; - 5C36026827F44386009F19D9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026327F44385009F19D9 /* libffi.a */; }; - 5C36026927F44386009F19D9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026327F44385009F19D9 /* libffi.a */; }; - 5C36026A27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; }; - 5C36026B27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; }; - 5C36026C27F44386009F19D9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026527F44386009F19D9 /* libgmpxx.a */; }; - 5C36026D27F44386009F19D9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026527F44386009F19D9 /* libgmpxx.a */; }; - 5C36026E27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; }; - 5C36026F27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; }; - 5C36027027F44386009F19D9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026727F44386009F19D9 /* libgmp.a */; }; - 5C36027127F44386009F19D9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026727F44386009F19D9 /* libgmp.a */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; + 5C41155C27FB81D80054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155727FB81D80054D6CB /* libgmp.a */; }; + 5C41155D27FB81D80054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155727FB81D80054D6CB /* libgmp.a */; }; + 5C41155E27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; + 5C41155F27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; + 5C41156027FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; + 5C41156127FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; + 5C41156227FB81D80054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155A27FB81D80054D6CB /* libffi.a */; }; + 5C41156327FB81D80054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155A27FB81D80054D6CB /* libffi.a */; }; + 5C41156427FB81D80054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155B27FB81D80054D6CB /* libgmpxx.a */; }; + 5C41156527FB81D80054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155B27FB81D80054D6CB /* libgmpxx.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; @@ -84,16 +84,6 @@ 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; - 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; }; - 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; }; - 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; }; - 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; }; - 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; }; - 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; }; - 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; }; - 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; }; - 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; }; - 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; @@ -157,13 +147,13 @@ 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; - 5C36026327F44385009F19D9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a"; sourceTree = ""; }; - 5C36026527F44386009F19D9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a"; sourceTree = ""; }; - 5C36026727F44386009F19D9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; + 5C41155727FB81D80054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a"; sourceTree = ""; }; + 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a"; sourceTree = ""; }; + 5C41155A27FB81D80054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C41155B27FB81D80054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; @@ -195,11 +185,6 @@ 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = ""; }; 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; - 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a"; sourceTree = ""; }; - 5CA14D1F27F6DE37009B11CE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CA14D2027F6DE37009B11CE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CA14D2127F6DE37009B11CE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a"; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -224,13 +209,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */, + 5C41155E27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */, - 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */, - 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */, + 5C41155C27FB81D80054D6CB /* libgmp.a in Frameworks */, + 5C41156427FB81D80054D6CB /* libgmpxx.a in Frameworks */, + 5C41156227FB81D80054D6CB /* libffi.a in Frameworks */, + 5C41156027FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -239,13 +224,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */, - 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */, - 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */, - 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */, + 5C41156327FB81D80054D6CB /* libffi.a in Frameworks */, + 5C41156127FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, + 5C41155D27FB81D80054D6CB /* libgmp.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, + 5C41155F27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, + 5C41156527FB81D80054D6CB /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -297,11 +282,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA14D2127F6DE37009B11CE /* libffi.a */, - 5CA14D1F27F6DE37009B11CE /* libgmp.a */, - 5CA14D2027F6DE37009B11CE /* libgmpxx.a */, - 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */, - 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */, + 5C41155A27FB81D80054D6CB /* libffi.a */, + 5C41155727FB81D80054D6CB /* libgmp.a */, + 5C41155B27FB81D80054D6CB /* libgmpxx.a */, + 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */, + 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */, ); path = Libraries; sourceTree = ""; From a5ca2c216345e2bd7b4d0bd5afcb342073bdf66b Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 5 Apr 2022 10:01:08 +0400 Subject: [PATCH 05/37] core: new files protocol (#492) --- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 147 ++++++++++--- src/Simplex/Chat/Controller.hs | 4 +- .../Migrations/M20220404_files_cancelled.hs | 12 ++ src/Simplex/Chat/Protocol.hs | 16 +- src/Simplex/Chat/Store.hs | 194 ++++++++++++++++-- src/Simplex/Chat/Terminal/Input.hs | 2 + src/Simplex/Chat/Types.hs | 31 ++- src/Simplex/Chat/View.hs | 43 ++-- tests/ChatClient.hs | 1 + tests/ChatTests.hs | 153 +++++++++++++- tests/ProtocolTests.hs | 17 +- 12 files changed, 551 insertions(+), 70 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 884a7693c8..6b91a1e3b7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -34,6 +34,7 @@ library Simplex.Chat.Migrations.M20220302_profile_images Simplex.Chat.Migrations.M20220304_msg_quotes Simplex.Chat.Migrations.M20220321_chat_item_edited + Simplex.Chat.Migrations.M20220404_files_cancelled Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8464195b40..f620e034fb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -463,25 +463,37 @@ processChatCommand = \case editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc + -- old file protocol SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName - (agentConnId, connReq) <- withAgent (`createConnection` SCMInvitation) - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = ACR SCMInvitation connReq} + (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} SndFileTransfer {fileId} <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci + -- new file protocol + SendFileInv cName f -> withUser $ \user@User {userId} -> withChatLock $ do + (fileSize, chSize) <- checkSndFile f + contact <- withStore $ \st -> getContactByName st userId cName + let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndFileTransferV2 st userId contact f fileInv chSize + ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + setActive $ ActiveC cName + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci + -- old file protocol SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do - (connId, connReq) <- withAgent (`createConnection` SCMInvitation) - pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = ACR SCMInvitation connReq}) + (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}) fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize -- TODO sendGroupChatItem - same file invitation to all forM_ ms $ \(m, _, fileInv) -> @@ -493,27 +505,69 @@ processChatCommand = \case cItem@ChatItem {meta = CIMeta {itemId}} <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent Nothing withStore $ \st -> updateFileTransferChatItemId st fileId itemId pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem - ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do - ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq = ACR _ fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId + -- new file protocol + SendGroupFileInv gName f -> withUser $ \user@User {userId} -> withChatLock $ do + (fileSize, chSize) <- checkSndFile f + g@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroupByName st user gName + unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo f fileInv chSize + ci <- sendGroupChatItem user g (XFile fileInv) (CISndFileInvitation fileId f) Nothing + withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> do + ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} <- withStore $ \st -> getRcvFileTransfer st userId fileId unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName - withChatLock . procCmd $ do - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case - Right agentConnId -> do - filePath <- getRcvFilePath fileId filePath_ fileName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - pure $ CRRcvFileAccepted ft filePath - Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left e -> throwError e + case fileConnReq of + -- old file protocol + Just connReq -> + withChatLock . procCmd $ do + tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fileName) >>= \case + Right agentConnId -> do + filePath <- getRcvFilePath fileId filePath_ fileName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + pure $ CRRcvFileAccepted ft filePath + Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft + Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft + Left e -> throwError e + -- new file protocol + Nothing -> + case grpMemberId of + Nothing -> + withChatLock . procCmd $ do + ct <- withStore $ \st -> getContactByName st userId senderDisplayName + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fileName + Just memId -> + withChatLock . procCmd $ do + (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId + case activeConn of + Just conn -> + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fileName) (GroupId groupId) + _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen + where + acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m ChatResponse + acceptFileV2 sendXFileAcptInv = do + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + filePath <- getRcvFilePath fileId filePath_ fileName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + void $ sendXFileAcptInv sharedMsgId fileInvConnReq + pure $ CRRcvFileAccepted ft filePath CancelFile fileId -> withUser $ \User {userId} -> do ft' <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ case ft' of - FTSnd fts -> do - forM_ fts $ \ft -> cancelSndFileTransfer ft - pure $ CRSndGroupFileCancelled fts - FTRcv ft -> do - cancelRcvFileTransfer ft - pure $ CRRcvFileCancelled ft + withChatLock . procCmd $ do + unless (fileTransferCancelled ft') $ + withStore $ \st -> updateFileCancelled st userId fileId + case ft' of + FTSnd ftm [] -> do + pure $ CRSndGroupFileCancelled ftm [] + FTSnd ftm fts -> do + forM_ fts $ \ft -> cancelSndFileTransfer ft + pure $ CRSndGroupFileCancelled ftm fts + FTRcv ft -> do + cancelRcvFileTransfer ft + pure $ CRRcvFileCancelled ft FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile @@ -772,6 +826,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta XFile fInv -> processFileInvitation ct fInv msg msgMeta + XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInv ct sharedMsgId fileConnReq fName msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe @@ -913,6 +968,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo m sharedMsgId mContent msg XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta + XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq fName msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv @@ -933,6 +989,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processSndFileConn :: ACommand 'Agent -> Connection -> SndFileTransfer -> m () processSndFileConn agentMsg conn ft@SndFileTransfer {fileId, fileName, fileStatus} = case agentMsg of + -- old file protocol CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of @@ -963,8 +1020,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> pure () processRcvFileConn :: ACommand 'Agent -> Connection -> RcvFileTransfer -> m () - processRcvFileConn agentMsg _conn ft@RcvFileTransfer {fileId, chunkSize} = + processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize} = case agentMsg of + -- new file protocol + CONF confId connInfo -> do + ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo + case chatMsgEvent of + XOk -> allowAgentConnection conn confId XOk + _ -> pure () CON -> do withStore $ \st -> updateRcvFileStatus st ft FSConnected toView $ CRRcvFileStart ft @@ -1170,6 +1233,42 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g + xFileAcptInv :: Contact -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () + xFileAcptInv Contact {contactId} sharedMsgId fileConnReq fName msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId + withStore (\st -> getFileTransfer st userId fileId) >>= \case + FTSnd FileTransferMeta {fileName, cancelled} _ -> + if not cancelled + then + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndFileTransferV2Connection st userId fileId acId + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" + else pure () -- TODO send "file cancelled" message + _ -> messageError "x.file.acpt.inv: bad file direction" + + xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () + xFileAcptInvGroup GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId + withStore (\st -> getFileTransfer st userId fileId) >>= \case + FTSnd FileTransferMeta {fileName, cancelled} _ -> + if not cancelled + then + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndGroupFileTransferV2Connection st userId fileId acId m + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" + else pure () -- TODO send "file cancelled" message + _ -> messageError "x.file.acpt.inv: bad file direction" + groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo ci msgMeta = do toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci @@ -1696,7 +1795,9 @@ chatCommandP = <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) + <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) + <|> ("/file_v2 @" <|> "/file_v2 " <|> "/f_v2 @" <|> "/f_v2 ") *> (SendFileInv <$> displayName <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) <|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal) <|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 99a9fb1f72..00faac4349 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -138,7 +138,9 @@ data ChatCommand | DeleteGroupMessage GroupName ByteString | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString} | SendFile ContactName FilePath + | SendFileInv ContactName FilePath | SendGroupFile GroupName FilePath + | SendGroupFileInv GroupName FilePath | ReceiveFile FileTransferId (Maybe FilePath) | CancelFile FileTransferId | FileStatus FileTransferId @@ -205,7 +207,7 @@ data ChatResponse | CRSndFileComplete {sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {sndFileTransfer :: SndFileTransfer} | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {sndFileTransfers :: [SndFileTransfer]} + | CRSndGroupFileCancelled {fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} diff --git a/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs b/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs new file mode 100644 index 0000000000..c1187a7ec3 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs @@ -0,0 +1,12 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220404_files_cancelled where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220404_files_cancelled :: Query +m20220404_files_cancelled = + [sql| +ALTER TABLE files ADD COLUMN cancelled INTEGER; -- 1 for cancelled +|] diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index dfa4497b9d..5dfea4c6b0 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -113,7 +113,8 @@ data ChatMsgEvent | XMsgDel SharedMsgId | XMsgDeleted | XFile FileInvitation - | XFileAcpt String + | XFileAcpt String -- old file protocol + | XFileAcptInv SharedMsgId ConnReqInvitation String -- new file protocol | XInfo Profile | XContact Profile (Maybe XContactId) | XGrpInv GroupInvitation @@ -261,6 +262,7 @@ data CMEventTag | XMsgDeleted_ | XFile_ | XFileAcpt_ + | XFileAcptInv_ | XInfo_ | XContact_ | XGrpInv_ @@ -290,6 +292,7 @@ instance StrEncoding CMEventTag where XMsgDeleted_ -> "x.msg.deleted" XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" + XFileAcptInv_ -> "x.file.acpt.inv" XInfo_ -> "x.info" XContact_ -> "x.contact" XGrpInv_ -> "x.grp.inv" @@ -316,6 +319,7 @@ instance StrEncoding CMEventTag where "x.msg.deleted" -> Right XMsgDeleted_ "x.file" -> Right XFile_ "x.file.acpt" -> Right XFileAcpt_ + "x.file.acpt.inv" -> Right XFileAcptInv_ "x.info" -> Right XInfo_ "x.contact" -> Right XContact_ "x.grp.inv" -> Right XGrpInv_ @@ -345,6 +349,7 @@ toCMEventTag = \case XMsgDeleted -> XMsgDeleted_ XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ + XFileAcptInv {} -> XFileAcptInv_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ XGrpInv _ -> XGrpInv_ @@ -392,6 +397,7 @@ appToChatMessage AppMessage {msgId, event, params} = do XMsgDeleted_ -> pure XMsgDeleted XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" + XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> p "fileConnReq" <*> p "fileName" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XGrpInv_ -> XGrpInv <$> p "groupInvitation" @@ -424,8 +430,9 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content] XMsgDel msgId' -> o ["msgId" .= msgId'] XMsgDeleted -> JM.empty - XFile fileInv -> o ["file" .= fileInv] + XFile fileInv -> o ["file" .= fileInvitationJSON fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] + XFileAcptInv sharedMsgId fileConnReq fileName -> o ["msgId" .= sharedMsgId, "fileConnReq" .= fileConnReq, "fileName" .= fileName] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] @@ -445,3 +452,8 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XInfoProbeOk probe -> o ["probe" .= probe] XOk -> JM.empty XUnknown _ ps -> ps + +fileInvitationJSON :: FileInvitation -> J.Object +fileInvitationJSON FileInvitation {fileName, fileSize, fileConnReq} = case fileConnReq of + Nothing -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize] + Just fConnReq -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize, "fileConnReq" .= fConnReq] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index df9fb5b09c..c4f25e1540 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -52,6 +52,7 @@ module Simplex.Chat.Store getPendingConnections, getContactConnections, getConnectionEntity, + getGroupAndMember, updateConnectionStatus, createNewGroup, createGroupInvitation, @@ -87,8 +88,16 @@ module Simplex.Chat.Store matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - createSndFileTransfer, - createSndGroupFileTransfer, + createSndFileTransfer, -- old file protocol + createSndFileTransferV2, + createSndFileTransferV2Connection, + createSndGroupFileTransfer, -- old file protocol + createSndGroupFileTransferV2, + createSndGroupFileTransferV2Connection, + updateFileCancelled, + getSharedMsgIdByFileId, + getFileIdBySharedMsgId, + getGroupFileIdBySharedMsgId, updateSndFileStatus, createSndFileChunk, updateSndFileChunkMsg, @@ -179,10 +188,11 @@ import Simplex.Chat.Migrations.M20220301_smp_servers import Simplex.Chat.Migrations.M20220302_profile_images import Simplex.Chat.Migrations.M20220304_msg_quotes import Simplex.Chat.Migrations.M20220321_chat_item_edited +import Simplex.Chat.Migrations.M20220404_files_cancelled import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C @@ -202,7 +212,8 @@ schemaMigrations = ("20220301_smp_servers", m20220301_smp_servers), ("20220302_profile_images", m20220302_profile_images), ("20220304_msg_quotes", m20220304_msg_quotes), - ("20220321_chat_item_edited", m20220321_chat_item_edited) + ("20220321_chat_item_edited", m20220321_chat_item_edited), + ("20220404_files_cancelled", m20220404_files_cancelled) ] -- | The list of migrations in ascending order by date @@ -1139,7 +1150,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = FROM snd_files s JOIN files f USING (file_id) LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN group_members m USING (group_member_id) WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ? |] (userId, fileId, connId) @@ -1165,6 +1176,47 @@ getConnectionEntity st User {userId, userContactId} agentConnId = userContact_ [Only cReq] = Right UserContact {userContactLinkId, connReqContact = cReq} userContact_ _ = Left SEUserContactLinkNotFound +getGroupAndMember :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (GroupInfo, GroupMember) +getGroupAndMember st User {userId, userContactId} groupMemberId = + liftIOEither . withTransaction st $ \db -> + firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupMemberId, userId, userContactId) + where + toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) + toGroupAndMember (groupInfoRow :. memberRow :. connRow) = + let groupInfo = toGroupInfo userContactId groupInfoRow + member = toGroupMember userContactId memberRow + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m () updateConnectionStatus st Connection {connId} connStatus = liftIO . withTransaction st $ \db -> do @@ -1748,6 +1800,26 @@ createSndFileTransfer st userId Contact {contactId, localDisplayName = recipient (fileId, fileStatus, connId, currentTs, currentTs) pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId acId} +createSndFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} chunkSize = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + insertedRowId db + +createSndFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> m () +createSndFileTransferV2Connection st userId fileId acId = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + Connection {connId} <- createSndFileConnection_ db userId fileId acId + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, FSAccepted, connId, currentTs, currentTs) + createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = liftIO . withTransaction st $ \db -> do @@ -1766,6 +1838,74 @@ createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize ch (fileId, FSNew, connId, groupMemberId, currentTs, currentTs) pure fileId +createSndGroupFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + insertedRowId db + +createSndGroupFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () +createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupMemberId} = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + Connection {connId} <- createSndFileConnection_ db userId fileId acId + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) + +updateFileCancelled :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () +updateFileCancelled st userId fileId = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) + +getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId +getSharedMsgIdByFileId st userId fileId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SESharedMsgIdNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.shared_msg_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + |] + (userId, fileId) + +getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getFileIdBySharedMsgId st userId contactId sharedMsgId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.shared_msg_id = ? + |] + (userId, contactId, sharedMsgId) + +getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? AND i.shared_msg_id = ? + |] + (userId, groupId, sharedMsgId) + createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime @@ -1842,7 +1982,7 @@ createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@File db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, created_at, updated_at) VALUES (?,?,?,?,?)" (fileId, FSNew, fileConnReq, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing} createRcvGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> FileInvitation -> Integer -> m RcvFileTransfer createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = @@ -1857,7 +1997,7 @@ createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localD db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer getRcvFileTransfer st userId fileId = @@ -1870,8 +2010,8 @@ getRcvFileTransfer_ db userId fileId = <$> DB.query db [sql| - SELECT r.file_status, r.file_queue_info, f.file_name, - f.file_size, f.chunk_size, cs.local_display_name, m.local_display_name, + SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, + f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, c.connection_id, c.agent_conn_id FROM rcv_files r JOIN files f USING (file_id) @@ -1883,16 +2023,16 @@ getRcvFileTransfer_ db userId fileId = (userId, fileId) where rcvFileTransfer :: - [(FileStatus, AConnectionRequestUri, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> + [(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> Either StoreError RcvFileTransfer - rcvFileTransfer [(fileStatus', fileConnReq, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] = + rcvFileTransfer [(fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_, contactName_, memberName_, filePath_, connId_, agentConnId_)] = let fileInv = FileInvitation {fileName, fileSize, fileConnReq} fileInfo = (filePath_, connId_, agentConnId_) in case contactName_ <|> memberName_ of Nothing -> Left $ SERcvFileInvalid fileId Just name -> case fileStatus' of - FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize} + FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize, cancelled, grpMemberId} FSAccepted -> ft name fileInv RFSAccepted fileInfo FSConnected -> ft name fileInv RFSConnected fileInfo FSComplete -> ft name fileInv RFSComplete fileInfo @@ -1903,6 +2043,7 @@ getRcvFileTransfer_ db userId fileId = let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId} in Right RcvFileTransfer {..} _ -> Left $ SERcvFileInvalid fileId + cancelled = fromMaybe False cancelled_ rcvFileTransfer _ = Left $ SERcvFileNotFound fileId acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ConnId -> FilePath -> m () @@ -1996,7 +2137,8 @@ getFileTransferProgress st userId fileId = ft <- ExceptT $ getFileTransfer_ db userId fileId liftIO $ (ft,) . map fromOnly <$> case ft of - FTSnd _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) + FTSnd _ [] -> pure [Only 0] + FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) getFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError FileTransfer) @@ -2014,7 +2156,13 @@ getFileTransfer_ db userId fileId = (userId, fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> IO (Either StoreError FileTransfer) - fileTransfer ((Just _, Nothing) : _) = FTSnd <$$> getSndFileTransfers_ db userId fileId + fileTransfer [(Nothing, Nothing)] = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + pure FTSnd {fileTransferMeta, sndFileTransfers = []} + fileTransfer ((Just _, Nothing) : _) = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId + pure FTSnd {fileTransferMeta, sndFileTransfers} fileTransfer [(Nothing, Just _)] = FTRcv <$$> getRcvFileTransfer_ db userId fileId fileTransfer _ = pure . Left $ SEFileNotFound fileId @@ -2043,6 +2191,22 @@ getSndFileTransfers_ db userId fileId = Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId} Nothing -> Left $ SESndFileInvalid fileId +getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError FileTransferMeta) +getFileTransferMeta_ db userId fileId = + firstRow fileTransferMeta (SEFileNotFound fileId) $ + DB.query + db + [sql| + SELECT f.file_name, f.file_size, f.chunk_size, f.file_path, f.cancelled + FROM files f + WHERE f.user_id = ? AND f.file_id = ? + |] + (userId, fileId) + where + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe Bool) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = + FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} + createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage createNewSndMessage st gVar connOrGroupId mkMessage = liftIOEither . withTransaction st $ \db -> @@ -3380,6 +3544,8 @@ data StoreError | SERcvFileNotFound {fileId :: FileTransferId} | SEFileNotFound {fileId :: FileTransferId} | SERcvFileInvalid {fileId :: FileTransferId} + | SESharedMsgIdNotFoundByFileId {fileId :: FileTransferId} + | SEFileIdNotFoundBySharedMsgId {sharedMsgId :: SharedMsgId} | SEConnectionNotFound {agentConnId :: AgentConnId} | SEIntroNotFound | SEUniqueID diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index cc7aa47cf5..6b215bd344 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -47,7 +47,9 @@ runInputLoop ct cc = forever $ do Right SendMessage {} -> True Right SendGroupMessage {} -> True Right SendFile {} -> True + Right SendFileInv {} -> True Right SendGroupFile {} -> True + Right SendGroupFileInv {} -> True Right SendMessageQuote {} -> True Right SendGroupMessageQuote {} -> True Right SendMessageBroadcast {} -> True diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index a25a674e6a..03eda5f6f5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -31,7 +31,7 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) @@ -522,7 +522,7 @@ type FileTransferId = Int64 data FileInvitation = FileInvitation { fileName :: String, fileSize :: Integer, - fileConnReq :: AConnectionRequestUri + fileConnReq :: Maybe ConnReqInvitation } deriving (Eq, Show, Generic, FromJSON) @@ -533,7 +533,9 @@ data RcvFileTransfer = RcvFileTransfer fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, senderDisplayName :: ContactName, - chunkSize :: Integer + chunkSize :: Integer, + cancelled :: Bool, + grpMemberId :: Maybe Int64 } deriving (Eq, Show, Generic, FromJSON) @@ -601,13 +603,34 @@ instance FromField AgentInvId where fromField f = AgentInvId <$> fromField f instance ToField AgentInvId where toField (AgentInvId m) = toField m -data FileTransfer = FTSnd {sndFileTransfers :: [SndFileTransfer]} | FTRcv RcvFileTransfer +data FileTransfer + = FTSnd + { fileTransferMeta :: FileTransferMeta, + sndFileTransfers :: [SndFileTransfer] + } + | FTRcv {rcvFileTransfer :: RcvFileTransfer} deriving (Show, Generic) instance ToJSON FileTransfer where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" +data FileTransferMeta = FileTransferMeta + { fileId :: FileTransferId, + fileName :: String, + filePath :: String, + fileSize :: Integer, + chunkSize :: Integer, + cancelled :: Bool + } + deriving (Eq, Show, Generic) + +instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions + +fileTransferCancelled :: FileTransfer -> Bool +fileTransferCancelled (FTSnd FileTransferMeta {cancelled} _) = cancelled +fileTransferCancelled (FTRcv RcvFileTransfer {cancelled}) = cancelled + data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) instance FromField FileStatus where fromField = fromTextField_ decodeText diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d6f230b4e2..5ef9be10c3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -93,7 +93,7 @@ responseToView testView = \case CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath -> ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled fts -> viewSndGroupFileCancelled fts + CRSndGroupFileCancelled ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' CRContactUpdated c c' -> viewContactUpdated c c' @@ -488,11 +488,11 @@ viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = [ttyContact c <> " cancelled sending " <> rcvFile ft] -viewSndGroupFileCancelled :: [SndFileTransfer] -> [StyledString] -viewSndGroupFileCancelled fts = +viewSndGroupFileCancelled :: FileTransferMeta -> [SndFileTransfer] -> [StyledString] +viewSndGroupFileCancelled FileTransferMeta {fileId, fileName} fts = case filter (\SndFileTransfer {fileStatus = s} -> s /= FSCancelled && s /= FSComplete) fts of - [] -> ["sending file can't be cancelled"] - ts@(ft : _) -> ["cancelled sending " <> sndFile ft <> " to " <> listMembers ts] + [] -> ["cancelled sending " <> fileTransferStr fileId fileName] + ts -> ["cancelled sending " <> fileTransferStr fileId fileName <> " to " <> listRecipients ts] sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString] sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = @@ -533,24 +533,20 @@ fileTransferStr :: Int64 -> String -> StyledString fileTransferStr fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")" viewFileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] -viewFileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = - ["sending " <> sndFile ft <> " " <> sndStatus] - where - sndStatus = case fileStatus of - FSNew -> "not accepted yet" - FSAccepted -> "just started" - FSConnected -> "progress " <> fileProgress chunksNum chunkSize fileSize - FSComplete -> "complete" - FSCancelled -> "cancelled" -viewFileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"] -viewFileTransferStatus (FTSnd fts@(ft : _), chunksNum) = - case concatMap membersTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of - [membersStatus] -> ["sending " <> sndFile ft <> " " <> membersStatus] - membersStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) membersStatuses +viewFileTransferStatus (FTSnd FileTransferMeta {fileId, fileName, cancelled} [], _) = + [ "sending " <> fileTransferStr fileId fileName <> ": no file transfers" + <> if cancelled then ", file transfer cancelled" else "" + ] +viewFileTransferStatus (FTSnd FileTransferMeta {cancelled} fts@(ft : _), chunksNum) = + recipientStatuses <> ["file transfer cancelled" | cancelled] where + recipientStatuses = + case concatMap recipientsTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of + [recipientsStatus] -> ["sending " <> sndFile ft <> " " <> recipientsStatus] + recipientsStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) recipientsStatuses fs = fileStatus :: SndFileTransfer -> FileStatus - membersTransferStatus [] = [] - membersTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listMembers ts] + recipientsTransferStatus [] = [] + recipientsTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listRecipients ts] where sndStatus = case fileStatus of FSNew -> "not accepted" @@ -568,8 +564,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath RFSCancelled RcvFileInfo {filePath} -> "cancelled, received part path: " <> plain filePath -listMembers :: [SndFileTransfer] -> StyledString -listMembers = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) +listRecipients :: [SndFileTransfer] -> StyledString +listRecipients = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) fileProgress :: [Integer] -> Integer -> Integer -> StyledString fileProgress chunksNum chunkSize fileSize = @@ -622,6 +618,7 @@ viewChatError = \case SEDuplicateContactLink -> ["you already have chat address, to show: " <> highlight' "/sa"] SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"] SEContactRequestNotFoundByName c -> ["no contact request from " <> ttyContact c] + SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index b27780430d..42cc82b908 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -135,6 +135,7 @@ getTermLine :: TestCC -> IO String getTermLine = atomically . readTQueue . termQ -- Use code below to echo virtual terminal +-- getTermLine :: TestCC -> IO String -- getTermLine cc = do -- s <- atomically . readTQueue $ termQ cc -- name <- userName cc diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 7a3492af27..dd450b132a 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -57,6 +57,12 @@ chatTests = do it "sender cancelled file transfer" testFileSndCancel it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer + describe "sending and receiving files v2" $ do + it "send and receive file" testFileTransferV2 + it "send and receive a small file" testSmallFileTransferV2 + it "sender cancelled file transfer" testFileSndCancelV2 + it "recipient cancelled file transfer" testFileRcvCancelV2 + it "send and receive file to group" testGroupFileTransferV2 describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "auto accept contact requests" testUserContactLinkAutoAccept @@ -995,7 +1001,8 @@ testFileSndCancel = [ do alice <## "cancelled sending file 1 (test.jpg) to bob" alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled", + alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled", do bob <## "alice cancelled sending file 1 (test.jpg)" bob ##> "/fs 1" @@ -1021,7 +1028,7 @@ testFileRcvCancel = do alice <## "bob cancelled receiving file 1 (test.jpg)" alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled" + alice <## "sending file 1 (test.jpg) cancelled: bob" ] checkPartialTransfer where @@ -1070,6 +1077,135 @@ testGroupFileTransfer = cath <## "completed receiving file 1 (test.jpg) from alice" ] +testFileTransferV2 :: IO () +testFileTransferV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + concurrentlyN_ + [ do + bob #> "@alice receiving here..." + bob <## "completed receiving file 1 (test.jpg) from alice", + do + alice <# "bob> receiving here..." + alice <## "completed sending file 1 (test.jpg) to bob" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + +testSmallFileTransferV2 :: IO () +testSmallFileTransferV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice `send` "/f_v2 @bob ./tests/fixtures/test.txt" + alice <# "/f @bob ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.txt" + concurrentlyN_ + [ do + bob <## "started receiving file 1 (test.txt) from alice" + bob <## "completed receiving file 1 (test.txt) from alice", + do + alice <## "started sending file 1 (test.txt) to bob" + alice <## "completed sending file 1 (test.txt) to bob" + ] + src <- B.readFile "./tests/fixtures/test.txt" + dest <- B.readFile "./tests/tmp/test.txt" + dest `shouldBe` src + +testFileSndCancelV2 :: IO () +testFileSndCancelV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + alice ##> "/fc 1" + concurrentlyN_ + [ do + alice <## "cancelled sending file 1 (test.jpg) to bob" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled", + do + bob <## "alice cancelled sending file 1 (test.jpg)" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg" + ] + checkPartialTransfer + +testFileRcvCancelV2 :: IO () +testFileRcvCancelV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + bob ##> "/fs 1" + getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") + waitFileExists "./tests/tmp/test.jpg" + bob ##> "/fc 1" + concurrentlyN_ + [ do + bob <## "cancelled receiving file 1 (test.jpg) from alice" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", + do + alice <## "bob cancelled receiving file 1 (test.jpg)" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" + ] + checkPartialTransfer + where + waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f + +testGroupFileTransferV2 :: IO () +testGroupFileTransferV2 = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice `send` "/f_v2 #team ./tests/fixtures/test.jpg" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + alice ##> "/fs 1" + getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) complete: bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath" + alice ##> "/fs 1" + getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + testUserContactLink :: IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1324,6 +1460,19 @@ startFileTransfer alice bob = do (bob <## "started receiving file 1 (test.jpg) from alice") (alice <## "started sending file 1 (test.jpg) to bob") +startFileTransferV2 :: TestCC -> TestCC -> IO () +startFileTransferV2 alice bob = do + alice `send` "/f_v2 @bob ./tests/fixtures/test.jpg" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + checkPartialTransfer :: IO () checkPartialTransfer = do src <- B.readFile "./tests/fixtures/test.jpg" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 344c7e0bb7..bd898a00f4 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -109,10 +109,25 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello") + it "x.msg.update" $ + "{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") + it "x.msg.del" $ + "{\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + #==# XMsgDel (SharedMsgId "\1\2\3\4") + it "x.msg.deleted" $ + "{\"event\":\"x.msg.deleted\",\"params\":{}}" + #==# XMsgDeleted it "x.file" $ "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = ACR SCMInvitation testConnReq} + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq} + it "x.file without file invitation" $ + "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing} it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" + it "x.file.acpt.inv" $ + "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + #==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg" it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} it "x.contact with xContactId" $ From de64f3a1a000807f5862cf431481d09cc4946f75 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 5 Apr 2022 12:44:22 +0400 Subject: [PATCH 06/37] tests: maintain schema dump (#505) --- simplex-chat.cabal | 1 + src/Simplex/Chat/Migrations/chat_schema.sql | 283 ++++++++++++++++++++ tests/SchemaDump.hs | 30 +++ tests/Test.hs | 2 + 4 files changed, 316 insertions(+) create mode 100644 src/Simplex/Chat/Migrations/chat_schema.sql create mode 100644 tests/SchemaDump.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6b91a1e3b7..9af114a67d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -201,6 +201,7 @@ test-suite simplex-chat-test MarkdownTests MobileTests ProtocolTests + SchemaDump Paths_simplex_chat hs-source-dirs: tests diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql new file mode 100644 index 0000000000..afe9c16eaa --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -0,0 +1,283 @@ +CREATE TABLE migrations ( + name TEXT NOT NULL, + ts TEXT NOT NULL, + PRIMARY KEY (name) + ); +CREATE TABLE contact_profiles ( -- remote user profile + contact_profile_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT); +CREATE INDEX contact_profiles_index ON contact_profiles (display_name, full_name); +CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL UNIQUE, + active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 1 for active user + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE display_names ( + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + ldn_base TEXT NOT NULL, + ldn_suffix INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + PRIMARY KEY (user_id, local_display_name) ON CONFLICT FAIL, + UNIQUE (user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL +) WITHOUT ROWID; +CREATE TABLE contacts ( + contact_id INTEGER PRIMARY KEY, + contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- NULL if it's an incognito profile + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user + via_group INTEGER REFERENCES groups (group_id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, contact_profile_id) +); +CREATE TABLE sent_probes ( + sent_probe_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE, + probe BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (user_id, probe) +); +CREATE TABLE sent_probe_hashes ( + sent_probe_hash_id INTEGER PRIMARY KEY, + sent_probe_id INTEGER NOT NULL REFERENCES sent_probes ON DELETE CASCADE, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (sent_probe_id, contact_id) +); +CREATE TABLE received_probes ( + received_probe_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + probe BLOB, + probe_hash BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); +CREATE TABLE known_servers( + server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (user_id, host, port) +) WITHOUT ROWID; +CREATE TABLE group_profiles ( -- shared group profiles + group_profile_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, -- this name must not contain spaces + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT); +CREATE TABLE groups ( + group_id INTEGER PRIMARY KEY, -- local group ID + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, -- local group name without spaces + group_profile_id INTEGER REFERENCES group_profiles ON DELETE SET NULL, -- shared group profile + inv_queue_info BLOB, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- received + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, group_profile_id) +); +CREATE INDEX idx_groups_inv_queue_info ON groups (inv_queue_info); +CREATE TABLE group_members ( -- group members, excluding the local user + group_member_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, -- shared member ID, unique per group + member_role TEXT NOT NULL, -- owner, admin, member + member_category TEXT NOT NULL, -- see GroupMemberCategory + member_status TEXT NOT NULL, -- see GroupMemberStatus + invited_by INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, -- NULL for the members who joined before the current user and for the group creator + sent_inv_queue_info BLOB, -- sent + group_queue_info BLOB, -- received + direct_queue_info BLOB, -- received + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, -- should be the same as contact + contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (group_id, member_id) +); +CREATE TABLE group_member_intros ( + group_member_intro_id INTEGER PRIMARY KEY, + re_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + to_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + group_queue_info BLOB, + direct_queue_info BLOB, + intro_status TEXT NOT NULL, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- see GroupMemberIntroStatus + UNIQUE (re_group_member_id, to_group_member_id) +); +CREATE TABLE files ( + file_id INTEGER PRIMARY KEY, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT, + file_size INTEGER NOT NULL, + chunk_size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE +, chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), cancelled INTEGER); +CREATE TABLE snd_files ( + file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, + file_status TEXT NOT NULL, -- new, accepted, connected, completed + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + PRIMARY KEY (file_id, connection_id) +) WITHOUT ROWID; +CREATE TABLE rcv_files ( + file_id INTEGER PRIMARY KEY REFERENCES files ON DELETE CASCADE, + file_status TEXT NOT NULL, -- new, accepted, connected, completed + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, + file_queue_info BLOB +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); +CREATE TABLE snd_file_chunks ( + file_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER, + chunk_sent INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (sent to agent), 1 (sent to server) + FOREIGN KEY (file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY (file_id, connection_id, chunk_number) +) WITHOUT ROWID; +CREATE TABLE rcv_file_chunks ( + file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER NOT NULL, + chunk_stored INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (received), 1 (appended to file) + PRIMARY KEY (file_id, chunk_number) +) WITHOUT ROWID; +CREATE TABLE connections ( -- all SMP agent connections + connection_id INTEGER PRIMARY KEY, + agent_conn_id BLOB NOT NULL UNIQUE, + conn_level INTEGER NOT NULL DEFAULT 0, + via_contact INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, + conn_status TEXT NOT NULL, + conn_type TEXT NOT NULL, -- contact, member, rcv_file, snd_file + user_contact_link_id INTEGER REFERENCES user_contact_links ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, + snd_file_id INTEGER, + rcv_file_id INTEGER REFERENCES rcv_files (file_id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), via_contact_uri_hash BLOB, xcontact_id BLOB, + FOREIGN KEY (snd_file_id, connection_id) + REFERENCES snd_files (file_id, connection_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE user_contact_links ( + user_contact_link_id INTEGER PRIMARY KEY, + conn_req_contact BLOB NOT NULL, + local_display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), auto_accept INTEGER DEFAULT 0, + UNIQUE (user_id, local_display_name) +); +CREATE TABLE contact_requests ( + contact_request_id INTEGER PRIMARY KEY, + user_contact_link_id INTEGER NOT NULL REFERENCES user_contact_links + ON UPDATE CASCADE ON DELETE CASCADE, + agent_invitation_id BLOB NOT NULL, + contact_profile_id INTEGER REFERENCES contact_profiles + ON DELETE SET NULL -- NULL if it's an incognito profile + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON UPDATE CASCADE + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, contact_profile_id) +); +CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY, + msg_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + chat_msg_event TEXT NOT NULL, -- message event tag (the constructor of CMEventTag) + msg_body BLOB, -- agent message body as received or sent + created_at TEXT NOT NULL DEFAULT (datetime('now')) +, updated_at TEXT CHECK (updated_at NOT NULL), connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, shared_msg_id BLOB, shared_msg_id_user INTEGER); +CREATE TABLE msg_deliveries ( + msg_delivery_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- non UNIQUE for group messages + connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, + agent_msg_id INTEGER, -- internal agent message ID (NULL while pending) + agent_msg_meta TEXT, -- JSON with timestamps etc. sent in MSG, NULL for sent + chat_ts TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- broker_ts for received, created_at for sent + UNIQUE (connection_id, agent_msg_id) +); +CREATE TABLE msg_delivery_events ( + msg_delivery_event_id INTEGER PRIMARY KEY, + msg_delivery_id INTEGER NOT NULL REFERENCES msg_deliveries ON DELETE CASCADE, -- non UNIQUE for multiple events per msg delivery + delivery_status TEXT NOT NULL, -- see MsgDeliveryStatus for allowed values + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (msg_delivery_id, delivery_status) +); +CREATE TABLE pending_group_messages ( + pending_group_message_id INTEGER PRIMARY KEY, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, + group_member_intro_id INTEGER REFERENCES group_member_intros ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE chat_items ( + chat_item_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- NULL for sent even if group_id is not + chat_msg_id INTEGER, -- sent as part of the message that created the item + created_by_msg_id INTEGER UNIQUE REFERENCES messages (message_id) ON DELETE SET NULL, + item_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + item_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent + item_deleted INTEGER NOT NULL DEFAULT 0, -- 1 for deleted, + item_content TEXT NOT NULL, -- JSON + item_text TEXT NOT NULL, -- textual representation + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +, item_status TEXT CHECK (item_status NOT NULL), shared_msg_id BLOB, quoted_shared_msg_id BLOB, quoted_sent_at TEXT, quoted_content TEXT, quoted_sent INTEGER, quoted_member_id BLOB, item_edited INTEGER); +CREATE TABLE chat_item_messages ( + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (chat_item_id, message_id) +); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash); +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id); +CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id); +CREATE TABLE smp_servers ( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (host, port) +); +CREATE INDEX idx_messages_shared_msg_id ON messages (shared_msg_id); +CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages (connection_id, shared_msg_id_user, shared_msg_id); +CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages (group_id, shared_msg_id_user, shared_msg_id); +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items (shared_msg_id); diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs new file mode 100644 index 0000000000..03ef2a47d9 --- /dev/null +++ b/tests/SchemaDump.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE OverloadedStrings #-} + +module SchemaDump where + +import ChatClient (withTmpFiles) +import Control.Monad (void) +import Simplex.Chat.Store (createStore) +import System.Process (readCreateProcess, shell) +import Test.Hspec + +testDB :: FilePath +testDB = "tests/tmp/test_chat.db" + +schema :: FilePath +schema = "src/Simplex/Chat/Migrations/chat_schema.sql" + +schemaDumpTest :: Spec +schemaDumpTest = + it "verify and overwrite schema dump" testVerifySchemaDump + +testVerifySchemaDump :: IO () +testVerifySchemaDump = + withTmpFiles $ do + void $ createStore testDB 1 False + void $ readCreateProcess (shell $ "touch " <> schema) "" + savedSchema <- readFile schema + savedSchema `seq` pure () + void $ readCreateProcess (shell $ "sqlite3 " <> testDB <> " .schema > " <> schema) "" + currentSchema <- readFile schema + savedSchema `shouldBe` currentSchema diff --git a/tests/Test.hs b/tests/Test.hs index 8ed0ac0dcb..3df06d6c60 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,6 +3,7 @@ import ChatTests import MarkdownTests import MobileTests import ProtocolTests +import SchemaDump import Test.Hspec main :: IO () @@ -11,3 +12,4 @@ main = withSmpServer . hspec $ do describe "SimpleX chat protocol" protocolTests describe "Mobile API Tests" mobileTests describe "SimpleX chat client" chatTests + describe "Schema dump" schemaDumpTest From 8dd324b9b3f06b65b3f8e19b18e656463366b484 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 6 Apr 2022 13:21:06 +0400 Subject: [PATCH 07/37] core: images api (#506) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- src/Simplex/Chat.hs | 31 +++++---- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Messages.hs | 13 +++- src/Simplex/Chat/Protocol.hs | 40 ++++++++---- src/Simplex/Chat/Store.hs | 4 +- tests/ProtocolTests.hs | 114 +++++++++++++++++++++++++-------- 6 files changed, 148 insertions(+), 58 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f620e034fb..369538f2fb 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -174,16 +174,18 @@ processChatCommand = \case CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessage cType chatId _file mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + -- TODO send message with file attachment; initiate file transfer CTDirect -> do ct <- withStore $ \st -> getContact st userId chatId - sendNewMsg user ct (MCSimple mc) mc Nothing + sendNewMsg user ct (MCSimple (ExtMsgContent mc Nothing)) mc Nothing CTGroup -> do group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - sendNewGroupMsg user group (MCSimple mc) mc Nothing + sendNewGroupMsg user group (MCSimple (ExtMsgContent mc Nothing)) mc Nothing CTContactRequest -> pure $ chatCmdError "not supported" - APISendMessageQuote cType chatId quotedItemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessageQuote cType chatId quotedItemId _file mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + -- TODO send message with file attachment; initiate file transfer CTDirect -> do (ct, qci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId quotedItemId case qci of @@ -197,7 +199,7 @@ processChatCommand = \case send_ chatDir sent qmc = let quotedItem = CIQuote {chatDir, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} mc) mc (Just quotedItem) + in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc Nothing)) mc (Just quotedItem) CTGroup -> do group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved @@ -213,7 +215,7 @@ processChatCommand = \case send_ qd sent GroupMember {memberId} content = let quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content, formattedText} msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem) + in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} (ExtMsgContent mc Nothing)) mc (Just quotedItem) CTContactRequest -> pure $ chatCmdError "not supported" APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do @@ -350,21 +352,21 @@ processChatCommand = \case SendMessage cName msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId mc + processChatCommand $ APISendMessage CTDirect contactId Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do let mc = MCText $ safeDecodeUtf8 msg cts = filter isReady contacts forM_ cts $ \ct -> - void (sendDirectChatItem user ct (XMsgNew $ MCSimple mc) (CISndMsgContent mc) Nothing) + void (sendDirectChatItem user ct (XMsgNew $ MCSimple (ExtMsgContent mc Nothing)) (CISndMsgContent mc) Nothing) `catchError` (toView . CRChatError) CRBroadcastSent mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId mc + processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId Nothing mc DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg) @@ -448,12 +450,12 @@ processChatCommand = \case SendGroupMessage gName msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId mc + processChatCommand $ APISendMessage CTGroup groupId Nothing mc SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId mc + processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId Nothing mc DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do groupId <- withStore $ \st -> getGroupIdByName st user gName deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg) @@ -1636,7 +1638,7 @@ mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do currentTs <- liftIO getCurrentTime let itemText = ciContentToText content meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt - pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem} + pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file = Nothing} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do @@ -1753,8 +1755,8 @@ chatCommandP = <|> "/_get chats" $> APIGetChats <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) - <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) + <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <* A.space <*> msgContentP) + <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <*> optional filePathTagged <* A.space <*> msgContentP) <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) @@ -1850,6 +1852,7 @@ chatCommandP = n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString + filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 00faac4349..6e2419f3a9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -94,8 +94,8 @@ data ChatCommand | APIGetChats | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int - | APISendMessage ChatType Int64 MsgContent - | APISendMessageQuote ChatType Int64 ChatItemId MsgContent + | APISendMessage ChatType Int64 (Maybe FilePath) MsgContent + | APISendMessageQuote ChatType Int64 ChatItemId (Maybe FilePath) MsgContent | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 1a430ed831..7ebdca07f0 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -79,7 +79,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem meta :: CIMeta d, content :: CIContent d, formattedText :: Maybe MarkdownList, - quotedItem :: Maybe (CIQuote c) + quotedItem :: Maybe (CIQuote c), + file :: Maybe CIFile } deriving (Show, Generic) @@ -265,6 +266,16 @@ quoteMsgDirection = \case CIQGroupSnd -> MDSnd CIQGroupRcv _ -> MDRcv +data CIFile = CIFile + { file :: FilePath, -- local file path + loaded :: Bool + } + deriving (Show, Generic) + +instance ToJSON CIFile where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: CIStatus 'MDSnd diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 5dfea4c6b0..3edebc93a8 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -148,16 +148,18 @@ cmToQuotedMsg = \case XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCLink_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCUnknown_ Text instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" MCLink_ -> "link" + MCImage_ -> "image" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ "link" -> Right MCLink_ + "image" -> Right MCImage_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -169,16 +171,16 @@ instance ToJSON MsgContentTag where toEncoding = strToJEncoding data MsgContainer - = MCSimple MsgContent - | MCQuote QuotedMsg MsgContent - | MCForward MsgContent + = MCSimple ExtMsgContent + | MCQuote QuotedMsg ExtMsgContent + | MCForward ExtMsgContent deriving (Eq, Show) mcContent :: MsgContainer -> MsgContent mcContent = \case - MCSimple c -> c - MCQuote _ c -> c - MCForward c -> c + MCSimple (ExtMsgContent c _) -> c + MCQuote _ (ExtMsgContent c _) -> c + MCForward (ExtMsgContent c _) -> c data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData} deriving (Eq, Show, Generic) @@ -193,6 +195,7 @@ instance ToJSON LinkPreview where data MsgContent = MCText Text | MCLink {text :: Text, preview :: LinkPreview} + | MCImage {text :: Text, image :: ImageData} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) @@ -200,21 +203,26 @@ msgContentText :: MsgContent -> Text msgContentText = \case MCText t -> t MCLink {text} -> text + MCImage {text} -> text MCUnknown {text} -> text msgContentTag :: MsgContent -> MsgContentTag msgContentTag = \case MCText _ -> MCText_ MCLink {} -> MCLink_ + MCImage {} -> MCImage_ MCUnknown {tag} -> MCUnknown_ tag +data ExtMsgContent = ExtMsgContent MsgContent (Maybe FileInvitation) + deriving (Eq, Show) + parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) <|> MCSimple <$> mc where - mc = v .: "content" + mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" instance FromJSON MsgContent where parseJSON (J.Object v) = @@ -224,6 +232,10 @@ instance FromJSON MsgContent where text <- v .: "text" preview <- v .: "preview" pure MCLink {text, preview} + MCImage_ -> do + text <- v .: "text" + image <- v .: "image" + pure MCImage {image, text} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -235,19 +247,25 @@ unknownMsgType = "unknown message type" msgContainerJSON :: MsgContainer -> J.Object msgContainerJSON = \case - MCQuote qm c -> JM.fromList ["quote" .= qm, "content" .= c] - MCForward c -> JM.fromList ["forward" .= True, "content" .= c] - MCSimple c -> JM.fromList ["content" .= c] + MCQuote qm (ExtMsgContent c file) -> JM.fromList $ withFile ["quote" .= qm, "content" .= c] file + MCForward (ExtMsgContent c file) -> JM.fromList $ withFile ["forward" .= True, "content" .= c] file + MCSimple (ExtMsgContent c file) -> JM.fromList $ withFile ["content" .= c] file + where + withFile l = \case + Nothing -> l + Just f -> l <> ["file" .= fileInvitationJSON f] instance ToJSON MsgContent where toJSON = \case MCUnknown {json} -> J.Object json MCText t -> J.object ["type" .= MCText_, "text" .= t] MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] + MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview + MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image instance ToField MsgContent where toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index c4f25e1540..e63edd0a6a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -3391,7 +3391,7 @@ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatu where cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> CChatItem 'CTDirect cItem d chatDir ciStatus content = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file = Nothing} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt @@ -3424,7 +3424,7 @@ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemTe where cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> CChatItem 'CTGroup cItem d chatDir ciStatus content quotedMember_ = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file = Nothing} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index bd898a00f4..b5da5bb901 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -89,26 +89,59 @@ testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do - it "x.msg.new" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple $ MCText "hello") - it "x.msg.new" $ + it "x.msg.new simple text" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)) + it "x.msg.new simple link" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" - #==# XMsgNew (MCSimple $ MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA"}) - it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello")) - it "x.msg.new" $ + #==# XMsgNew (MCSimple (ExtMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA"}) Nothing)) + it "x.msg.new simple image" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "https://simplex.chat" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) + it "x.msg.new chat message " $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)))) + it "x.msg.new quote" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") - ( XMsgNew $ - MCQuote - ( QuotedMsg - (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) - $ MCText "hello there!" - ) - (MCText "hello to you too") + ( XMsgNew + ( MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + ( ExtMsgContent + (MCText "hello to you too") + Nothing + ) + ) ) - it "x.msg.new" $ + it "x.msg.new forward" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello") + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing)) + it "x.msg.new simple with file invitation" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) + it "x.msg.new quote with file invitation" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage + (Just $ SharedMsgId "\1\2\3\4") + ( XMsgNew + ( MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + ( ExtMsgContent + (MCText "hello to you too") + (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}) + ) + ) + ) + it "x.msg.new forward with file invitation" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) it "x.msg.update" $ "{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") @@ -124,12 +157,18 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.file without file invitation" $ "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing} - it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" + it "x.file.acpt" $ + "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" + #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg" - it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile - it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} + it "x.info" $ + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XInfo testProfile + it "x.info with empty full name" $ + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" + #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} it "x.contact with xContactId" $ "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") @@ -145,8 +184,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.inv" $ "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile} - it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") - it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") + it "x.grp.acpt" $ + "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} @@ -162,12 +202,30 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.mem.info" $ "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile - it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4") - it "x.grp.mem.con.all" $ "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4") - it "x.grp.mem.del" $ "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemDel (MemberId "\1\2\3\4") - it "x.grp.leave" $ "{\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave - it "x.grp.del" $ "{\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel - it "x.info.probe" $ "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") - it "x.info.probe.check" $ "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") - it "x.info.probe.ok" $ "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbeOk (Probe "\1\2\3\4") - it "x.ok" $ "{\"event\":\"x.ok\",\"params\":{}}" ==# XOk + it "x.grp.mem.con" $ + "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemCon (MemberId "\1\2\3\4") + it "x.grp.mem.con.all" $ + "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemConAll (MemberId "\1\2\3\4") + it "x.grp.mem.del" $ + "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemDel (MemberId "\1\2\3\4") + it "x.grp.leave" $ + "{\"event\":\"x.grp.leave\",\"params\":{}}" + ==# XGrpLeave + it "x.grp.del" $ + "{\"event\":\"x.grp.del\",\"params\":{}}" + ==# XGrpDel + it "x.info.probe" $ + "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" + #==# XInfoProbe (Probe "\1\2\3\4") + it "x.info.probe.check" $ + "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" + #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") + it "x.info.probe.ok" $ + "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" + #==# XInfoProbeOk (Probe "\1\2\3\4") + it "x.ok" $ + "{\"event\":\"x.ok\",\"params\":{}}" + ==# XOk From 082e62c56bedd9db82d8015f8160a7ad48669ba9 Mon Sep 17 00:00:00 2001 From: Moritz Angermann Date: Thu, 7 Apr 2022 16:12:54 +0800 Subject: [PATCH 08/37] Update flake.nix (#508) * Update flake.nix * update nix file, simplexmq sha Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- cabal.project | 2 +- flake.nix | 37 +++++++++++++++++++++++++++++++++---- sha256map.nix | 2 +- stack.yaml | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/cabal.project b/cabal.project index b30676122e..37dd62c1ee 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3ba1926b1e5ab32451a2239831d614492d40c9be + tag: ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8 source-repository-package type: git diff --git a/flake.nix b/flake.nix index ecd71e5f95..645e9d8b4d 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,7 @@ let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: let pkgs = haskellNix.legacyPackages.${system}; in - let drv = pkgs': pkgs'.haskell-nix.project { + let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; index-state = "2022-01-24T00:00:00Z"; # We need this, to specify we want the cabal project. @@ -25,8 +25,10 @@ } ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; - })]; + })] ++ extra-modules; }; in + # by defualt we don't need to pass extra-modules. + let drv = pkgs': drv' { extra-modules = []; inherit pkgs' }; in # This will package up all *.a in $out into a pkg.zip that can # be downloaded from hydra. let withHydraLibPkg = pkg: pkg.overrideAttrs (old: { @@ -211,7 +213,8 @@ }; }; "aarch64-darwin" = { - "aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { + # this is the aarch64-darwin iOS build (to be patched with mac2ios) + "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swiftJSON = true; }] } ).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. # for iOS we also use -staticlib, to get one rolled up library. @@ -229,7 +232,33 @@ find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; # There is no static libc ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64.zip *) + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64-swift-json.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + # This is the aarch64-darwin build with tagged JSON format (for Mac & Flutter) + "aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # we need threaded here, otherwise all the queing logic doesn't work properly. + # for iOS we also use -staticlib, to get one rolled up library. + # still needs mac2ios patching of the archives. + ghcOptions = [ "-staticlib" "-threaded" ]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \; + find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \; + find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # There is no static libc + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64-tagged-json.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ diff --git a/sha256map.nix b/sha256map.nix index 77f2287dd2..abc341216e 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3ba1926b1e5ab32451a2239831d614492d40c9be" = "1rgadib3xjzi81i1xda55gv1mdaq8vvyyh65qg482rnyh6kq9f6g"; + "https://github.com/simplex-chat/simplexmq.git"."ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8" = "1lsp2x1sm5bpmax6ngwnmn8hb5812mqigszjxigip69kyaq5nn2s"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index b9c297539d..ff5608a855 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 3ba1926b1e5ab32451a2239831d614492d40c9be + commit: ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From 3f86737d3f12258cf7dbe16095af7e05725d7911 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Apr 2022 10:33:32 +0100 Subject: [PATCH 09/37] fix flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 645e9d8b4d..7144fff733 100644 --- a/flake.nix +++ b/flake.nix @@ -28,7 +28,7 @@ })] ++ extra-modules; }; in # by defualt we don't need to pass extra-modules. - let drv = pkgs': drv' { extra-modules = []; inherit pkgs' }; in + let drv = pkgs': drv' { extra-modules = []; inherit pkgs'; }; in # This will package up all *.a in $out into a pkg.zip that can # be downloaded from hydra. let withHydraLibPkg = pkg: pkg.overrideAttrs (old: { From f12b5524fd8b91e5e6a723d0410bedd14c21f2c9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Apr 2022 10:43:13 +0100 Subject: [PATCH 10/37] fix flake.nix 2 --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 7144fff733..4a17194ff0 100644 --- a/flake.nix +++ b/flake.nix @@ -214,7 +214,7 @@ }; "aarch64-darwin" = { # this is the aarch64-darwin iOS build (to be patched with mac2ios) - "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swiftJSON = true; }] } ).simplex-chat.components.library.override { + "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swiftJSON = true; }]; } ).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. # for iOS we also use -staticlib, to get one rolled up library. From 238cc8b90b1b1d64e6de042fc43caadb9a88305a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 7 Apr 2022 16:11:18 +0100 Subject: [PATCH 11/37] ios: update libs --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 60 +++++++++++----------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index eb0b72e9cf..4254809373 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -29,16 +29,16 @@ 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; - 5C41155C27FB81D80054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155727FB81D80054D6CB /* libgmp.a */; }; - 5C41155D27FB81D80054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155727FB81D80054D6CB /* libgmp.a */; }; - 5C41155E27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; - 5C41155F27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; - 5C41156027FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; - 5C41156127FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; - 5C41156227FB81D80054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155A27FB81D80054D6CB /* libffi.a */; }; - 5C41156327FB81D80054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155A27FB81D80054D6CB /* libffi.a */; }; - 5C41156427FB81D80054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155B27FB81D80054D6CB /* libgmpxx.a */; }; - 5C41156527FB81D80054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41155B27FB81D80054D6CB /* libgmpxx.a */; }; + 5C41158927FF2D400054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158427FF2D400054D6CB /* libgmpxx.a */; }; + 5C41158A27FF2D400054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158427FF2D400054D6CB /* libgmpxx.a */; }; + 5C41158B27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; + 5C41158C27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; + 5C41158D27FF2D400054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158627FF2D400054D6CB /* libgmp.a */; }; + 5C41158E27FF2D400054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158627FF2D400054D6CB /* libgmp.a */; }; + 5C41158F27FF2D400054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158727FF2D400054D6CB /* libffi.a */; }; + 5C41159027FF2D400054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158727FF2D400054D6CB /* libffi.a */; }; + 5C41159127FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; + 5C41159227FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; @@ -149,11 +149,11 @@ 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; - 5C41155727FB81D80054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a"; sourceTree = ""; }; - 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a"; sourceTree = ""; }; - 5C41155A27FB81D80054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C41155B27FB81D80054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C41158427FF2D400054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a"; sourceTree = ""; }; + 5C41158627FF2D400054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C41158727FF2D400054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a"; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; @@ -209,14 +209,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C41155E27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, + 5C41158927FF2D400054D6CB /* libgmpxx.a in Frameworks */, + 5C41158B27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C41155C27FB81D80054D6CB /* libgmp.a in Frameworks */, - 5C41156427FB81D80054D6CB /* libgmpxx.a in Frameworks */, - 5C41156227FB81D80054D6CB /* libffi.a in Frameworks */, - 5C41156027FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, + 5C41158F27FF2D400054D6CB /* libffi.a in Frameworks */, + 5C41158D27FF2D400054D6CB /* libgmp.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, + 5C41159127FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -224,13 +224,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5C41158A27FF2D400054D6CB /* libgmpxx.a in Frameworks */, + 5C41159027FF2D400054D6CB /* libffi.a in Frameworks */, + 5C41158E27FF2D400054D6CB /* libgmp.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C41156327FB81D80054D6CB /* libffi.a in Frameworks */, - 5C41156127FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, - 5C41155D27FB81D80054D6CB /* libgmp.a in Frameworks */, + 5C41159227FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, + 5C41158C27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, - 5C41155F27FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, - 5C41156527FB81D80054D6CB /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -282,11 +282,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C41155A27FB81D80054D6CB /* libffi.a */, - 5C41155727FB81D80054D6CB /* libgmp.a */, - 5C41155B27FB81D80054D6CB /* libgmpxx.a */, - 5C41155827FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */, - 5C41155927FB81D80054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */, + 5C41158727FF2D400054D6CB /* libffi.a */, + 5C41158627FF2D400054D6CB /* libgmp.a */, + 5C41158427FF2D400054D6CB /* libgmpxx.a */, + 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */, + 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */, ); path = Libraries; sourceTree = ""; From 8ef27de503e920db6f8126e8a47c1007db3ad4a1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 8 Apr 2022 09:21:56 +0100 Subject: [PATCH 12/37] update simplexmq, cabal flag, fix tests --- cabal.project | 2 +- flake.nix | 2 +- sha256map.nix | 2 +- stack.yaml | 2 +- tests/MobileTests.hs | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cabal.project b/cabal.project index 37dd62c1ee..9f3a5af05e 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8 + tag: 390e99ca9552c01685dc91cd81b4151285c30b44 source-repository-package type: git diff --git a/flake.nix b/flake.nix index 4a17194ff0..4b0ead6e98 100644 --- a/flake.nix +++ b/flake.nix @@ -214,7 +214,7 @@ }; "aarch64-darwin" = { # this is the aarch64-darwin iOS build (to be patched with mac2ios) - "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swiftJSON = true; }]; } ).simplex-chat.components.library.override { + "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swift = true; }]; } ).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. # for iOS we also use -staticlib, to get one rolled up library. diff --git a/sha256map.nix b/sha256map.nix index abc341216e..466f2b7947 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8" = "1lsp2x1sm5bpmax6ngwnmn8hb5812mqigszjxigip69kyaq5nn2s"; + "https://github.com/simplex-chat/simplexmq.git"."390e99ca9552c01685dc91cd81b4151285c30b44" = "0d3g2n9rxwdc948mn726mf932xsgasqzc8p81xdyq0ii520apsx9"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index ff5608a855..23db2b0e95 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: ac7ffc413e1ff0fd09082278878e9d63e3bdfeb8 + commit: 390e99ca9552c01685dc91cd81b4151285c30b44 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index ce9add0e8a..0ca78fa798 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -16,28 +16,28 @@ mobileTests = do it "start new chat with existing user" testChatApi noActiveUser :: String -#if defined(darwin_HOST_OS) +#if defined(darwin_HOST_OS) && defined(swiftJSON) noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" #else noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" #endif activeUserExists :: String -#if defined(darwin_HOST_OS) +#if defined(darwin_HOST_OS) && defined(swiftJSON) activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"activeUserExists\":{}}}}}}}" #else activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"activeUserExists\"}}}}" #endif activeUser :: String -#if defined(darwin_HOST_OS) +#if defined(darwin_HOST_OS) && defined(swiftJSON) activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}}" #else activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"displayName\":\"alice\",\"fullName\":\"Alice\"},\"activeUser\":true}}}" #endif chatStarted :: String -#if defined(darwin_HOST_OS) +#if defined(darwin_HOST_OS) && defined(swiftJSON) chatStarted = "{\"resp\":{\"chatStarted\":{}}}" #else chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" From 9fda89d0db2f496847aa39fed3a259587099b8d0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 8 Apr 2022 15:44:42 +0100 Subject: [PATCH 13/37] update simplexmq (with swift flag) --- cabal.project | 2 +- sha256map.nix | 2 +- stack.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 9f3a5af05e..14cbb9713d 100644 --- a/cabal.project +++ b/cabal.project @@ -3,7 +3,7 @@ packages: . source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 390e99ca9552c01685dc91cd81b4151285c30b44 + tag: a638486bb3a9b212d15775d0bc63de2e619e0518 source-repository-package type: git diff --git a/sha256map.nix b/sha256map.nix index 466f2b7947..89a6fff080 100644 --- a/sha256map.nix +++ b/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."390e99ca9552c01685dc91cd81b4151285c30b44" = "0d3g2n9rxwdc948mn726mf932xsgasqzc8p81xdyq0ii520apsx9"; + "https://github.com/simplex-chat/simplexmq.git"."a638486bb3a9b212d15775d0bc63de2e619e0518" = "14m6ljmkfvcqnv53lp4ygaqnn2vydhws1hc3ah8zh5l5g4hkif36"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97"; diff --git a/stack.yaml b/stack.yaml index 23db2b0e95..c7d7cac852 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 390e99ca9552c01685dc91cd81b4151285c30b44 + commit: a638486bb3a9b212d15775d0bc63de2e619e0518 # - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977 - github: simplex-chat/aeson commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7 From d90c4261b8ce11f7251172f1ec82286b8001fc48 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Fri, 8 Apr 2022 18:17:10 +0100 Subject: [PATCH 14/37] ios: link previews (#503) * refactor image utils and initial link metadata tools * remove LPMetadata conversion as we will build our own view to avoid network calls * initial very basic preview outline, remove icon loading * connect preview view to compose view * v0.1 barely working * minor refactor * refactor * collect images effectively * link up to api for send/receive * rework async get metadata logic * show previews in chat * refactor resizing logic * checkpoint before view editing * ui changes * housekeeping * ui tweaks * typo * improve link preview design/logic * resize image to target data size * fix link preview state machine * tidy up * fix typo Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/ChatModel.swift | 30 +++++- apps/ios/Shared/Model/SimpleXAPI.swift | 16 +++- .../Views/Chat/ChatItem/FramedItemView.swift | 9 +- apps/ios/Shared/Views/Chat/ChatView.swift | 16 +++- .../Chat/ComposeMessage/ComposeView.swift | 84 ++++++++++++++++- .../Chat/ComposeMessage/SendMessageView.swift | 1 - .../Views/Helpers/ChatItemLinkView.swift | 52 +++++++++++ .../Views/Helpers/ComposeLinkView.swift | 91 +++++++++++++++++++ .../Shared/Views/Helpers/ImagePicker.swift | 58 ++++++++++++ .../Shared/Views/Helpers/ProfileImage.swift | 8 -- .../Views/UserSettings/UserProfile.swift | 34 +------ apps/ios/SimpleX.xcodeproj/project.pbxproj | 12 +++ 12 files changed, 361 insertions(+), 50 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift create mode 100644 apps/ios/Shared/Views/Helpers/ComposeLinkView.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1d52b2ab40..b70aecf771 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -726,6 +726,15 @@ enum CIContent: Decodable, ItemContent { } } } + var msgContent: MsgContent? { + get { + switch self { + case let .sndMsgContent(mc): return mc + case let .rcvMsgContent(mc): return mc + default: return nil + } + } + } } struct RcvFileTransfer: Decodable { @@ -761,6 +770,7 @@ struct CIQuote: Decodable, ItemContent { enum MsgContent { case text(String) + case link(text: String, preview: LinkPreview) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -768,6 +778,7 @@ enum MsgContent { get { switch self { case let .text(text): return text + case let .link(text, _): return text case let .unknown(_, text): return text } } @@ -777,6 +788,8 @@ enum MsgContent { get { switch self { case let .text(text): return "text \(text)" + case let .link(text: text, preview: preview): + return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" default: return "" } } @@ -785,9 +798,11 @@ enum MsgContent { enum CodingKeys: String, CodingKey { case type case text + case preview } } +// TODO define Encodable extension MsgContent: Decodable { init(from decoder: Decoder) throws { do { @@ -797,6 +812,10 @@ extension MsgContent: Decodable { case "text": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .text(text) + case "link": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) + self = .link(text: text, preview: preview) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -812,7 +831,7 @@ struct FormattedText: Decodable { var format: Format? } -enum Format: Decodable { +enum Format: Decodable, Equatable { case bold case italic case strikeThrough @@ -849,3 +868,12 @@ enum FormatColor: String, Decodable { } } } + +// Struct to use with simplex API +struct LinkPreview: Codable { + var uri: URL + var title: String + // TODO remove once optional in haskell + var description: String = "" + var image: String +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 68f38e423e..f834e1ab0b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -13,7 +13,7 @@ import BackgroundTasks private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() -private let jsonEncoder = getJSONEncoder() +let jsonEncoder = getJSONEncoder() enum ChatCommand { case showActiveUser @@ -31,6 +31,7 @@ enum ChatCommand { case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiUpdateProfile(profile: Profile) + case apiParseMarkdown(text: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -57,6 +58,7 @@ enum ChatCommand { case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiParseMarkdown(text): return "/_parse \(text)" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -86,6 +88,7 @@ enum ChatCommand { case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" case .apiUpdateProfile: return "apiUpdateProfile" + case .apiParseMarkdown: return "apiParseMarkdown" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -125,6 +128,7 @@ enum ChatResponse: Decodable, Error { case contactDeleted(contact: Contact) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case apiParsedMarkdown(formattedText: [FormattedText]?) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -166,6 +170,7 @@ enum ChatResponse: Decodable, Error { case .contactDeleted: return "contactDeleted" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" + case .apiParsedMarkdown: return "apiParsedMarkdown" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -210,6 +215,7 @@ enum ChatResponse: Decodable, Error { case let .contactDeleted(contact): return String(describing: contact) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails @@ -487,6 +493,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } +func apiParseMarkdown(text: String) throws -> [FormattedText]? { + let r = chatSendCmdSync(.apiParseMarkdown(text: text)) + if case let .apiParsedMarkdown(formattedText) = r { return formattedText } + throw r +} + func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } @@ -774,7 +786,7 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { return try? JSONSerialization.jsonObject(with: d) as? NSDictionary } -private func encodeJSON(_ value: T) -> String { +func encodeJSON(_ value: T) -> String { let data = try! jsonEncoder.encode(value) return String(decoding: data, as: UTF8.self) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d011edc787..2c0cff82fe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -8,8 +8,8 @@ import SwiftUI -private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) -private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) +let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) +let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11) private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) @@ -51,6 +51,11 @@ struct FramedItemView: View { .frame(minWidth: msgWidth, alignment: .center) .padding(.bottom, 2) } else { + if case let .link(_, preview) = chatItem.content.msgContent { + ChatItemLinkView(linkPreview: preview, width: msgWidth) + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + } MsgContentView( content: chatItem.content, formattedText: chatItem.formattedText, diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7463178efb..ee638d8bf2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -17,6 +17,7 @@ struct ChatView: View { @State var message: String = "" @State var quotedItem: ChatItem? = nil @State var editingItem: ChatItem? = nil + @State var linkPreview: LinkPreview? = nil @State var deletingItem: ChatItem? = nil @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @@ -85,6 +86,7 @@ struct ChatView: View { message: $message, quotedItem: $quotedItem, editingItem: $editingItem, + linkPreview: $linkPreview, sendMessage: sendMessage, resetMessage: { message = "" }, inProgress: inProgress, @@ -200,7 +202,7 @@ struct ChatView: View { } } - func sendMessage(_ msg: String) { + func sendMessage(_ text: String) { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") @@ -210,21 +212,29 @@ struct ChatView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, - msg: .text(msg) + msg: .text(text) ) DispatchQueue.main.async { editingItem = nil + linkPreview = nil let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem) } } else { + let mc: MsgContent + if let preview = linkPreview { + mc = .link(text: text, preview: preview) + } else { + mc = .text(text) + } let chatItem = try await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, quotedItemId: quotedItem?.meta.itemId, - msg: .text(msg) + msg: mc ) DispatchQueue.main.async { quotedItem = nil + linkPreview = nil chatModel.addChatItem(chat.chatInfo, chatItem) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3dc3e1b71a..f71c810434 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -19,21 +19,62 @@ struct ComposeView: View { @Binding var message: String @Binding var quotedItem: ChatItem? @Binding var editingItem: ChatItem? + @Binding var linkPreview: LinkPreview? + var sendMessage: (String) -> Void var resetMessage: () -> Void var inProgress: Bool = false @FocusState.Binding var keyboardVisible: Bool @State var editing: Bool = false + @State var linkUrl: URL? = nil + @State var prevLinkUrl: URL? = nil + @State var pendingLinkUrl: URL? = nil + @State var cancelledLinks: Set = [] + + + private func isValidLink(link: String) -> Bool { + return !(link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")) + } + + func cancelPreview() { + if let uri = linkPreview?.uri.absoluteString { + cancelledLinks.insert(uri) + } + linkPreview = nil + } + + func parseMessage(_ msg: String) -> URL? { + do { + if let parsedMsg = try apiParseMarkdown(text: msg), + let link = parsedMsg.first(where: { + $0.format == .uri && !cancelledLinks.contains($0.text) + }), + isValidLink(link: link.text) { + return URL(string: link.text) + } else { + return nil + } + } catch { + logger.error("apiParseMarkdown error: \(error.localizedDescription)") + return nil + } + } var body: some View { VStack(spacing: 0) { + if let metadata = linkPreview { + ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview) + } if (quotedItem != nil) { ContextItemView(contextItem: $quotedItem, editing: $editing) } else if (editingItem != nil) { ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) } SendMessageView( - sendMessage: sendMessage, + sendMessage: { text in + sendMessage(text) + resetLinkPreview() + }, inProgress: inProgress, message: $message, keyboardVisible: $keyboardVisible, @@ -41,10 +82,48 @@ struct ComposeView: View { ) .background(.background) } + .onChange(of: message) { _ in + if message.count > 0 { + prevLinkUrl = linkUrl + linkUrl = parseMessage(message) + if let url = linkUrl { + if prevLinkUrl == linkUrl { + loadLinkPreview(url) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + loadLinkPreview(url) + } + } + } else { + linkPreview = nil + } + } else { + resetLinkPreview() + } + } .onChange(of: editingItem == nil) { _ in editing = (editingItem != nil) } } + + func loadLinkPreview(_ url: URL) { + if url != linkPreview?.uri && url != pendingLinkUrl { + pendingLinkUrl = url + getLinkPreview(url: url) { lp in + if pendingLinkUrl == url { + linkPreview = lp + pendingLinkUrl = nil + } + } + } + } + + func resetLinkPreview() { + linkUrl = nil + prevLinkUrl = nil + pendingLinkUrl = nil + cancelledLinks = [] + } } struct ComposeView_Previews: PreviewProvider { @@ -53,12 +132,14 @@ struct ComposeView_Previews: PreviewProvider { @FocusState var keyboardVisible: Bool @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") @State var nilItem: ChatItem? = nil + @State var linkPreview: LinkPreview? = nil return Group { ComposeView( message: $message, quotedItem: $item, editingItem: $nilItem, + linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, keyboardVisible: $keyboardVisible @@ -67,6 +148,7 @@ struct ComposeView_Previews: PreviewProvider { message: $message, quotedItem: $nilItem, editingItem: $item, + linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, keyboardVisible: $keyboardVisible diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index a760195fa9..86ab936803 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -91,7 +91,6 @@ struct SendMessageView_Previews: PreviewProvider { @State var editingOff: Bool = false @State var editingOn: Bool = true @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var nilItem: ChatItem? = nil return Group { VStack { diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift new file mode 100644 index 0000000000..a0d5460a5c --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift @@ -0,0 +1,52 @@ +// +// LargeLinkPreviewView.swift +// SimpleX +// +// Created by Ian Davies on 07/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatItemLinkView: View { + @Environment(\.colorScheme) var colorScheme + let linkPreview: LinkPreview + let width: CGFloat + + var body: some View { + VStack(alignment: .center, spacing: 6) { + if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(maxWidth: width) + .clipped() + } + VStack(alignment: .leading, spacing: 6) { + Text(linkPreview.title) + .lineLimit(2) + .padding(.horizontal, 12) + Text(linkPreview.uri.absoluteString) + .font(.caption) + .lineLimit(1) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + } + .frame(maxWidth: width, alignment: .leading) + } + } +} + +struct LargeLinkPreview_Previews: PreviewProvider { + static var previews: some View { + let preview = LinkPreview( + uri: URL(string: "http://DuckDuckGo.com")!, + title: "Privacy, simplified.", + description: "", + image: "data:image/jpg;base64,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" + ) + ChatItemLinkView(linkPreview: preview, width: 300) + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift new file mode 100644 index 0000000000..7ed4fde77f --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -0,0 +1,91 @@ +// +// LinkPreview.swift +// SimpleX +// +// Created by Ian Davies on 04/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import LinkPresentation + + +func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { + logger.debug("getLinkMetadata: fetching URL preview") + LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in + if let e = error { + logger.error("Error retrieving link metadata: \(e.localizedDescription)") + } + if let metadata = metadata, + let imageProvider = metadata.imageProvider, + imageProvider.canLoadObject(ofClass: UIImage.self) { + imageProvider.loadObject(ofClass: UIImage.self){ object, error in + var linkPreview: LinkPreview? = nil + if let error = error { + logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") + } else { + if let image = object as? UIImage, + let resized = resizeImageToDataSize(image, maxSize: 14000), + let title = metadata.title, + let uri = metadata.originalURL { + linkPreview = LinkPreview(uri: uri, title: title, image: resized) + } + } + cb(linkPreview) + } + } else { + cb(nil) + } + } +} + +struct ComposeLinkView: View { + @Environment(\.colorScheme) var colorScheme + let linkPreview: LinkPreview + var cancelPreview: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .center, spacing: 8) { + if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 80, maxHeight: 60) + } + VStack(alignment: .center, spacing: 4) { + Text(linkPreview.title) + .lineLimit(1) + Text(linkPreview.uri.absoluteString) + .font(.caption) + .lineLimit(1) + .foregroundColor(.secondary) + } + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + if let cancelPreview = cancelPreview { + Button { cancelPreview() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } +} + +struct SmallLinkPreview_Previews: PreviewProvider { + static var previews: some View { + let preview = LinkPreview( + uri: URL(string: "http://DuckDuckGo.com")!, + title: "Privacy, simplified.", + description: "", + image: "data:image/jpg;base64,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" + ) + ComposeLinkView(linkPreview: preview, cancelPreview: {}) + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 8c2b68b8bc..51d72a0da3 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -9,6 +9,64 @@ import SwiftUI import PhotosUI +func dropPrefix(_ s: String, _ prefix: String) -> String { + s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s +} + +func dropImagePrefix(_ s: String) -> String { + dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") +} + +func resizeAndCrop(_ image: UIImage, to newSize: CGSize) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + format.opaque = true + return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in + let size = image.size + let hScale = newSize.height / size.height + let vScale = newSize.width / size.width + let scale = max(hScale, vScale) // scaleToFill + let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) + var middle = CGPoint.zero + if resizeSize.width > newSize.width { + middle.x -= (resizeSize.width - newSize.width) / 2 + } else if resizeSize.height > newSize.height { + middle.y -= (resizeSize.height - newSize.height) / 2 + } + image.draw(in: CGRect(origin: middle, size: resizeSize)) + } +} + +func cropToSquare(_ image: UIImage) -> UIImage { + let side = min(image.size.width, image.size.height) + return resizeAndCrop(image, to: CGSize(width: side, height: side)) +} + +func resizeImageToDataSize(_ image: UIImage, maxSize: Int) -> String? { + let size = image.size + var imageStr = compressImage(image) + var resized = image + var ratio: CGFloat = 1 + var dataSize = imageStr?.count ?? 0 + logger.debug("resizeImageToDataSize: initial size \(String(describing: size)), data size \(dataSize)") + while dataSize != 0 && dataSize > maxSize { + ratio *= sqrt(CGFloat(dataSize / maxSize) * 1.2) + resized = resizeAndCrop(resized, to: CGSize(width: size.width / ratio, height: size.height / ratio)) + imageStr = compressImage(resized) + dataSize = imageStr?.count ?? 0 + logger.debug("resizeImageToDataSize: ratio \(ratio)") + } + logger.debug("resizeImageToDataSize: final size \(String(describing: resized.size)), data size \(dataSize)") + return imageStr +} + +func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { + if let data = image.jpegData(compressionQuality: compressionQuality) { + return "data:image/jpg;base64,\(data.base64EncodedString())" + } + return nil +} + enum ImageSource { case imageLibrary case camera diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 74abaca4b9..f8f75d74d2 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -26,14 +26,6 @@ struct ProfileImage: View { .foregroundColor(color) } } - - func dropPrefix(_ s: String, _ prefix: String) -> String { - s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s - } - - func dropImagePrefix(_ s: String) -> String { - dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") - } } struct ProfileImage_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index df2e64c4ba..a83cac3a96 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -98,14 +98,8 @@ struct UserProfile: View { } } .onChange(of: chosenImage) { image in - if let image = image, - let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) { - let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())" - if imageStr.count <= 12500 { - profile.image = imageStr - } else { - logger.error("UserProfile: resized image is too big \(imageStr.count)") - } + if let image = image { + profile.image = resizeImageToDataSize(cropToSquare(image), maxSize: 12500) } else { profile.image = nil } @@ -167,30 +161,6 @@ struct UserProfile: View { } } -func resize(_ image: UIImage, to newSize: CGSize) -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = 1.0 - format.opaque = true - return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in - let size = image.size - let hScale = newSize.height / size.height - let vScale = newSize.width / size.width - let scale = max(hScale, vScale) // scaleToFill - let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) - var middle = CGPoint.zero - if resizeSize.width > newSize.width { - middle.x -= (resizeSize.width - newSize.width) / 2 - } else if resizeSize.height > newSize.height { - middle.y -= (resizeSize.height - newSize.height) / 2 - } - image.draw(in: CGRect(origin: middle, size: resizeSize)) - } -} - -func resizeToSquare(_ image: UIImage, _ side: CGFloat) -> UIImage { - resize(image, to: CGSize(width: side, height: side)) -} - struct UserProfile_Previews: PreviewProvider { static var previews: some View { let chatModel1 = ChatModel() diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4254809373..87261c7cc5 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; + 3CDBCF4327FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; + 3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; }; + 3CDBCF4927FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; @@ -138,6 +142,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; + 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemLinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; @@ -321,6 +327,8 @@ 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */, 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */, 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */, + 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, + 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */, ); path = Helpers; sourceTree = ""; @@ -622,6 +630,8 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, + 3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, @@ -674,6 +684,8 @@ 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, + 3CDBCF4327FAE51000354CDD /* ComposeLinkView.swift in Sources */, + 3CDBCF4927FF621E00354CDD /* ChatItemLinkView.swift in Sources */, 5C764E81279C7276000C6508 /* dummy.m in Sources */, 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */, From 84a77de53ce3e1f1bea21c31c545f0ef07e8e949 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 8 Apr 2022 18:58:09 +0100 Subject: [PATCH 15/37] remove apiParseMarkdown commands from console (#511) --- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f834e1ab0b..f87a178dc1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -329,9 +329,11 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") } - DispatchQueue.main.async { - ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) - ChatModel.shared.terminalItems.append(.resp(.now, resp)) + if case .apiParseMarkdown = cmd {} else { + DispatchQueue.main.async { + ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.resp(.now, resp)) + } } return resp } From 150b4196ea38ee72344f4783fb9675e86c9885a0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 8 Apr 2022 19:58:02 +0100 Subject: [PATCH 16/37] ios: fix scrolling with link previews, fix large terminal item detail view (#512) --- .../Shared/Views/Chat/ChatItem/FramedItemView.swift | 4 +--- .../ios/Shared/Views/Helpers/ChatItemLinkView.swift | 7 ++----- apps/ios/Shared/Views/TerminalView.swift | 13 +++++++++++-- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 2c0cff82fe..57ac507d8e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -52,9 +52,7 @@ struct FramedItemView: View { .padding(.bottom, 2) } else { if case let .link(_, preview) = chatItem.content.msgContent { - ChatItemLinkView(linkPreview: preview, width: msgWidth) - .overlay(DetermineWidth()) - .frame(minWidth: msgWidth, alignment: .leading) + ChatItemLinkView(linkPreview: preview) } MsgContentView( content: chatItem.content, diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift index a0d5460a5c..00f2806828 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift @@ -11,7 +11,6 @@ import SwiftUI struct ChatItemLinkView: View { @Environment(\.colorScheme) var colorScheme let linkPreview: LinkPreview - let width: CGFloat var body: some View { VStack(alignment: .center, spacing: 6) { @@ -20,8 +19,6 @@ struct ChatItemLinkView: View { Image(uiImage: uiImage) .resizable() .scaledToFit() - .frame(maxWidth: width) - .clipped() } VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) @@ -33,7 +30,7 @@ struct ChatItemLinkView: View { .foregroundColor(.secondary) .padding(.horizontal, 12) } - .frame(maxWidth: width, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -46,7 +43,7 @@ struct LargeLinkPreview_Previews: PreviewProvider { description: "", image: "data:image/jpg;base64,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" ) - ChatItemLinkView(linkPreview: preview, width: 300) + ChatItemLinkView(linkPreview: preview) .previewLayout(.fixed(width: 360, height: 200)) } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index ae9321fd43..9e92eebb4d 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -10,6 +10,8 @@ import SwiftUI private let terminalFont = Font.custom("Menlo", size: 16) +private let maxItemSize: Int = 50000 + struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @State var inProgress: Bool = false @@ -24,11 +26,18 @@ struct TerminalView: View { LazyVStack { ForEach(chatModel.terminalItems) { item in NavigationLink { + let s = item.details ScrollView { - Text(item.details) - .textSelection(.enabled) + Text(s.prefix(maxItemSize)) .padding() } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [s]) } label: { + Image(systemName: "square.and.arrow.up") + } + } + } } label: { HStack { Text(item.id.formatted(date: .omitted, time: .standard)) From 13f84f2a963db687eb89a5d12c585235cf1ea301 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sun, 10 Apr 2022 13:30:58 +0400 Subject: [PATCH 17/37] core: sending messages with files (#507) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat.hs | 395 ++++++++++-------- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Messages.hs | 78 ++-- .../Migrations/M20220404_files_cancelled.hs | 12 - .../M20220404_files_status_fields.hs | 19 + src/Simplex/Chat/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Protocol.hs | 12 +- src/Simplex/Chat/Store.hs | 143 +++++-- src/Simplex/Chat/View.hs | 78 ++-- tests/ChatClient.hs | 1 + tests/ChatTests.hs | 206 ++++++++- tests/ProtocolTests.hs | 13 +- 14 files changed, 670 insertions(+), 297 deletions(-) delete mode 100644 src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs create mode 100644 src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9d3e4d2246..9bf5ba8711 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @epoberezkin @efim-poberezkin +* @epoberezkin @jr-simplex diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9af114a67d..9dbb4b6a17 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -34,7 +34,7 @@ library Simplex.Chat.Migrations.M20220302_profile_images Simplex.Chat.Migrations.M20220304_msg_quotes Simplex.Chat.Migrations.M20220321_chat_item_edited - Simplex.Chat.Migrations.M20220404_files_cancelled + Simplex.Chat.Migrations.M20220404_files_status_fields Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 369538f2fb..e93abdedec 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -174,49 +174,84 @@ processChatCommand = \case CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage cType chatId _file mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of - -- TODO send message with file attachment; initiate file transfer + APISendMessage cType chatId file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - ct <- withStore $ \st -> getContact st userId chatId - sendNewMsg user ct (MCSimple (ExtMsgContent mc Nothing)) mc Nothing - CTGroup -> do - group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - sendNewGroupMsg user group (MCSimple (ExtMsgContent mc Nothing)) mc Nothing - CTContactRequest -> pure $ chatCmdError "not supported" - APISendMessageQuote cType chatId quotedItemId _file mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of - -- TODO send message with file attachment; initiate file transfer - CTDirect -> do - (ct, qci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId quotedItemId - case qci of - CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do - case ciContent of - CISndMsgContent qmc -> send_ CIQDirectSnd True qmc - CIRcvMsgContent qmc -> send_ CIQDirectRcv False qmc - _ -> throwChatError CEInvalidQuote + ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId + (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ + msg <- sendDirectContactMessage ct (XMsgNew msgContainer) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveC c + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + where + setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer ct = case file_ of + Nothing -> pure Nothing + Just file -> do + (fileSize, chSize) <- checkSndFile file + (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} + fileId <- withStore $ \st -> createSndFileTransfer st userId ct file fileInvitation agentConnId chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure $ Just (fileInvitation, ciFile) + prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) + prepareMsg fileInvitation_ = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) + Just quotedItemId -> do + CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + withStore $ \st -> getDirectChatItem st userId chatId quotedItemId + (qmc, qd, sent) <- liftEither $ quoteData ciContent + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - send_ :: CIQDirection 'CTDirect -> Bool -> MsgContent -> m ChatResponse - send_ chatDir sent qmc = - let quotedItem = CIQuote {chatDir, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc Nothing)) mc (Just quotedItem) + quoteData :: CIContent d -> Either ChatError (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData (CISndMsgContent qmc) = Right (qmc, CIQDirectSnd, True) + quoteData (CIRcvMsgContent qmc) = Right (qmc, CIQDirectRcv, False) + quoteData _ = Left $ ChatError CEInvalidQuote CTGroup -> do - group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId + Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - qci <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId - case qci of - CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do - case (ciContent, chatDir) of - (CISndMsgContent qmc, _) -> send_ CIQGroupSnd True membership qmc - (CIRcvMsgContent qmc, CIGroupRcv m) -> send_ (CIQGroupRcv $ Just m) False m qmc - _ -> throwChatError CEInvalidQuote + (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer gInfo + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership + msg <- sendGroupMessage gInfo ms (XMsgNew msgContainer) + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + where + setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer gInfo = case file_ of + Nothing -> pure Nothing + Just file -> do + (fileSize, chSize) <- checkSndFile file + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo file fileInvitation chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure $ Just (fileInvitation, ciFile) + prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) + prepareMsg fileInvitation_ membership = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) + Just quotedItemId -> do + CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + withStore $ \st -> getGroupChatItem st user chatId quotedItemId + (qmc, qd, sent, GroupMember {memberId}) <- liftEither $ quoteData ciContent chatDir membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - send_ :: CIQDirection 'CTGroup -> Bool -> GroupMember -> MsgContent -> m ChatResponse - send_ qd sent GroupMember {memberId} content = - let quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content, formattedText} - msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} (ExtMsgContent mc Nothing)) mc (Just quotedItem) + quoteData :: CIContent d -> CIDirection 'CTGroup d -> GroupMember -> Either ChatError (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData (CISndMsgContent qmc) CIGroupSnd membership' = Right (qmc, CIQGroupSnd, True, membership') + quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = Right (qmc, CIQGroupRcv $ Just m, False, m) + quoteData _ _ _ = Left $ ChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError "not supported" + where + unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) + unzipMaybe t = (fst <$> t, snd <$> t) + -- TODO discontinue + APISendMessageQuote cType chatId quotedItemId mc -> + processChatCommand $ APISendMessage cType chatId Nothing (Just quotedItemId) mc APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId @@ -352,21 +387,25 @@ processChatCommand = \case SendMessage cName msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId Nothing mc + processChatCommand $ APISendMessage CTDirect contactId Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do let mc = MCText $ safeDecodeUtf8 msg cts = filter isReady contacts forM_ cts $ \ct -> - void (sendDirectChatItem user ct (XMsgNew $ MCSimple (ExtMsgContent mc Nothing)) (CISndMsgContent mc) Nothing) + void + ( do + sndMsg <- sendDirectContactMessage ct (XMsgNew $ MCSimple (ExtMsgContent mc Nothing)) + saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing + ) `catchError` (toView . CRChatError) CRBroadcastSent mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId Nothing mc + processChatCommand $ APISendMessage CTDirect contactId Nothing (Just quotedItemId) mc DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg) @@ -450,12 +489,12 @@ processChatCommand = \case SendGroupMessage gName msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId Nothing mc + processChatCommand $ APISendMessage CTGroup groupId Nothing Nothing mc SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId Nothing mc + processChatCommand $ APISendMessage CTGroup groupId Nothing (Just quotedItemId) mc DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do groupId <- withStore $ \st -> getGroupIdByName st user gName deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg) @@ -466,110 +505,88 @@ processChatCommand = \case let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc -- old file protocol + -- SendFile cName f -> withUser $ \User {userId} -> do + -- contactId <- withStore $ \st -> getContactIdByName st userId cName + -- processChatCommand $ APISendMessage CTDirect contactId (Just f) Nothing (MCText "") + -- TODO replace with code above when switching from XFile SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} - SndFileTransfer {fileId} <- withStore $ \st -> + let fileName = takeFileName f + fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} + fileId <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize - ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing + msg <- sendDirectContactMessage contact (XFile fileInv) + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + ci <- saveSndChatItem user (CDDirectSnd contact) msg (CISndMsgContent $ MCText "") (Just ciFile) Nothing withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - -- new file protocol + -- new file protocol (not used for direct files) SendFileInv cName f -> withUser $ \user@User {userId} -> withChatLock $ do + ct <- withStore $ \st -> getContactByName st userId cName (fileSize, chSize) <- checkSndFile f - contact <- withStore $ \st -> getContactByName st userId cName - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndFileTransferV2 st userId contact f fileInv chSize - ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + let fileName = takeFileName f + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndFileTransferV2 st userId ct f fileInvitation chSize + let mc = MCText "" + ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + msg <- sendDirectContactMessage ct (XMsgNew (MCSimple (ExtMsgContent mc (Just fileInvitation)))) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile Nothing setActive $ ActiveC cName - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci -- old file protocol + -- TODO discontinue SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do - (fileSize, chSize) <- checkSndFile f Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + (fileSize, chSize) <- checkSndFile f let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}) fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize - -- TODO sendGroupChatItem - same file invitation to all - forM_ ms $ \(m, _, fileInv) -> - traverse (\conn -> sendDirectMessage conn (XFile fileInv) (GroupId groupId)) $ memberConn m + forM_ ms $ \(m, _, fileInvitation) -> + traverse (\conn -> sendDirectMessage conn (XFile fileInvitation) (GroupId groupId)) $ memberConn m setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""} - ciContent = CISndFileInvitation fileId f - cItem@ChatItem {meta = CIMeta {itemId}} <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId itemId - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem - -- new file protocol - SendGroupFileInv gName f -> withUser $ \user@User {userId} -> withChatLock $ do - (fileSize, chSize) <- checkSndFile f - g@(Group gInfo@GroupInfo {membership} _) <- withStore $ \st -> getGroupByName st user gName - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Nothing} - fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo f fileInv chSize - ci <- sendGroupChatItem user g (XFile fileInv) (CISndFileInvitation fileId f) Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci - setActive $ ActiveG gName + ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent $ MCText "") ciFile Nothing pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> do - ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} <- withStore $ \st -> getRcvFileTransfer st userId fileId - unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName - case fileConnReq of - -- old file protocol - Just connReq -> - withChatLock . procCmd $ do - tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fileName) >>= \case - Right agentConnId -> do - filePath <- getRcvFilePath fileId filePath_ fileName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - pure $ CRRcvFileAccepted ft filePath - Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left e -> throwError e - -- new file protocol - Nothing -> - case grpMemberId of - Nothing -> - withChatLock . procCmd $ do - ct <- withStore $ \st -> getContactByName st userId senderDisplayName - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fileName - Just memId -> - withChatLock . procCmd $ do - (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId - case activeConn of - Just conn -> - acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fileName) (GroupId groupId) - _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen - where - acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m ChatResponse - acceptFileV2 sendXFileAcptInv = do - sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId - (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) - filePath <- getRcvFilePath fileId filePath_ fileName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - void $ sendXFileAcptInv sharedMsgId fileInvConnReq - pure $ CRRcvFileAccepted ft filePath + -- new file protocol + SendGroupFileInv gName f -> withUser $ \user -> do + groupId <- withStore $ \st -> getGroupIdByName st user gName + processChatCommand $ APISendMessage CTGroup groupId (Just f) Nothing (MCText "") + ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> + withChatLock . procCmd $ do + ft <- withStore $ \st -> getRcvFileTransfer st userId fileId + (CRRcvFileAccepted ft <$> acceptFileReceive user ft filePath_) `catchError` processError ft + where + processError ft = \case + ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft + ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft + e -> throwError e CancelFile fileId -> withUser $ \User {userId} -> do ft' <- withStore (\st -> getFileTransfer st userId fileId) withChatLock . procCmd $ do - unless (fileTransferCancelled ft') $ - withStore $ \st -> updateFileCancelled st userId fileId case ft' of - FTSnd ftm [] -> do - pure $ CRSndGroupFileCancelled ftm [] FTSnd ftm fts -> do + cancelFileTransfer userId ft' CIFSSndCancelled forM_ fts $ \ft -> cancelSndFileTransfer ft pure $ CRSndGroupFileCancelled ftm fts FTRcv ft -> do + cancelFileTransfer userId ft' CIFSRcvCancelled cancelRcvFileTransfer ft pure $ CRRcvFileCancelled ft + where + cancelFileTransfer :: MsgDirectionI d => UserId -> FileTransfer -> CIFileStatus d -> m () + cancelFileTransfer userId ft ciFileStatus = + unless (fileTransferCancelled ft) $ + withStore $ \st -> do + updateFileCancelled st userId fileId + updateCIFileStatus st userId fileId ciFileStatus FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile @@ -609,14 +626,6 @@ processChatCommand = \case connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId pure CRSentInvitation - sendNewMsg user ct@Contact {localDisplayName = c} msgContainer mc quotedItem = do - ci <- sendDirectChatItem user ct (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem - setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - sendNewGroupMsg user g@(Group gInfo@GroupInfo {localDisplayName = gName} _) msgContainer mc quotedItem = do - ci <- sendGroupChatItem user g (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem - setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -641,17 +650,52 @@ processChatCommand = \case isReady ct = let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady - getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath - getRcvFilePath fileId filePath fileName = case filePath of + +acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath +acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do + unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName + case fileConnReq of + -- old file protocol + Just connReq -> + tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case + Right agentConnId -> do + filePath <- getRcvFilePath filePath_ fName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + pure filePath + Left e -> throwError e + -- new file protocol + Nothing -> + case grpMemberId of + Nothing -> do + ct <- withStore $ \st -> getContactByName st userId senderDisplayName + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fName + Just memId -> do + (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId + case activeConn of + Just conn -> + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) + _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen + where + acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m FilePath + acceptFileV2 sendXFileAcptInv = do + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + filePath <- getRcvFilePath filePath_ fName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + void $ sendXFileAcptInv sharedMsgId fileInvConnReq + pure filePath + where + getRcvFilePath :: Maybe FilePath -> String -> m FilePath + getRcvFilePath fPath_ fn = case fPath_ of Nothing -> do dir <- (`combine` "Downloads") <$> getHomeDirectory ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory - >>= (`uniqueCombine` fileName) + >>= (`uniqueCombine` fn) >>= createEmptyFile Just fPath -> ifM (doesDirectoryExist fPath) - (fPath `uniqueCombine` fileName >>= createEmptyFile) + (fPath `uniqueCombine` fn >>= createEmptyFile) $ ifM (doesFileExist fPath) (throwChatError $ CEFileAlreadyExists fPath) @@ -664,14 +708,14 @@ processChatCommand = \case h <- getFileHandle fileId fPath rcvFiles AppendMode liftIO $ B.hPut h "" >> hFlush h pure fPath - uniqueCombine :: FilePath -> String -> m FilePath - uniqueCombine filePath fileName = tryCombine (0 :: Int) - where - tryCombine n = - let (name, ext) = splitExtensions fileName - suffix = if n == 0 then "" else "_" <> show n - f = filePath `combine` (name <> suffix <> ext) - in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) + uniqueCombine :: FilePath -> String -> m FilePath + uniqueCombine filePath fileName = tryCombine (0 :: Int) + where + tryCombine n = + let (name, ext) = splitExtensions fileName + suffix = if n == 0 then "" else "_" <> show n + f = filePath `combine` (name <> suffix <> ext) + in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} = do @@ -827,7 +871,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgNew mc -> newContentMessage ct mc msg msgMeta XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta - XFile fInv -> processFileInvitation ct fInv msg msgMeta + -- TODO discontinue XFile + XFile fInv -> processFileInvitation' ct fInv msg msgMeta XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInv ct sharedMsgId fileConnReq fName msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv @@ -969,7 +1014,8 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo m sharedMsgId mContent msg XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg - XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta + -- TODO discontinue XFile + XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq fName msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo @@ -1056,6 +1102,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage appendFileChunk ft chunkNo chunk withStore $ \st -> do updateRcvFileStatus st ft FSComplete + updateCIFileStatus st userId fileId CIFSRcvComplete deleteRcvFileChunks st ft toView $ CRRcvFileComplete ft closeFileHandle fileId rcvFiles @@ -1148,13 +1195,24 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do - let content = mcContent mc - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) + let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc + ciFile_ <- processFileInvitation fileInvitation_ $ + \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c + processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) + processFileInvitation fileInvitation_ createRcvFileTransferF = case fileInvitation_ of + Nothing -> pure Nothing + Just fileInvitation@FileInvitation {fileName, fileSize} -> do + chSize <- asks $ fileChunkSize . config + RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + pure $ Just ciFile + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId @@ -1181,8 +1239,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do - let content = mcContent mc - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) + let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc + ciFile_ <- processFileInvitation fileInvitation_ $ + \fi chSize -> withStore $ \st -> createRcvGroupFileTransfer st userId m fi chSize + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText @@ -1212,24 +1272,26 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage else messageError "x.msg.del: group member attempted to delete a message of another member" (SMDSnd, _) -> messageError "x.msg.del: group member attempted invalid message delete" - processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do + -- TODO remove once XFile is discontinued + processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config - ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvFileInvitation ft) - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize + let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c - processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msg msgMeta = do + -- TODO remove once XFile is discontinued + processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do chSize <- asks $ fileChunkSize . config - ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvFileInvitation ft) - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize + let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" @@ -1610,35 +1672,27 @@ saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} withStore $ \st -> createNewMessageAndRcvMsgDelivery st connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery -sendDirectChatItem :: ChatMonad m => User -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTDirect) -> m (ChatItem 'CTDirect 'MDSnd) -sendDirectChatItem user ct chatMsgEvent ciContent quotedItem = do - msg <- sendDirectContactMessage ct chatMsgEvent - saveSndChatItem user (CDDirectSnd ct) msg ciContent quotedItem - -sendGroupChatItem :: ChatMonad m => User -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTGroup) -> m (ChatItem 'CTGroup 'MDSnd) -sendGroupChatItem user (Group g ms) chatMsgEvent ciContent quotedItem = do - msg <- sendGroupMessage g ms chatMsgEvent - saveSndChatItem user (CDGroupSnd g) msg ciContent quotedItem - -saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) -saveSndChatItem user cd msg@SndMessage {sharedMsgId} content quotedItem = do +saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) +saveSndChatItem user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem = do createdAt <- liftIO getCurrentTime ciId <- withStore $ \st -> createNewSndChatItem st user cd msg content quotedItem createdAt - liftIO $ mkChatItem cd ciId content quotedItem (Just sharedMsgId) createdAt createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) createdAt createdAt -saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) -saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content = do +saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content ciFile = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt -- createNewChatItem st user cd $ mkNewChatItem content msg brokerTs createdAt - liftIO $ mkChatItem cd ciId content quotedItem sharedMsgId_ brokerTs createdAt + (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt -mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do +mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) +mkChatItem cd ciId content file quotedItem sharedMsgId itemTs createdAt = do tz <- getCurrentTimeZone currentTs <- liftIO getCurrentTime let itemText = ciContentToText content meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt - pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file = Nothing} + pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do @@ -1755,8 +1809,8 @@ chatCommandP = <|> "/_get chats" $> APIGetChats <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <* A.space <*> msgContentP) - <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <*> optional filePathTagged <* A.space <*> msgContentP) + <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) + <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) <|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal))) @@ -1853,6 +1907,7 @@ chatCommandP = pure $ if B.null n then name else safeDecodeUtf8 n filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) + quotedItemIdTagged = " quoted " *> A.decimal memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6e2419f3a9..1842b632c9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -94,8 +94,8 @@ data ChatCommand | APIGetChats | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int - | APISendMessage ChatType Int64 (Maybe FilePath) MsgContent - | APISendMessageQuote ChatType Int64 ChatItemId (Maybe FilePath) MsgContent + | APISendMessage ChatType Int64 (Maybe FilePath) (Maybe ChatItemId) MsgContent + | APISendMessageQuote ChatType Int64 ChatItemId MsgContent -- TODO discontinue | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 7ebdca07f0..b2bc8d2218 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -20,7 +20,6 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) -import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) @@ -80,11 +79,11 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem content :: CIContent d, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), - file :: Maybe CIFile + file :: Maybe (CIFile d) } deriving (Show, Generic) -instance ToJSON (ChatItem c d) where +instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -197,7 +196,7 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) -instance ToJSON (JSONAnyChatItem c d) where +instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -266,16 +265,63 @@ quoteMsgDirection = \case CIQGroupSnd -> MDSnd CIQGroupRcv _ -> MDRcv -data CIFile = CIFile - { file :: FilePath, -- local file path - loaded :: Bool +data CIFile (d :: MsgDirection) = CIFile + { fileId :: Int64, + fileName :: String, + fileSize :: Integer, + filePath :: Maybe FilePath, -- local file path + fileStatus :: CIFileStatus d } deriving (Show, Generic) -instance ToJSON CIFile where +instance MsgDirectionI d => ToJSON (CIFile d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data CIFileStatus (d :: MsgDirection) where + CIFSSndStored :: CIFileStatus 'MDSnd + CIFSSndCancelled :: CIFileStatus 'MDSnd + CIFSRcvInvitation :: CIFileStatus 'MDRcv + CIFSRcvTransfer :: CIFileStatus 'MDRcv + CIFSRcvComplete :: CIFileStatus 'MDRcv + CIFSRcvCancelled :: CIFileStatus 'MDRcv + +deriving instance Show (CIFileStatus d) + +instance MsgDirectionI d => ToJSON (CIFileStatus d) where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) + +deriving instance Show ACIFileStatus + +instance MsgDirectionI d => StrEncoding (CIFileStatus d) where + strEncode = \case + CIFSSndStored -> "snd_stored" + CIFSSndCancelled -> "snd_cancelled" + CIFSRcvInvitation -> "rcv_invitation" + CIFSRcvTransfer -> "rcv_transfer" + CIFSRcvComplete -> "rcv_complete" + CIFSRcvCancelled -> "rcv_cancelled" + strP = (\(AFS _ st) -> checkDirection st) <$?> strP + +instance StrEncoding ACIFileStatus where + strEncode (AFS _ s) = strEncode s + strP = + A.takeTill (== ' ') >>= \case + "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored + "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled + "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation + "rcv_transfer" -> pure $ AFS SMDRcv CIFSRcvTransfer + "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete + "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled + _ -> fail "bad file status" + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: CIStatus 'MDSnd @@ -377,8 +423,6 @@ data CIContent (d :: MsgDirection) where CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv - CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd - CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv deriving instance Show (CIContent d) @@ -388,8 +432,6 @@ ciContentToText = \case CIRcvMsgContent mc -> msgContentText mc CISndDeleted cidm -> ciDeleteModeToText cidm CIRcvDeleted cidm -> ciDeleteModeToText cidm - CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath - CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d msgDirToDeletedContent_ msgDir mode = case msgDir of @@ -422,8 +464,6 @@ data JSONCIContent | JCIRcvMsgContent {msgContent :: MsgContent} | JCISndDeleted {deleteMode :: CIDeleteMode} | JCIRcvDeleted {deleteMode :: CIDeleteMode} - | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} - | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) instance FromJSON JSONCIContent where @@ -439,8 +479,6 @@ jsonCIContent = \case CIRcvMsgContent mc -> JCIRcvMsgContent mc CISndDeleted cidm -> JCISndDeleted cidm CIRcvDeleted cidm -> JCIRcvDeleted cidm - CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath - CIRcvFileInvitation ft -> JCIRcvFileInvitation ft aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case @@ -448,8 +486,6 @@ aciContentJSON = \case JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm - JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath - JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft -- platform independent data DBJSONCIContent @@ -457,8 +493,6 @@ data DBJSONCIContent | DBJCIRcvMsgContent {msgContent :: MsgContent} | DBJCISndDeleted {deleteMode :: CIDeleteMode} | DBJCIRcvDeleted {deleteMode :: CIDeleteMode} - | DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} - | DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -474,8 +508,6 @@ dbJsonCIContent = \case CIRcvMsgContent mc -> DBJCIRcvMsgContent mc CISndDeleted cidm -> DBJCISndDeleted cidm CIRcvDeleted cidm -> DBJCIRcvDeleted cidm - CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath - CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -483,8 +515,6 @@ aciContentDBJSON = \case DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm - DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath - DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect diff --git a/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs b/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs deleted file mode 100644 index c1187a7ec3..0000000000 --- a/src/Simplex/Chat/Migrations/M20220404_files_cancelled.hs +++ /dev/null @@ -1,12 +0,0 @@ -{-# LANGUAGE QuasiQuotes #-} - -module Simplex.Chat.Migrations.M20220404_files_cancelled where - -import Database.SQLite.Simple (Query) -import Database.SQLite.Simple.QQ (sql) - -m20220404_files_cancelled :: Query -m20220404_files_cancelled = - [sql| -ALTER TABLE files ADD COLUMN cancelled INTEGER; -- 1 for cancelled -|] diff --git a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs b/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs new file mode 100644 index 0000000000..40623a3be6 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220404_files_status_fields where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220404_files_status_fields :: Query +m20220404_files_status_fields = + [sql| +ALTER TABLE files ADD COLUMN cancelled INTEGER; -- 1 for cancelled +ALTER TABLE files ADD COLUMN ci_file_status TEXT; -- CIFileStatus + +DELETE FROM chat_items +WHERE chat_item_id IN ( + SELECT chat_item_id + FROM files +); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index afe9c16eaa..6e1b0ff732 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -134,7 +134,7 @@ CREATE TABLE files ( chunk_size INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE -, chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), cancelled INTEGER); +, chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), cancelled INTEGER, ci_file_status TEXT); CREATE TABLE snd_files ( file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3edebc93a8..b52786fe86 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -112,7 +112,7 @@ data ChatMsgEvent | XMsgUpdate SharedMsgId MsgContent | XMsgDel SharedMsgId | XMsgDeleted - | XFile FileInvitation + | XFile FileInvitation -- TODO discontinue | XFileAcpt String -- old file protocol | XFileAcptInv SharedMsgId ConnReqInvitation String -- new file protocol | XInfo Profile @@ -176,11 +176,11 @@ data MsgContainer | MCForward ExtMsgContent deriving (Eq, Show) -mcContent :: MsgContainer -> MsgContent -mcContent = \case - MCSimple (ExtMsgContent c _) -> c - MCQuote _ (ExtMsgContent c _) -> c - MCForward (ExtMsgContent c _) -> c +mcExtMsgContent :: MsgContainer -> ExtMsgContent +mcExtMsgContent = \case + MCSimple c -> c + MCQuote _ c -> c + MCForward c -> c data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData} deriving (Eq, Show, Generic) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index e63edd0a6a..19a7cfefb3 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -95,6 +95,7 @@ module Simplex.Chat.Store createSndGroupFileTransferV2, createSndGroupFileTransferV2Connection, updateFileCancelled, + updateCIFileStatus, getSharedMsgIdByFileId, getFileIdBySharedMsgId, getGroupFileIdBySharedMsgId, @@ -188,7 +189,7 @@ import Simplex.Chat.Migrations.M20220301_smp_servers import Simplex.Chat.Migrations.M20220302_profile_images import Simplex.Chat.Migrations.M20220304_msg_quotes import Simplex.Chat.Migrations.M20220321_chat_item_edited -import Simplex.Chat.Migrations.M20220404_files_cancelled +import Simplex.Chat.Migrations.M20220404_files_status_fields import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) @@ -213,7 +214,7 @@ schemaMigrations = ("20220302_profile_images", m20220302_profile_images), ("20220304_msg_quotes", m20220304_msg_quotes), ("20220321_chat_item_edited", m20220321_chat_item_edited), - ("20220404_files_cancelled", m20220404_files_cancelled) + ("20220404_files_status_fields", m20220404_files_status_fields) ] -- | The list of migrations in ascending order by date @@ -1783,14 +1784,14 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} toContact' _ = Nothing -createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m SndFileTransfer -createSndFileTransfer st userId Contact {contactId, localDisplayName = recipientDisplayName} filePath FileInvitation {fileName, fileSize} acId chunkSize = +createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m Int64 +createSndFileTransfer st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} acId chunkSize = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) fileId <- insertedRowId db Connection {connId} <- createSndFileConnection_ db userId fileId acId let fileStatus = FSNew @@ -1798,7 +1799,7 @@ createSndFileTransfer st userId Contact {contactId, localDisplayName = recipient db "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" (fileId, fileStatus, connId, currentTs, currentTs) - pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId acId} + pure fileId createSndFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> Integer -> m Int64 createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} chunkSize = @@ -1806,8 +1807,8 @@ createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {f currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) insertedRowId db createSndFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> m () @@ -1827,8 +1828,8 @@ createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize ch currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) fileId <- insertedRowId db forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId @@ -1844,8 +1845,8 @@ createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitati currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) insertedRowId db createSndGroupFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () @@ -1864,6 +1865,12 @@ updateFileCancelled st userId fileId = currentTs <- getCurrentTime DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) +updateCIFileStatus :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () +updateCIFileStatus st userId fileId ciFileStatus = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) + getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId getSharedMsgIdByFileId st userId fileId = liftIOEither . withTransaction st $ \db -> @@ -1975,8 +1982,8 @@ createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@File currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (userId, contactId, fileName, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) fileId <- insertedRowId db DB.execute db @@ -2052,8 +2059,8 @@ acceptRcvFileTransfer st userId fileId agentConnId filePath = currentTs <- getCurrentTime DB.execute db - "UPDATE files SET file_path = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" - (filePath, currentTs, userId, fileId) + "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" + (filePath, CIFSRcvTransfer, currentTs, userId, fileId) DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" @@ -2512,6 +2519,8 @@ getDirectChatPreviews_ db User {userId} = do COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct @@ -2525,6 +2534,7 @@ getDirectChatPreviews_ db User {userId} = do ) MaxIds ON MaxIds.contact_id = ct.contact_id LEFT JOIN chat_items i ON i.contact_id = MaxIds.contact_id AND i.chat_item_id = MaxIds.MaxId + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -2574,6 +2584,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2596,6 +2608,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do ) MaxIds ON MaxIds.group_id = g.group_id LEFT JOIN chat_items i ON i.group_id = MaxIds.group_id AND i.chat_item_id = MaxIds.MaxId + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -2667,9 +2680,12 @@ getDirectChatLast_ db User {userId} contactId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 ORDER BY i.chat_item_id DESC @@ -2695,9 +2711,12 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1 ORDER BY i.chat_item_id ASC @@ -2723,9 +2742,12 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1 ORDER BY i.chat_item_id DESC @@ -2823,6 +2845,8 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2834,6 +2858,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -2863,6 +2888,8 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2874,6 +2901,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -2903,6 +2931,8 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2914,6 +2944,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -3138,9 +3169,12 @@ getDirectChatItem_ db userId contactId itemId = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? |] @@ -3265,6 +3299,8 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -3276,6 +3312,7 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -3359,20 +3396,14 @@ type ChatStatsRow = (Int, ChatItemId) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId} -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) +type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus) -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) :. MaybeCIFIleRow + +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) --- type DirectChatItemRow = ChatItemRow :. DirectQuoteRow - --- type MaybeDirectChatItemRow = MaybeChatItemRow :. DirectQuoteRow - --- toQuoteData :: QuoteDataRow -> Maybe CIQuoteData --- toQuoteData (quotedItemId, quotedSentAt, quotedMsgContent) = --- CIQuoteData quotedItemId <$> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) - toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent where @@ -3383,22 +3414,33 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) = - case (itemContent, itemStatus) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent +toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = + case (itemContent, itemStatus, fileStatus_) of + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Just (AFS SMDSnd fileStatus)) -> + Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Nothing) -> + Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing) -> + Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent Nothing _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> CChatItem 'CTDirect - cItem d chatDir ciStatus content = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file = Nothing} + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_) of + (Just fileId, Just fileName, Just fileSize) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect + cItem d chatDir ciStatus content file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz currentTs ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) +toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. quoteRow) toDirectChatItemList _ _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -3414,24 +3456,35 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do +toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - case (itemContent, itemStatus, member_) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ + case (itemContent, itemStatus, member_, fileStatus_) of + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ Nothing _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> CChatItem 'CTGroup - cItem d chatDir ciStatus content quotedMember_ = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file = Nothing} + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_) of + (Just fileId, Just fileName, Just fileSize) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> Maybe (CIFile d) -> CChatItem 'CTGroup + cItem d chatDir ciStatus content quotedMember_ file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz currentTs userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) +toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5ef9be10c3..c1d1d73a47 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -156,54 +156,69 @@ responseToView testView = \case testViewChat :: AChat -> [StyledString] testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems] where - toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text)) - toChatView (CChatItem dir ChatItem {meta, quotedItem}) = - ((msgDirectionInt $ toMsgDirection dir, itemText meta),) $ case quotedItem of - Nothing -> Nothing - Just CIQuote {chatDir = quoteDir, content} -> - Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) + toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text), Maybe String) + toChatView (CChatItem dir ChatItem {meta, quotedItem, file}) = + ((msgDirectionInt $ toMsgDirection dir, itemText meta), qItem, fPath) + where + qItem = case quotedItem of + Nothing -> Nothing + Just CIQuote {chatDir = quoteDir, content} -> + Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) + fPath = case file of + Just CIFile {filePath = Just fp} -> Just fp + _ -> Nothing viewErrorsSummary :: [a] -> StyledString -> [StyledString] viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] -viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of +viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of - CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToContact' c CIDirectRcv -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromContact' c where quote = maybe [] (directQuote chatDir) quotedItem GroupChat g -> case chatDir of CIGroupSnd -> case content of - CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToGroup g CIGroupRcv m -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromGroup' g m where quote = maybe [] (groupQuote g) quotedItem _ -> [] + where + sndMsg to quote mc = case (msgContentText mc, file) of + ("", Just _) -> [] + _ -> viewSentMessage to quote mc meta + withSndFile to l = case file of + -- TODO pass CIFile + Just CIFile {fileId, filePath = Just fPath} -> l <> viewSentFileInvitation to fileId fPath meta + _ -> l + rcvMsg from quote mc = case (msgContentText mc, file) of + ("", Just _) -> [] + _ -> viewReceivedMessage from quote mc meta + withRcvFile from l = case file of + Just f -> l <> viewReceivedFileInvitation from f meta + _ -> l viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of DirectChat Contact {localDisplayName = c} -> case chatDir of CIDirectRcv -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta _ -> [] where from = ttyFromContactEdited c @@ -211,7 +226,7 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of CIDirectSnd -> ["message updated"] GroupChat g -> case chatDir of CIGroupRcv GroupMember {localDisplayName = m} -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta _ -> [] where from = ttyFromGroupEdited g m @@ -223,13 +238,13 @@ viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString] viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of (CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of - CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] meta mc + CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta CIDMInternal -> ["message deleted"] (CIDirectSnd, _, _) -> ["message deleted"] _ -> [] GroupChat g -> case (chatDir, deletedContent, toContent) of (CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of - CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] meta mc + CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc meta CIDMInternal -> ["message deleted"] (CIGroupSnd, _, _) -> ["message deleted"] _ -> [] @@ -434,8 +449,8 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> [StyledString] -> CIMeta d -> MsgContent -> [StyledString] -viewReceivedMessage from quote meta = receivedWithTime_ from quote meta . ttyMsgContent +viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] +viewReceivedMessage from quote mc meta = receivedWithTime_ from quote meta (ttyMsgContent mc) receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString] receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do @@ -454,7 +469,7 @@ receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do in styleTime $ formatTime defaultTimeLocale format localTime viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] -viewSentMessage to quote mc = sentWithTime_ . prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc) +viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc)) where indent = if null quote then "" else " " @@ -501,11 +516,22 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString] -viewReceivedFileInvitation from meta ft = receivedWithTime_ from [] meta (receivedFileInvitation_ ft) +viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString] +viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file) -receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] -receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = +receivedFileInvitation_ :: CIFile d -> [StyledString] +receivedFileInvitation_ CIFile {fileId, fileName, fileSize} = + [ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", + -- below is printed for auto-accepted files as well; auto-accept is disabled in terminal though so in reality it never happens + "use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it" + ] + +-- TODO remove +viewReceivedFileInvitation' :: StyledString -> RcvFileTransfer -> CIMeta d -> [StyledString] +viewReceivedFileInvitation' from RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} meta = receivedWithTime_ from [] meta (receivedFileInvitation_' fileId fileName fileSize) + +receivedFileInvitation_' :: Int64 -> String -> Integer -> [StyledString] +receivedFileInvitation_' fileId fileName fileSize = [ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", "use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it" ] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 42cc82b908..28f44e294c 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -125,6 +125,7 @@ testChatN ps test = withTmpFiles $ do test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs getTestCCs ((p, db) : envs') tcs = (:) <$> virtualSimplexChat db p <*> getTestCCs envs' tcs diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index dd450b132a..f6875594e5 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -63,6 +63,12 @@ chatTests = do it "sender cancelled file transfer" testFileSndCancelV2 it "recipient cancelled file transfer" testFileRcvCancelV2 it "send and receive file to group" testGroupFileTransferV2 + describe "messages with files" $ do + it "send and receive message with file" testMessageWithFile + it "send and receive image" testSendImage + it "send and receive image with text and quote" testSendImageWithTextAndQuote + it "send and receive image to group" testGroupSendImage + it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "auto accept contact requests" testUserContactLinkAutoAccept @@ -239,7 +245,7 @@ testDirectMessageDelete = alice #$> ("/_get chat @2 count=100", chat, []) alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send_quote @2 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send @2 quoted 1 text quoting deleted message", id, "cannot reply to this message") bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" @@ -829,7 +835,7 @@ testGroupMessageDelete = cath #$> ("/_get chat #1 count=100", chat, [(0, "hello!")]) alice #$> ("/_update item #1 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send_quote #1 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send #1 quoted 1 text quoting deleted message", id, "cannot reply to this message") threadDelay 1000000 -- msg id 2 @@ -1206,6 +1212,192 @@ testGroupFileTransferV2 = cath <## "completed receiving file 1 (test.jpg) from alice" ] +testMessageWithFile :: IO () +testMessageWithFile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice <# "@bob hi, sending a file" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> hi, sending a file" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) + +testSendImage :: IO () +testSendImage = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/_send @2 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + +testSendImageWithTextAndQuote :: IO () +testSendImageWithTextAndQuote = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + bob #> "@alice hi alice" + alice <# "bob> hi alice" + alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "@bob > hi alice" + alice <## " hey bob" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> > hi alice" + bob <## " hey bob" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chat'', [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) + alice #$$> ("/_get chats", [("@bob", "hey bob")]) + bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) + bob #$$> ("/_get chats", [("@alice", "hey bob")]) + +testGroupSendImage :: IO () +testGroupSendImage = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath", + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + alice #$> ("/_get chat #1 count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat #1 count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + cath #$> ("/_get chat #1 count=100", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) + +testGroupSendImageWithTextAndQuote :: IO () +testGroupSendImageWithTextAndQuote = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob #> "#team hi team" + concurrently_ + (alice <# "#team bob> hi team") + (cath <# "#team bob> hi team") + threadDelay 1000000 + alice ##> "/_send #1 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "#team > bob hi team" + alice <## " hey bob" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> > bob hi team" + bob <## " hey bob" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> > bob hi team" + cath <## " hey bob" + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath", + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + alice #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi team"), Just "./tests/fixtures/test.jpg")]) + alice #$$> ("/_get chats", [("#team", "hey bob"), ("@bob", ""), ("@cath", "")]) + bob #$> ("/_get chat #1 count=100", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just "./tests/tmp/test.jpg")]) + bob #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@cath", "")]) + cath #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) + cath #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@bob", "")]) + testUserContactLink :: IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1567,10 +1759,16 @@ cc #$> (cmd, f, res) = do (f <$> getTermLine cc) `shouldReturn` res chat :: String -> [(Int, String)] -chat = map fst . chat' +chat = map (\(a, _, _) -> a) . chat'' chat' :: String -> [((Int, String), Maybe (Int, String))] -chat' = read +chat' = map (\(a, b, _) -> (a, b)) . chat'' + +chatF :: String -> [((Int, String), Maybe String)] +chatF = map (\(a, _, c) -> (a, c)) . chat'' + +chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)] +chat'' = read (#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation cc #$$> (cmd, res) = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index b5da5bb901..9b37be14a9 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -96,8 +96,11 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA"}) Nothing)) it "x.msg.new simple image" $ - "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "https://simplex.chat" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) + it "x.msg.new simple image with text" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) it "x.msg.new chat message " $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)))) @@ -120,10 +123,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.msg.new forward" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing)) - it "x.msg.new simple with file invitation" $ + it "x.msg.new simple with file" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) - it "x.msg.new quote with file invitation" $ + it "x.msg.new quote with file" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") @@ -139,7 +142,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ) ) ) - it "x.msg.new forward with file invitation" $ + it "x.msg.new forward with file" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) it "x.msg.update" $ From 6c2fb822d78cf07e7003ef7e3fe49f7ff753908a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Apr 2022 10:52:36 +0100 Subject: [PATCH 18/37] nix: add the second x86 ios sim build with swift JSON --- flake.nix | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 4b0ead6e98..e5d3d03933 100644 --- a/flake.nix +++ b/flake.nix @@ -245,7 +245,7 @@ # we need threaded here, otherwise all the queing logic doesn't work properly. # for iOS we also use -staticlib, to get one rolled up library. # still needs mac2ios patching of the archives. - ghcOptions = [ "-staticlib" "-threaded" ]; + ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ]; postInstall = '' ${pkgs.tree}/bin/tree $out mkdir -p $out/_pkg @@ -267,6 +267,33 @@ }; }; "x86_64-darwin" = { + # this is the aarch64-darwin iOS build (to be patched with mac2ios) + "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swift = true; }]; } ).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # we need threaded here, otherwise all the queing logic doesn't work properly. + # for iOS we also use -staticlib, to get one rolled up library. + # still needs mac2ios patching of the archives. + ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \; + find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \; + find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # There is no static libc + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64-swift-json.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + # This is the aarch64-darwin build with tagged JSON format (for Mac & Flutter) "x86_64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. @@ -285,7 +312,7 @@ find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; # There is no static libc ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64.zip *) + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64-tagged-json.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ From fd69b673d872cbe3878b4c6b39f4d2bd73c8746f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Apr 2022 12:18:53 +0100 Subject: [PATCH 19/37] terminal: use up arrow to edit the last message (#514) * terminal: use up error to edit the last message * update help --- src/Simplex/Chat.hs | 9 ++++++--- src/Simplex/Chat/Help.hs | 3 ++- src/Simplex/Chat/Terminal/Input.hs | 18 +++++++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e93abdedec..c1f80c33f5 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -146,10 +146,13 @@ withLock lock = (atomically $ putTMVar lock ()) execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse -execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of +execChatCommand s = case parseChatCommand s of Left e -> pure $ chatCmdError e Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) +parseChatCommand :: ByteString -> Either String ChatCommand +parseChatCommand = parseAll chatCommandP . B.dropWhileEnd isSpace + toView :: ChatMonad m => ChatResponse -> m () toView event = do q <- asks outputQ @@ -1839,7 +1842,7 @@ chatCommandP = <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString) <|> ("\\#" <|> "\\ #") *> (DeleteGroupMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString) + <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact @@ -1848,7 +1851,7 @@ chatCommandP = <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString) + <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index b3d1784eb3..7725a7341e 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -160,7 +160,8 @@ messagesHelpInfo = indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team", "", green "Editing sent messages", - "To edit a message that starts with \"hi\":", + "To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.", + "To edit your most recent message that starts with \"hi\":", indent <> highlight "! @alice (hi) " <> " - to edit your message to alice", indent <> highlight "! #team (hi) " <> " - to edit your message in the group #team" ] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 6b215bd344..5e8f4bcd74 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,14 +1,14 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Terminal.Input where import Control.Monad.Except import Control.Monad.Reader -import qualified Data.ByteString.Char8 as B -import Data.Char (isSpace) import Data.List (dropWhileEnd) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -16,8 +16,8 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Styled import Simplex.Chat.Terminal.Output +import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Chat.View -import Simplex.Messaging.Parsers (parseAll) import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM @@ -33,7 +33,7 @@ runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct cc = forever $ do s <- atomically . readTBQueue $ inputQ cc let bs = encodeUtf8 $ T.pack s - cmd = parseAll chatCommandP $ B.dropWhileEnd isSpace bs + cmd = parseChatCommand bs unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc case r of @@ -94,7 +94,7 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition Leftwards -> setPosition leftPos Rightwards -> setPosition rightPos Upwards - | ms == mempty && null s -> let s' = previousInput ts in ts' (s', length s') + | ms == mempty && null s -> let s' = upArrowCmd $ previousInput ts in ts' (s', length s') | ms == mempty -> let p' = p - tw in if p' > 0 then setPosition p' else ts | otherwise -> ts Downwards @@ -135,6 +135,14 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition | ms == ctrlKey = nextWordPos | ms == altKey = nextWordPos | otherwise = p + upArrowCmd inp = case parseChatCommand . encodeUtf8 $ T.pack inp of + Left _ -> inp + Right cmd -> case cmd of + SendMessage {} -> "! " <> inp + SendGroupMessage {} -> "! " <> inp + SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> safeDecodeUtf8 message + SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> safeDecodeUtf8 message + _ -> inp setPosition p' = ts' (s, p') prevWordPos | p == 0 || null s = p From 0ac9785e4b459528ea47c740a69f9f2a4f7c3d59 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Apr 2022 16:30:54 +0100 Subject: [PATCH 20/37] terminal: option to execute a single chat command via command line (#515) --- apps/simplex-chat/Main.hs | 29 ++++++++++++++++++--------- src/Simplex/Chat/Mobile.hs | 4 +++- src/Simplex/Chat/Options.hs | 40 ++++++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 0465354994..b78d241aa1 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -2,24 +2,35 @@ module Main where +import Control.Concurrent (threadDelay) import Simplex.Chat -import Simplex.Chat.Controller (versionNumber) +import Simplex.Chat.Bot +import Simplex.Chat.Controller (ChatConfig, versionNumber) import Simplex.Chat.Options import Simplex.Chat.Terminal +import Simplex.Chat.View (serializeChatResponse) import System.Directory (getAppUserDataDirectory) import System.Terminal (withTerminal) +cfg :: ChatConfig +cfg = defaultChatConfig + main :: IO () main = do - opts <- welcomeGetOpts - t <- withTerminal pure - simplexChat defaultChatConfig opts t - -welcomeGetOpts :: IO ChatOpts -welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {dbFilePrefix} <- getChatOpts appDir "simplex_v1" + opts@ChatOpts {chatCmd} <- getChatOpts appDir "simplex_v1" + if null chatCmd + then do + welcome opts + t <- withTerminal pure + simplexChat cfg opts t + else simplexChatBot cfg opts $ \_ cc -> do + r <- sendCmd cc chatCmd + putStrLn $ serializeChatResponse r + threadDelay $ chatCmdDelay opts * 1000000 + +welcome :: ChatOpts -> IO () +welcome ChatOpts {dbFilePrefix} = do putStrLn $ "SimpleX Chat v" ++ versionNumber putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" putStrLn "type \"/help\" or \"/h\" for usage info" - pure opts diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index e4f15dd0d1..0902c58bde 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -51,7 +51,9 @@ mobileChatOpts = { dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db smpServers = [], logConnections = False, - logAgent = False + logAgent = False, + chatCmd = "", + chatCmdDelay = 3 } defaultMobileConfig :: ChatConfig diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 68b9394db1..09cc1e9f85 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Options @@ -20,13 +22,15 @@ data ChatOpts = ChatOpts { dbFilePrefix :: String, smpServers :: [SMPServer], logConnections :: Bool, - logAgent :: Bool + logAgent :: Bool, + chatCmd :: String, + chatCmdDelay :: Int } chatOpts :: FilePath -> FilePath -> Parser ChatOpts -chatOpts appDir defaultDbFileName = - ChatOpts - <$> strOption +chatOpts appDir defaultDbFileName = do + dbFilePrefix <- + strOption ( long "database" <> short 'd' <> metavar "DB_FILE" @@ -34,7 +38,8 @@ chatOpts appDir defaultDbFileName = <> value defaultDbFilePath <> showDefault ) - <*> option + smpServers <- + option parseSMPServers ( long "server" <> short 's' @@ -43,16 +48,37 @@ chatOpts appDir defaultDbFileName = "Comma separated list of SMP server(s) to use" <> value [] ) - <*> switch + logConnections <- + switch ( long "connections" <> short 'c' <> help "Log every contact and group connection on start" ) - <*> switch + logAgent <- + switch ( long "log-agent" <> short 'l' <> help "Enable logs from SMP agent" ) + chatCmd <- + strOption + ( long "execute" + <> short 'e' + <> metavar "COMMAND" + <> help "Execute chat command (received messages won't be logged) and exit" + <> value "" + ) + chatCmdDelay <- + option + auto + ( long "time" + <> short 't' + <> metavar "TIME" + <> help "Time to wait after sending chat command before exiting, seconds" + <> value 3 + <> showDefault + ) + pure ChatOpts {dbFilePrefix, smpServers, logConnections, logAgent, chatCmd, chatCmdDelay} where defaultDbFilePath = combine appDir defaultDbFileName From fa313caa8271f6ac6abe838b5a9132350983d5a1 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 10 Apr 2022 17:13:06 +0100 Subject: [PATCH 21/37] terminal: refactor chat core used in terminal app and in bot examples (#516) * terminal: refactor chat core used in terminal app and in bot examples * fix tests * refactor --- apps/simplex-bot-advanced/Main.hs | 5 ++-- apps/simplex-bot/Main.hs | 3 ++- apps/simplex-chat/Main.hs | 13 ++++------- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 7 +++--- src/Simplex/Chat/Bot.hs | 36 ++++------------------------- src/Simplex/Chat/Core.hs | 38 +++++++++++++++++++++++++++++++ src/Simplex/Chat/Mobile.hs | 2 +- src/Simplex/Chat/Terminal.hs | 36 +++++++---------------------- tests/ChatClient.hs | 9 +++++--- 10 files changed, 73 insertions(+), 77 deletions(-) create mode 100644 src/Simplex/Chat/Core.hs diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 0e270a45a6..bb81911913 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -14,6 +14,7 @@ import qualified Data.Text as T import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Types @@ -23,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatBot defaultChatConfig opts mySquaringBot + simplexChatCore defaultChatConfig opts Nothing mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do @@ -50,5 +51,5 @@ mySquaringBot _user cc = do Just n -> msg <> " * " <> msg <> " = " <> show (n * n) _ -> pure () where - sendMsg Contact {contactId} msg = sendCmd cc $ "/_send @" <> show contactId <> " text " <> msg + sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index 70faaf777e..1c322dbc18 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -5,6 +5,7 @@ module Main where import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller (versionNumber) +import Simplex.Chat.Core import Simplex.Chat.Options import System.Directory (getAppUserDataDirectory) import Text.Read @@ -12,7 +13,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatBot defaultChatConfig opts $ + simplexChatCore defaultChatConfig opts Nothing $ chatBotRepl "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" $ \msg -> case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index b78d241aa1..0a94c77935 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -4,17 +4,14 @@ module Main where import Control.Concurrent (threadDelay) import Simplex.Chat -import Simplex.Chat.Bot -import Simplex.Chat.Controller (ChatConfig, versionNumber) +import Simplex.Chat.Controller (versionNumber) +import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal import Simplex.Chat.View (serializeChatResponse) import System.Directory (getAppUserDataDirectory) import System.Terminal (withTerminal) -cfg :: ChatConfig -cfg = defaultChatConfig - main :: IO () main = do appDir <- getAppUserDataDirectory "simplex" @@ -23,9 +20,9 @@ main = do then do welcome opts t <- withTerminal pure - simplexChat cfg opts t - else simplexChatBot cfg opts $ \_ cc -> do - r <- sendCmd cc chatCmd + simplexChatTerminal defaultChatConfig opts t + else simplexChatCore defaultChatConfig opts Nothing $ \_ cc -> do + r <- sendChatCmd cc chatCmd putStrLn $ serializeChatResponse r threadDelay $ chatCmdDelay opts * 1000000 diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9dbb4b6a17..d83866cedf 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -22,6 +22,7 @@ library Simplex.Chat Simplex.Chat.Bot Simplex.Chat.Controller + Simplex.Chat.Core Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c1f80c33f5..60fc60523e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -100,10 +100,11 @@ defaultSMPServers = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendNotification = do +newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do let f = chatStoreFile dbFilePrefix - let config = cfg {subscriptionEvents = logConnections} + config = cfg {subscriptionEvents = logConnections} + sendNotification = fromMaybe (const $ pure ()) sendToast activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 93845ba2f0..83a8be168e 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -7,40 +7,17 @@ module Simplex.Chat.Bot where import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Logger.Simple import Control.Monad.Reader import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Simplex.Chat import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Messages -import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Store import Simplex.Chat.Types (Contact (..), User (..)) import Simplex.Messaging.Encoding.String (strEncode) import System.Exit (exitFailure) -simplexChatBot :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatBot cfg@ChatConfig {dbPoolSize, yesToMigrations} opts chatBot - | logAgent opts = do - setLogLevel LogInfo -- LogError - withGlobalLogging logCfg initRun - | otherwise = initRun - where - initRun = do - let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f dbPoolSize yesToMigrations - u <- getCreateActiveUser st - cc <- newChatController st (Just u) cfg opts (const $ pure ()) - runSimplexChatBot u cc chatBot - -runSimplexChatBot :: User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChatBot u cc chatBot = do - a1 <- async $ chatBot u cc - a2 <- runReaderT (startChatController u) cc - waitEither_ a1 a2 - chatBotRepl :: String -> (String -> String) -> User -> ChatController -> IO () chatBotRepl welcome answer _user cc = do initializeBotAddress cc @@ -55,23 +32,20 @@ chatBotRepl welcome answer _user cc = do void . sendMsg contact $ answer msg _ -> pure () where - sendMsg Contact {contactId} msg = sendCmd cc $ "/_send @" <> show contactId <> " text " <> msg + sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" initializeBotAddress :: ChatController -> IO () initializeBotAddress cc = do - sendCmd cc "/show_address" >>= \case + sendChatCmd cc "/show_address" >>= \case CRUserContactLink uri _ -> showBotAddress uri CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do putStrLn $ "No bot address, creating..." - sendCmd cc "/address" >>= \case + sendChatCmd cc "/address" >>= \case CRUserContactLinkCreated uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where showBotAddress uri = do putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) - void $ sendCmd cc "/auto_accept on" - -sendCmd :: ChatController -> String -> IO ChatResponse -sendCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc + void $ sendChatCmd cc "/auto_accept on" diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs new file mode 100644 index 0000000000..a4faaf876f --- /dev/null +++ b/src/Simplex/Chat/Core.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Core where + +import Control.Logger.Simple +import Control.Monad.Reader +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat +import Simplex.Chat.Controller +import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Store +import Simplex.Chat.Types +import UnliftIO.Async + +simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () +simplexChatCore cfg@ChatConfig {dbPoolSize, yesToMigrations} opts sendToast chat + | logAgent opts = do + setLogLevel LogInfo -- LogError + withGlobalLogging logCfg initRun + | otherwise = initRun + where + initRun = do + let f = chatStoreFile $ dbFilePrefix opts + st <- createStore f dbPoolSize yesToMigrations + u <- getCreateActiveUser st + cc <- newChatController st (Just u) cfg opts sendToast + runSimplexChat u cc chat + +runSimplexChat :: User -> ChatController -> (User -> ChatController -> IO ()) -> IO () +runSimplexChat u cc chat = do + a1 <- async $ chat u cc + a2 <- runReaderT (startChatController u) cc + waitEither_ a1 a2 + +sendChatCmd :: ChatController -> String -> IO ChatResponse +sendChatCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0902c58bde..9869b3692c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -73,7 +73,7 @@ chatInit dbFilePrefix = do let f = chatStoreFile dbFilePrefix chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations (defaultMobileConfig :: ChatConfig)) user_ <- getActiveUser_ chatStore - newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} (const $ pure ()) + newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} Nothing chatSendCmd :: ChatController -> String -> IO JSONString chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 1daab90035..4b1a6b8a4a 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -3,43 +3,23 @@ module Simplex.Chat.Terminal where -import Control.Logger.Simple import Control.Monad.Except -import Control.Monad.Reader -import Simplex.Chat import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Options -import Simplex.Chat.Store import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output -import Simplex.Chat.Types (User) import Simplex.Messaging.Util (raceAny_) -import UnliftIO (async, waitEither_) -simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t - | logAgent opts = do - setLogLevel LogInfo -- LogError - withGlobalLogging logCfg initRun - | otherwise = initRun - where - initRun = do - sendNotification' <- initializeNotifications - let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f dbPoolSize yesToMigrations - u <- getCreateActiveUser st - ct <- newChatTerminal t - cc <- newChatController st (Just u) cfg opts sendNotification' - runSimplexChat u ct cc - -runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () -runSimplexChat u ct cc = do - when (firstTime cc) . printToTerminal ct $ chatWelcome u - a1 <- async $ runChatTerminal ct cc - a2 <- runReaderT (startChatController u) cc - waitEither_ a1 a2 +simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () +simplexChatTerminal cfg opts t = do + sendToast <- initializeNotifications + simplexChatCore cfg opts (Just sendToast) $ \u cc -> do + ct <- newChatTerminal t + when (firstTime cc) . printToTerminal ct $ chatWelcome u + runChatTerminal ct cc runChatTerminal :: ChatTerminal -> ChatController -> IO () runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 28f44e294c..57143e7d8f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -18,6 +18,7 @@ import qualified Data.Text as T import Network.Socket import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal @@ -46,7 +47,9 @@ opts = { dbFilePrefix = undefined, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], logConnections = False, - logAgent = False + logAgent = False, + chatCmd = "", + chatCmdDelay = 3 } termSettings :: VirtualTerminalSettings @@ -83,8 +86,8 @@ virtualSimplexChat dbFilePrefix profile = do Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t - cc <- newChatController st (Just user) cfg opts {dbFilePrefix} (const $ pure ()) -- no notifications - chatAsync <- async $ runSimplexChat user ct cc + cc <- newChatController st (Just user) cfg opts {dbFilePrefix} Nothing -- no notifications + chatAsync <- async . runSimplexChat user cc . const $ runChatTerminal ct termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} From 02d21145b29025661050352f2ba6bf0585bca7f6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Apr 2022 09:34:59 +0100 Subject: [PATCH 22/37] core: replace quoted content with MCText if the message itself is not MCText (#517) * core: replace quoted content with MCText if the message itself is not MCText * core: quoteData in ChatMonad (#518) * core: quoteData in ChatMonad * use throwChatError Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- src/Simplex/Chat.hs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 60fc60523e..ae43c2ac0e 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -205,15 +205,16 @@ processChatCommand = \case Just quotedItemId -> do CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- withStore $ \st -> getDirectChatItem st userId chatId quotedItemId - (qmc, qd, sent) <- liftEither $ quoteData ciContent + (origQmc, qd, sent) <- quoteData ciContent let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} + qmc = quoteContent origQmc mc quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - quoteData :: CIContent d -> Either ChatError (MsgContent, CIQDirection 'CTDirect, Bool) - quoteData (CISndMsgContent qmc) = Right (qmc, CIQDirectSnd, True) - quoteData (CIRcvMsgContent qmc) = Right (qmc, CIQDirectRcv, False) - quoteData _ = Left $ ChatError CEInvalidQuote + quoteData :: CIContent d -> m (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData (CISndMsgContent qmc) = pure (qmc, CIQDirectSnd, True) + quoteData (CIRcvMsgContent qmc) = pure (qmc, CIQDirectRcv, False) + quoteData _ = throwChatError CEInvalidQuote CTGroup -> do Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved @@ -240,17 +241,21 @@ processChatCommand = \case Just quotedItemId -> do CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId - (qmc, qd, sent, GroupMember {memberId}) <- liftEither $ quoteData ciContent chatDir membership + (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + qmc = quoteContent origQmc mc quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - quoteData :: CIContent d -> CIDirection 'CTGroup d -> GroupMember -> Either ChatError (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData (CISndMsgContent qmc) CIGroupSnd membership' = Right (qmc, CIQGroupSnd, True, membership') - quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = Right (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ _ = Left $ ChatError CEInvalidQuote + quoteData :: CIContent d -> CIDirection 'CTGroup d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData (CISndMsgContent qmc) CIGroupSnd membership' = pure (qmc, CIQGroupSnd, True, membership') + quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData _ _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError "not supported" where + quoteContent qmc = \case + MCText _ -> qmc + _ -> MCText $ msgContentText qmc unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) unzipMaybe t = (fst <$> t, snd <$> t) -- TODO discontinue From 1b930e717a96df47c71047dd4dceeeccec753b44 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Mon, 11 Apr 2022 09:39:04 +0100 Subject: [PATCH 23/37] android: link previews (#510) * wire up api for link metadata parsing * add getLinkPreview (synchonous for now) * api wiring fix * get network requests off main thread * copy over state machine logic from iOS * filter api parsing calls from logs * refactor of image processing * remove image deepcopy * minor change to log filtering * mobile: link previews Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 3 + .../java/chat/simplex/app/model/ChatModel.kt | 48 +++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 26 +++- .../chat/simplex/app/views/TerminalView.kt | 10 +- .../chat/simplex/app/views/chat/ChatView.kt | 28 ++-- .../simplex/app/views/chat/ComposeView.kt | 26 +++- .../simplex/app/views/chat/SendMsgView.kt | 80 +++++++++- .../app/views/chat/item/FramedItemView.kt | 23 ++- .../simplex/app/views/helpers/GetImageView.kt | 46 +++--- .../simplex/app/views/helpers/LinkPreviews.kt | 140 ++++++++++++++++++ apps/android/build.gradle | 6 +- .../Chat/ComposeMessage/ComposeView.swift | 93 ++++++------ .../Views/Helpers/ChatItemLinkView.swift | 10 +- .../Views/Helpers/ComposeLinkView.swift | 2 +- .../Shared/Views/Helpers/ImagePicker.swift | 63 ++++---- .../Views/UserSettings/UserProfile.swift | 2 +- 16 files changed, 467 insertions(+), 139 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 5932b59f80..1aa20134be 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -96,6 +96,9 @@ dependencies { //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" + // Link Previews + implementation 'org.jsoup:jsoup:1.13.1' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 3eb4ddedfa..71185d1feb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -457,6 +457,23 @@ class GroupMember ( } } +@Serializable +class LinkPreview ( + val uri: String, + val title: String, + val description: String, + val image: String +) { + companion object { + val sampleData = LinkPreview( + uri = "https://www.duckduckgo.com", + title = "Privacy, simplified.", + description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.", + image = "data:image/jpg;base64,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" + ) + } +} + @Serializable class MemberSubError ( val member: GroupMember, @@ -659,35 +676,40 @@ interface ItemContent { @Serializable sealed class CIContent: ItemContent { abstract override val text: String + abstract val msgContent: MsgContent? @Serializable @SerialName("sndMsgContent") - class SndMsgContent(val msgContent: MsgContent): CIContent() { + class SndMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("rcvMsgContent") - class RcvMsgContent(val msgContent: MsgContent): CIContent() { + class RcvMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val text get() = "deleted" + override val msgContent get() = null } @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val text get() = "deleted" + override val msgContent get() = null } @Serializable @SerialName("sndFileInvitation") class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { override val text get() = "sending files is not supported yet" + override val msgContent get() = null } @Serializable @SerialName("rcvFileInvitation") class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { override val text get() = "receiving files is not supported yet" + override val msgContent get() = null } } @@ -716,15 +738,23 @@ class CIQuote ( } } +@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { abstract val text: String + @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() + + @Serializable(with = MsgContentSerializer::class) + class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" + is MCLink -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -735,6 +765,10 @@ object MsgContentSerializer : KSerializer { element("MCText", buildClassSerialDescriptor("MCText") { element("text") }) + element("MCLink", buildClassSerialDescriptor("MCLink") { + element("text") + element("preview") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -747,6 +781,10 @@ object MsgContentSerializer : KSerializer { val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format" when (t) { "text" -> MsgContent.MCText(text) + "link" -> { + val preview = Json.decodeFromString(json["preview"].toString()) + MsgContent.MCLink(text, preview) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -765,6 +803,12 @@ object MsgContentSerializer : KSerializer { put("type", "text") put("text", value.text) } + is MsgContent.MCLink -> + buildJsonObject { + put("type", "link") + put("text", value.text) + put("preview", json.encodeToJsonElement(value.preview)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 53968f7719..eeb7b5d046 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -15,8 +15,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -76,15 +75,19 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt suspend fun sendCmd(cmd: CC): CR { return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + if (cmd !is CC.ApiParseMarkdown) { + chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + Log.d(TAG, "sendCmd: ${cmd.cmdType}") + } val json = chatSendCmd(ctrl, c) - Log.d(TAG, "sendCmd: ${cmd.cmdType}") val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { Log.d(TAG, "sendCmd response json $json") } - chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + if (r.resp !is CR.ParsedMarkdown) { + chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + } r.resp } } @@ -240,6 +243,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } + suspend fun apiParseMarkdown(text: String): List? { + val r = sendCmd(CC.ApiParseMarkdown(text)) + if (r is CR.ParsedMarkdown) return r.formattedText + Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) if (r is CR.UserContactLinkCreated) return r.connReqContact @@ -479,6 +489,7 @@ sealed class CC { class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiUpdateProfile(val profile: Profile): CC() + class ApiParseMarkdown(val text: String): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -503,6 +514,7 @@ sealed class CC { is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiParseMarkdown -> "/_parse $text" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -528,6 +540,7 @@ sealed class CC { is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" is ApiUpdateProfile -> "updateProfile" + is ApiParseMarkdown -> "apiParseMarkdown" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -588,6 +601,7 @@ sealed class CR { @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() @@ -628,6 +642,7 @@ sealed class CR { is ContactDeleted -> "contactDeleted" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" + is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkCreated -> "userContactLinkCreated" is UserContactLinkDeleted -> "userContactLinkDeleted" @@ -669,6 +684,7 @@ sealed class CR { is ContactDeleted -> json.encodeToString(contact) is UserProfileNoChange -> noDetails() is UserProfileUpdated -> json.encodeToString(toProfile) + is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> connReqContact is UserContactLinkCreated -> connReqContact is UserContactLinkDeleted -> noDetails() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 98b828c42e..8f828f4e19 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -43,7 +43,15 @@ fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCom ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, - bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) }, + bottomBar = { + SendMsgView( + msg = remember { mutableStateOf("") }, + linkPreview = remember { mutableStateOf(null) }, + cancelledLinks = remember { mutableSetOf() }, + parseMarkdown = { null }, + sendMessage = sendCommand + ) + }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 02b8ab1da6..d8c5bd016e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -31,8 +31,7 @@ import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.ModalManager import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.datetime.Clock @Composable @@ -44,7 +43,9 @@ fun ChatView(chatModel: ChatModel) { } else { val quotedItem = remember { mutableStateOf(null) } val editingItem = remember { mutableStateOf(null) } + val linkPreview = remember { mutableStateOf(null) } var msg = remember { mutableStateOf("") } + BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -61,7 +62,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -84,17 +85,19 @@ fun ChatView(chatModel: ChatModel) { ) if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } else { + val linkPreviewData = linkPreview.value val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, quotedItemId = quotedItem.value?.meta?.itemId, - mc = MsgContent.MCText(msg) + mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg) ) if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } // hide "in progress" editingItem.value = null quotedItem.value = null + linkPreview.value = null } }, resetMessage = { msg.value = "" }, @@ -109,7 +112,8 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } - } + }, + parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } } ) } } @@ -122,12 +126,14 @@ fun ChatLayout( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + parseMarkdown: (String) -> List? ) { Surface( Modifier @@ -137,7 +143,7 @@ fun ChatLayout( ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) }, + bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { @@ -334,12 +340,14 @@ fun PreviewChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } @@ -377,12 +385,14 @@ fun PreviewGroupChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 3cea4b1f52..2c1973b054 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,9 +1,9 @@ package chat.simplex.app.views.chat -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import chat.simplex.app.model.ChatItem +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.ComposeLinkView // TODO ComposeState @@ -12,10 +12,24 @@ fun ComposeView( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, sendMessage: (String) -> Unit, - resetMessage: () -> Unit + resetMessage: () -> Unit, + parseMarkdown: (String) -> List? ) { + val cancelledLinks = remember { mutableSetOf() } + + fun cancelPreview() { + val uri = linkPreview.value?.uri + if (uri != null) { + cancelledLinks.add(uri) + } + linkPreview.value = null + } + Column { + val lp = linkPreview.value + if (lp != null) ComposeLinkView(lp, ::cancelPreview) when { quotedItem.value != null -> { ContextItemView(quotedItem) @@ -25,6 +39,6 @@ fun ComposeView( } else -> {} } - SendMsgView(msg, sendMessage, editing = editingItem.value != null) + SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 89b5851f89..7d1cb8d05b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.chat import android.content.res.Configuration +import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -20,22 +21,83 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.TAG +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.getLinkPreview +import chat.simplex.app.views.helpers.withApi +import kotlinx.coroutines.delay @Composable -fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editing: Boolean = false) { +fun SendMsgView( + msg: MutableState, + linkPreview: MutableState, + cancelledLinks: MutableSet, + parseMarkdown: (String) -> List?, + sendMessage: (String) -> Unit, + editing: Boolean = false +) { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) var textStyle by remember { mutableStateOf(smallFont) } + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } + + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = parseMarkdown(msg) + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (pendingLinkUrl.value == url) { + linkPreview.value = lp + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + linkPreview.value = null + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + BasicTextField( value = msg.value, - onValueChange = { - msg.value = it - textStyle = if (isShortEmoji(it)) { - if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + onValueChange = { s -> + msg.value = s + if (isShortEmoji(s)) { + textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { - smallFont + textStyle = smallFont + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() } }, textStyle = textStyle, @@ -99,6 +161,9 @@ fun PreviewSendMsgView() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), + parseMarkdown = { null }, sendMessage = { msg -> println(msg) } ) } @@ -115,7 +180,10 @@ fun PreviewSendMsgViewEditing() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), sendMessage = { msg -> println(msg) }, + parseMarkdown = { null }, editing = true ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 302f3ee1dc..54860a84f8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ChatItemLinkView import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -45,8 +46,8 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho ) } } - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( Modifier .padding(bottom = 2.dp) @@ -56,11 +57,19 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho EmojiText(ci.content.text) Text("") } - } else { - MarkdownText( - ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true - ) + } + } else { + Column(Modifier.fillMaxWidth()) { + val mc = ci.content.msgContent + if (mc is MsgContent.MCLink) { + ChatItemLinkView(mc.preview) + } + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + MarkdownText( + ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, + metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 6ae0c384a6..8c196e6ada 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -32,32 +32,40 @@ import chat.simplex.app.TAG import chat.simplex.app.views.newchat.ActionButton import java.io.ByteArrayOutputStream import java.io.File +import kotlin.math.min +import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String { - val size = 104 - var height = size - var width = size +private fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 - if (bitmap.height < bitmap.width) { - width = height * bitmap.width / bitmap.height - xOffset = (width - height) / 2 + val side = min(image.height, image.width) + if (image.height < image.width) { + xOffset = (image.width - side) / 2 } else { - height = width * bitmap.height / bitmap.width - yOffset = (height - width) / 2 + yOffset = (image.height - side) / 2 } - var image = bitmap - while (image.width / 2 > width) { - image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true) - } - image = Bitmap.createScaledBitmap(image, width, height, true) - if (squareCrop) { - image = Bitmap.createBitmap(image, xOffset, yOffset, size, size) + return Bitmap.createBitmap(image, xOffset, yOffset, side, side) +} + +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String { + var img = image + var str = compressImage(img) + while (str.length > maxDataSize) { + val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + str = compressImage(img) } + return str +} + +private fun compressImage(bitmap: Bitmap): String { val stream = ByteArrayOutputStream() - image.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) } @@ -126,12 +134,12 @@ fun GetImageBottomSheet( if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) - profileImageStr.value = bitmapToBase64(bitmap) + profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } } val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> - if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap) + if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt new file mode 100644 index 0000000000..102097457f --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -0,0 +1,140 @@ +package chat.simplex.app.views.helpers + +import android.content.res.Configuration +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.LinkPreview +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.SentColorLight +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup + +private const val OG_SELECT_QUERY = "meta[property^=og:]" + +suspend fun getLinkPreview(url: String): LinkPreview? { + return withContext(Dispatchers.IO) { + try { + val response = Jsoup.connect(url) + .ignoreContentType(true) + .timeout(10000) + .followRedirects(true) + .execute() + val doc = response.parse() + val ogTags = doc.select(OG_SELECT_QUERY) + val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content") + if (imageUri != null) { + try { + val stream = java.net.URL(imageUri).openStream() + val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) +// TODO add once supported in iOS +// val description = ogTags.firstOrNull { +// it.attr("property") == "og:description" +// }?.attr("content") ?: "" + val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") + if (title != null) { + return@withContext LinkPreview(url, title, description = "", image) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return@withContext null + } +} + + + +@Composable +fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) + ) + Column(Modifier.fillMaxWidth().weight(1F)) { + Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Cancel Preview", + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } +} + +@Composable +fun ChatItemLinkView(linkPreview: LinkPreview) { + Column { + Image( + base64ToBitmap(linkPreview.image).asImageBitmap(), + "link image", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { + Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp)) + if (linkPreview.description != "") { + Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp) + } + Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight) + } + } +} + + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ChatItemLinkView (Dark Mode)" +) +@Composable +fun PreviewChatItemLinkView() { + SimpleXTheme { + ChatItemLinkView(LinkPreview.sampleData) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ComposeLinkView (Dark Mode)" +) +@Composable +fun PreviewComposeLinkView() { + SimpleXTheme { + ComposeLinkView(LinkPreview.sampleData) { -> } + } +} \ No newline at end of file diff --git a/apps/android/build.gradle b/apps/android/build.gradle index ad0b119f62..231378f393 100644 --- a/apps/android/build.gradle +++ b/apps/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2" @@ -16,8 +16,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.1.2' apply false - id 'com.android.library' version '7.1.2' apply false + id 'com.android.application' version '7.1.3' apply false + id 'com.android.library' version '7.1.3' apply false id 'org.jetbrains.kotlin.android' version '1.6.10' apply false id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index f71c810434..72c5e66449 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -32,34 +32,6 @@ struct ComposeView: View { @State var cancelledLinks: Set = [] - private func isValidLink(link: String) -> Bool { - return !(link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")) - } - - func cancelPreview() { - if let uri = linkPreview?.uri.absoluteString { - cancelledLinks.insert(uri) - } - linkPreview = nil - } - - func parseMessage(_ msg: String) -> URL? { - do { - if let parsedMsg = try apiParseMarkdown(text: msg), - let link = parsedMsg.first(where: { - $0.format == .uri && !cancelledLinks.contains($0.text) - }), - isValidLink(link: link.text) { - return URL(string: link.text) - } else { - return nil - } - } catch { - logger.error("apiParseMarkdown error: \(error.localizedDescription)") - return nil - } - } - var body: some View { VStack(spacing: 0) { if let metadata = linkPreview { @@ -84,19 +56,7 @@ struct ComposeView: View { } .onChange(of: message) { _ in if message.count > 0 { - prevLinkUrl = linkUrl - linkUrl = parseMessage(message) - if let url = linkUrl { - if prevLinkUrl == linkUrl { - loadLinkPreview(url) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - loadLinkPreview(url) - } - } - } else { - linkPreview = nil - } + showLinkPreview(message) } else { resetLinkPreview() } @@ -106,9 +66,52 @@ struct ComposeView: View { } } - func loadLinkPreview(_ url: URL) { - if url != linkPreview?.uri && url != pendingLinkUrl { - pendingLinkUrl = url + private func showLinkPreview(_ s: String) { + prevLinkUrl = linkUrl + linkUrl = parseMessage(s) + if let url = linkUrl { + if url != linkPreview?.uri && url != pendingLinkUrl { + pendingLinkUrl = url + if prevLinkUrl == url { + loadLinkPreview(url) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + loadLinkPreview(url) + } + } + } + } else { + linkPreview = nil + } + } + + private func parseMessage(_ msg: String) -> URL? { + do { + let parsedMsg = try apiParseMarkdown(text: msg) + let uri = parsedMsg?.first(where: { ft in + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + }) + if let uri = uri { return URL(string: uri.text) } + else { return nil } + } catch { + logger.error("apiParseMarkdown error: \(error.localizedDescription)") + return nil + } + } + + private func isSimplexLink(_ link: String) -> Bool { + link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") + } + + private func cancelPreview() { + if let uri = linkPreview?.uri.absoluteString { + cancelledLinks.insert(uri) + } + linkPreview = nil + } + + private func loadLinkPreview(_ url: URL) { + if pendingLinkUrl == url { getLinkPreview(url: url) { lp in if pendingLinkUrl == url { linkPreview = lp @@ -118,7 +121,7 @@ struct ComposeView: View { } } - func resetLinkPreview() { + private func resetLinkPreview() { linkUrl = nil prevLinkUrl = nil pendingLinkUrl = nil diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift index 00f2806828..09db349295 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift @@ -22,14 +22,18 @@ struct ChatItemLinkView: View { } VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) - .lineLimit(2) - .padding(.horizontal, 12) + .lineLimit(3) +// if linkPreview.description != "" { +// Text(linkPreview.description) +// .font(.subheadline) +// .lineLimit(12) +// } Text(linkPreview.uri.absoluteString) .font(.caption) .lineLimit(1) .foregroundColor(.secondary) - .padding(.horizontal, 12) } + .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift index 7ed4fde77f..1a9c446499 100644 --- a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -25,7 +25,7 @@ func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") } else { if let image = object as? UIImage, - let resized = resizeImageToDataSize(image, maxSize: 14000), + let resized = resizeImageToDataSize(image, maxDataSize: 14000), let title = metadata.title, let uri = metadata.originalURL { linkPreview = LinkPreview(uri: uri, title: title, image: resized) diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 51d72a0da3..7fa0bc722b 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -17,47 +17,48 @@ func dropImagePrefix(_ s: String) -> String { dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") } -func resizeAndCrop(_ image: UIImage, to newSize: CGSize) -> UIImage { +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = 1.0 format.opaque = true - return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in - let size = image.size - let hScale = newSize.height / size.height - let vScale = newSize.width / size.width - let scale = max(hScale, vScale) // scaleToFill - let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) - var middle = CGPoint.zero - if resizeSize.width > newSize.width { - middle.x -= (resizeSize.width - newSize.width) / 2 - } else if resizeSize.height > newSize.height { - middle.y -= (resizeSize.height - newSize.height) / 2 - } - image.draw(in: CGRect(origin: middle, size: resizeSize)) + return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in + image.draw(in: drawIn) } } func cropToSquare(_ image: UIImage) -> UIImage { - let side = min(image.size.width, image.size.height) - return resizeAndCrop(image, to: CGSize(width: side, height: side)) + let size = image.size + let side = min(size.width, size.height) + let newSize = CGSize(width: side, height: side) + var origin = CGPoint.zero + if size.width > side { + origin.x -= (size.width - side) / 2 + } else if size.height > side { + origin.y -= (size.height - side) / 2 + } + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) } -func resizeImageToDataSize(_ image: UIImage, maxSize: Int) -> String? { - let size = image.size - var imageStr = compressImage(image) - var resized = image - var ratio: CGFloat = 1 - var dataSize = imageStr?.count ?? 0 - logger.debug("resizeImageToDataSize: initial size \(String(describing: size)), data size \(dataSize)") - while dataSize != 0 && dataSize > maxSize { - ratio *= sqrt(CGFloat(dataSize / maxSize) * 1.2) - resized = resizeAndCrop(resized, to: CGSize(width: size.width / ratio, height: size.height / ratio)) - imageStr = compressImage(resized) - dataSize = imageStr?.count ?? 0 - logger.debug("resizeImageToDataSize: ratio \(ratio)") + +func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { + let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) + let bounds = CGRect(origin: .zero, size: newSize) + return resizeImage(image, newBounds: bounds, drawIn: bounds) +} + +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? { + var img = image + var str = compressImage(img) + var dataSize = str?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + str = compressImage(img) + dataSize = str?.count ?? 0 } - logger.debug("resizeImageToDataSize: final size \(String(describing: resized.size)), data size \(dataSize)") - return imageStr + logger.debug("resizeImageToDataSize final \(dataSize)") + return str } func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a83cac3a96..8c38681b3c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -99,7 +99,7 @@ struct UserProfile: View { } .onChange(of: chosenImage) { image in if let image = image { - profile.image = resizeImageToDataSize(cropToSquare(image), maxSize: 12500) + profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) } else { profile.image = nil } From 7f8afb0c126cfbc9fc62526329f9461cd81043da Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Apr 2022 15:53:44 +0100 Subject: [PATCH 24/37] move nix files to folder (#520) * move nix files to folder * move nix to scripts --- flake.nix | 8 ++++---- .../nix/direct-sqlite-2.3.26.patch | 0 entropy.patch => scripts/nix/entropy.patch | 0 sha256map.nix => scripts/nix/sha256map.nix | 0 update-sha256.awk => scripts/nix/update-sha256.awk | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename direct-sqlite-2.3.26.patch => scripts/nix/direct-sqlite-2.3.26.patch (100%) rename entropy.patch => scripts/nix/entropy.patch (100%) rename sha256map.nix => scripts/nix/sha256map.nix (100%) rename update-sha256.awk => scripts/nix/update-sha256.awk (100%) diff --git a/flake.nix b/flake.nix index e5d3d03933..6e13c24a64 100644 --- a/flake.nix +++ b/flake.nix @@ -18,10 +18,10 @@ name = "simplex-chat"; src = ./.; }; - sha256map = import ./sha256map.nix; + sha256map = import ./scripts/nix/sha256map.nix; modules = [{ - packages.direct-sqlite.patches = [ ./direct-sqlite-2.3.26.patch ]; - packages.entropy.patches = [ ./entropy.patch ]; + packages.direct-sqlite.patches = [ ./scripts/nix/direct-sqlite-2.3.26.patch ]; + packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; } ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; @@ -329,7 +329,7 @@ name = "update-sha256map"; runtimeInputs = [ pkgs.nix-prefetch-git pkgs.jq pkgs.gawk ]; text = '' - gawk -f update-sha256.awk cabal.project > sha256map.nix + gawk -f ./scripts/nix/update-sha256.awk cabal.project > ./scripts/nix/sha256map.nix ''; }; in pkgs.mkShell { diff --git a/direct-sqlite-2.3.26.patch b/scripts/nix/direct-sqlite-2.3.26.patch similarity index 100% rename from direct-sqlite-2.3.26.patch rename to scripts/nix/direct-sqlite-2.3.26.patch diff --git a/entropy.patch b/scripts/nix/entropy.patch similarity index 100% rename from entropy.patch rename to scripts/nix/entropy.patch diff --git a/sha256map.nix b/scripts/nix/sha256map.nix similarity index 100% rename from sha256map.nix rename to scripts/nix/sha256map.nix diff --git a/update-sha256.awk b/scripts/nix/update-sha256.awk similarity index 100% rename from update-sha256.awk rename to scripts/nix/update-sha256.awk From 0a17f5c49193de36cbc8f997c0816ace83e52906 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Apr 2022 18:43:09 +0100 Subject: [PATCH 25/37] ios: update package name in prepare script, update libs (#509) * ios: update package name in prepare script, update libs (not working yet) * ios: update/move prepare scripts --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 60 +++++++++++----------- {apps => scripts}/ios/prepare-x86_64.sh | 4 +- {apps => scripts}/ios/prepare.sh | 2 +- 3 files changed, 33 insertions(+), 33 deletions(-) rename {apps => scripts}/ios/prepare-x86_64.sh (81%) rename {apps => scripts}/ios/prepare.sh (88%) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 87261c7cc5..21fdbe7d73 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -33,16 +33,16 @@ 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; - 5C41158927FF2D400054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158427FF2D400054D6CB /* libgmpxx.a */; }; - 5C41158A27FF2D400054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158427FF2D400054D6CB /* libgmpxx.a */; }; - 5C41158B27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; - 5C41158C27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */; }; - 5C41158D27FF2D400054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158627FF2D400054D6CB /* libgmp.a */; }; - 5C41158E27FF2D400054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158627FF2D400054D6CB /* libgmp.a */; }; - 5C41158F27FF2D400054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158727FF2D400054D6CB /* libffi.a */; }; - 5C41159027FF2D400054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158727FF2D400054D6CB /* libffi.a */; }; - 5C41159127FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; - 5C41159227FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */; }; + 5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; }; + 5C411599280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; }; + 5C41159A280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; }; + 5C41159B280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; }; + 5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; }; + 5C41159D280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; }; + 5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; }; + 5C41159F280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; }; + 5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; }; + 5C4115A1280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; @@ -155,11 +155,11 @@ 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; - 5C41158427FF2D400054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a"; sourceTree = ""; }; - 5C41158627FF2D400054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C41158727FF2D400054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a"; sourceTree = ""; }; + 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a"; sourceTree = ""; }; + 5C411594280048E90054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a"; sourceTree = ""; }; + 5C411596280048E90054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C411597280048E90054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; @@ -215,14 +215,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C41158927FF2D400054D6CB /* libgmpxx.a in Frameworks */, - 5C41158B27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, + 5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */, + 5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */, + 5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5C41158F27FF2D400054D6CB /* libffi.a in Frameworks */, - 5C41158D27FF2D400054D6CB /* libgmp.a in Frameworks */, + 5C41159A280048E90054D6CB /* libffi.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - 5C41159127FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, + 5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -230,13 +230,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C41158A27FF2D400054D6CB /* libgmpxx.a in Frameworks */, - 5C41159027FF2D400054D6CB /* libffi.a in Frameworks */, - 5C41158E27FF2D400054D6CB /* libgmp.a in Frameworks */, + 5C41159F280048E90054D6CB /* libgmpxx.a in Frameworks */, + 5C4115A1280048E90054D6CB /* libgmp.a in Frameworks */, 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C41159227FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a in Frameworks */, - 5C41158C27FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a in Frameworks */, + 5C41159D280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */, 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, + 5C411599280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */, + 5C41159B280048E90054D6CB /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -288,11 +288,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C41158727FF2D400054D6CB /* libffi.a */, - 5C41158627FF2D400054D6CB /* libgmp.a */, - 5C41158427FF2D400054D6CB /* libgmpxx.a */, - 5C41158827FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn-ghc8.10.7.a */, - 5C41158527FF2D400054D6CB /* libHSsimplex-chat-1.5.0-7pZO7WHlcmIHepHvDs8mvn.a */, + 5C411594280048E90054D6CB /* libffi.a */, + 5C411597280048E90054D6CB /* libgmp.a */, + 5C411596280048E90054D6CB /* libgmpxx.a */, + 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */, + 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */, ); path = Libraries; sourceTree = ""; diff --git a/apps/ios/prepare-x86_64.sh b/scripts/ios/prepare-x86_64.sh similarity index 81% rename from apps/ios/prepare-x86_64.sh rename to scripts/ios/prepare-x86_64.sh index c6848db29a..534365cb59 100755 --- a/apps/ios/prepare-x86_64.sh +++ b/scripts/ios/prepare-x86_64.sh @@ -3,8 +3,8 @@ # the binaries folders should be in ~/Downloads folder rm -rf ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim mkdir -p ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim -cp ~/Downloads/pkg-ios-aarch64/* ./apps/ios/Libraries/mac-aarch64 -cp ~/Downloads/pkg-ios-x86_64/* ./apps/ios/Libraries/mac-x86_64 +cp ~/Downloads/pkg-ios-aarch64-swift-json/* ./apps/ios/Libraries/mac-aarch64 +cp ~/Downloads/pkg-ios-x86_64-swift-json/* ./apps/ios/Libraries/mac-x86_64 chmod +w ./apps/ios/Libraries/mac-aarch64/* chmod +w ./apps/ios/Libraries/mac-x86_64/* cp ./apps/ios/Libraries/mac-aarch64/* ./apps/ios/Libraries/ios diff --git a/apps/ios/prepare.sh b/scripts/ios/prepare.sh similarity index 88% rename from apps/ios/prepare.sh rename to scripts/ios/prepare.sh index 3f8da7c90a..3e4157b4ca 100755 --- a/apps/ios/prepare.sh +++ b/scripts/ios/prepare.sh @@ -3,7 +3,7 @@ # the binaries folder should be in ~/Downloads folder rm -rf ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim -cp ~/Downloads/pkg-ios-aarch64/* ./apps/ios/Libraries/mac +cp ~/Downloads/pkg-ios-aarch64-swift-json/* ./apps/ios/Libraries/mac chmod +w ./apps/ios/Libraries/mac/* cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/ios cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/sim From af471d0077064fc9a4aaefb4cc70b2fdb0451f8a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 11 Apr 2022 22:29:08 +0100 Subject: [PATCH 26/37] update github content (#519) * update github content * update comparison * update link * move message_views.sql to scripts * move section * move news section * typos Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update readme * update readme * update readme Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- README.md | 345 +++++------------- docs/CLI.md | 224 ++++++++++++ docs/COMPARISON.md | 61 ++++ message_queries.md => docs/SQL.md | 4 +- {protocol => docs/protocol}/types.ts | 0 {rfcs => docs/rfcs}/2022-01-26-mobile-app.md | 0 ...2022-02-10-deduplicate-contact-requests.md | 0 .../rfcs}/2022-02-24-servers-configuration.md | 0 {rfcs => docs/rfcs}/2022-03-02-avatars.md | 0 .../rfcs}/2022-03-02-number-chat-items.md | 0 .../message_views.sql | 0 simplex.md | 84 ----- 12 files changed, 377 insertions(+), 341 deletions(-) create mode 100644 docs/CLI.md create mode 100644 docs/COMPARISON.md rename message_queries.md => docs/SQL.md (94%) rename {protocol => docs/protocol}/types.ts (100%) rename {rfcs => docs/rfcs}/2022-01-26-mobile-app.md (100%) rename {rfcs => docs/rfcs}/2022-02-10-deduplicate-contact-requests.md (100%) rename {rfcs => docs/rfcs}/2022-02-24-servers-configuration.md (100%) rename {rfcs => docs/rfcs}/2022-03-02-avatars.md (100%) rename {rfcs => docs/rfcs}/2022-03-02-number-chat-items.md (100%) rename message_views.sql => scripts/message_views.sql (100%) delete mode 100644 simplex.md diff --git a/README.md b/README.md index 0884573f3a..11964c6bc1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ SimpleX logo -# SimpleX - the first chat platform that is 100% private by design - it has no access to your connection graph! +# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! [![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild) [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) @@ -19,280 +19,115 @@ [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk) - 🖲 Protects your messages and metadata - who you talk to and when. -- 🔐 Double ratchet encryption. -- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md). +- 🔐 Double ratchet end-to-end encryption, with additional encryption layer. +- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! -- 🖥 Available as a [terminal (console) app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. +- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows. -See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. +## SimpleX unique approach to privacy and security -### :zap: Quick installation of a terminal app +Everyone should care about privacy and security of their communications - even ordinary conversations can put you in danger. + +### Full privacy of your identity, profile, contacts and metadata + +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - it does not use phone numbers (like Signal or WhatsApp), domain-based addresses (like email, XMPP or Matrix), usernames (like Telegram), public keys or even random numbers (like all other messengers) to identify its users - we do not even know how many people use SimpleX. + +To deliver the messages instead of user identifiers that all other platforms use, SimpleX uses the addresses of unidirectional (simplex) message queues. Using SimpleX is like having a different email address or a phone number for each contact you have, but without the hassle of managing all these addresses. In the near future SimpleX apps will also change the message queues automatically, moving the conversations from one server to another, to provide even better privacy to the users. + +This approach protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. You can further improve your privacy by configuring your network access to connect to SimpleX servers via some overlay transport network, e.g. Tor. + +### The best protection against spam and abuse + +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optinal user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. + +### Complete ownership, control and security of your data + +SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. + +We use portable database format that can be used on all supported devices - we will soon add the ability to export the chat database from the mobile app so it can be used on another device. + +Unlike servers of federated networks (email, XMPP or Matrix), SimpleX servers do not store user accounts, they simply relay messages to the recipients, protecting the privacy of both parties. There are no identifiers or encrypted messages in common between sent and received traffic of the server, thanks to the additional encryption layer for delivered messages. So if anybody is observing server traffic, they cannot easily determine who is communicating with whom (see [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for the known traffic correlation attacks). + +### Users own SimpleX network + +You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. + +SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps – we are really looking forward to see what SimpleX services can be built. + +If you are considering developing with SimpleX platform, whether to build chat bot service for SimpleX apps users or to integrate SimpleX Chat library in your mobile apps, please get in touch for any advice and support. + +## News and updates + +[Apr 04, 2022. Instant notifications for SimpleX Chat mobile apps](./blog/20220404-simplex-chat-instant-notifications.md). We would really appreciate any feedback on the design we are implementing. + +[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md) + +[Feb 14, 2022. SimpleX Chat: join our public beta for iOS](./blog/20220214-simplex-chat-ios-public-beta.md) + +[All updates](./blog) + +## Make a private connection + +You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging. + +The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established. + +Make a private connection + +## :zap: Quick installation of a terminal app ```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash ``` Once the chat client is installed, simply run `simplex-chat` from your terminal. ![simplex-chat](./images/connection.gif) -## Table of contents +Read more about [installing and using the terminal app](./docs/CLI.md). -- [Disclaimer](#disclaimer) -- [Network topology](#network-topology) -- [Terminal chat features](#terminal-chat-features) -- [Installation](#🚀-installation) - - [Download chat client](#download-chat-client) - - [Linux and MacOS](#linux-and-macos) - - [Windows](#windows) - - [Build from source](#build-from-source) - - [Using Docker](#using-docker) - - [Using Haskell stack](#using-haskell-stack) -- [Usage](#usage) - - [Running the chat client](#running-the-chat-client) - - [How to use SimpleX chat](#how-to-use-simplex-chat) - - [Groups](#groups) - - [Sending files](#sending-files) - - [User contact addresses](#user-contact-addresses) - - [Access chat history](#access-chat-history) -- [Roadmap](#Roadmap) -- [License](#license) +## SimpleX Platform design -## Disclaimer +SimpleX is a client-server network with a unique network topology that uses redundant, disposable message relay nodes to asynchronously pass messages via unidirectional (simplex) message queues, providing recipient and sender anonymity. -SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks. +Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/COMPARISON.md#comparison-with-p2p-messaging-protocols). -[SimpleXMQ security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) had many improvements in v1.0.0; the implementation has not been audited yet. +Unlike federated networks, the server nodes **do not have records of the users**, **do not communicate with each other** and **do not store messages** after they are delivered to the recipients. There is no way to discover the full list of servers participating in SimpleX network. This design avoids the problem of metadata visibility that all federated networks have and better protects from the network-wide attacks. -We use SimpleX Chat all the time, but you may find some bugs. We would really appreciate if you use it and let us know anything that needs to be fixed or improved. +Only the client devices have information about users, their contacts and groups. -## Network topology - -SimpleX is a client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity. - -Unlike P2P networks, all messages are passed through one or several (for redundancy) servers, that do not even need to have persistence (in fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records) - it provides better metadata protection than P2P designs, as no global participant ID is required, and avoids many [problems of P2P networks](https://github.com/simplex-chat/simplex-chat/blob/master/simplex.md#comparison-with-p2p-messaging-protocols). - -Unlike federated networks, the participating server nodes **do not have records of the users**, **do not communicate with each other**, **do not store messages** after they are delivered to the recipients, and there is no way to discover the full list of participating servers. SimpleX network avoids the problem of metadata visibility that federated networks have and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two Ed448 keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart. - -The routing of messages relies on the knowledge of client devices how user contacts and groups map at any given moment of time to these disposable queues on server nodes. - -## Terminal chat features - -- 1-to-1 chat with multiple people in the same terminal window. -- Group messaging. -- Sending files to contacts and groups. -- User contact addresses - establish connections via multiple-use contact links. -- Messages persisted in a local SQLite database. -- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established. -- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent). -- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations. -- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)). -- Message integrity validation (via including the digests of the previous messages). -- Authentication of each command/message by SMP servers with automatically generated Ed448 keys. -- TLS 1.3 transport encryption. -- Additional encryption of messages from SMP server to recipient to reduce traffic correlation. - -Public keys involved in key exchange are not used as identity, they are randomly generated for each contact. - -See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details. - - - -## 🚀 Installation - -### Download chat client - -#### Linux and MacOS - -To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command: - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash -``` - -```sh -wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash -``` - -Once the chat client downloads, you can run it with `simplex-chat` command in your terminal. - -Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below. - -```sh -chmod +x -mv ~/.local/bin/simplex-chat -``` - -(or any other preferred location on `PATH`). - -On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491). - -#### Windows - -```sh -move %APPDATA%/local/bin/simplex-chat.exe -``` - -### Build from source - -> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable). - -#### Using Docker - -On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): - -```shell -$ git clone git@github.com:simplex-chat/simplex-chat.git -$ cd simplex-chat -$ git checkout stable -$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . -``` - -> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)). - -#### Using Haskell stack - -Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/): - -```shell -curl -sSL https://get.haskellstack.org/ | sh -``` - -and build the project: - -```shell -$ git clone git@github.com:simplex-chat/simplex-chat.git -$ cd simplex-chat -$ git checkout stable -$ stack install -``` - -## Usage - -### Running the chat client - -To start the chat client, run `simplex-chat` from the terminal. - -By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it. - -To specify a different file path prefix for the database files use `-d` command line option: - -```shell -$ simplex-chat -d alice -``` - -Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory. - -Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L42). - -If you deployed your own SMP server(s) you can configure client via `-s` option: - -```shell -$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com -``` - -Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake. - -You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client). - -Run `simplex-chat -h` to see all available options. - -### How to use SimpleX chat - -Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name. - -The diagram below shows how to connect and message a contact: - -
- -
- -Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel. - -You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with. - -The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request). - -The contact who received the invitation should enter `/c ` to accept the connection. This establishes the connection, and both parties are notified. - -They would then use `@ ` commands to send messages. You may also just start typing a message to send it to the contact that was the last. - -Use `/help` in chat to see the list of available commands. - -### Groups - -To create a group use `/g `, then add contacts to it with `/a `. You can then send messages to the group by entering `# `. Use `/help groups` for other commands. - -![simplex-chat](./images/groups.gif) - -> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent. - -### Sending files - -You can send a file to your contact with `/f @ ` - the recipient will have to accept it before it is sent. Use `/help files` for other commands. - -![simplex-chat](./images/files.gif) - -You can send files to a group with `/f # `. - -### User contact addresses - -As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c `. - -You can accept or reject incoming requests with `/ac ` and `/rc ` commands. - -User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you). - -Use `/help address` for other commands. - -![simplex-chat](./images/user-addresses.gif) - -### Access chat history - -SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user. - -You can view and search your chat history by querying your database. Run the below script to create message views in your database. - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Open SQLite Command Line Shell: - -```sh -sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -See [Message queries](./message_queries.md) for examples. - -> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state. - -**Convenience queries** - -Get all messages from today (`chat_dt` is in UTC): - -```sql -select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; -``` - -Get overnight messages in the morning: - -```sql -select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; -``` +See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. ## Roadmap -1. Mobile and desktop apps (in progress). -2. SMP protocol improvements: - - SMP queue redundancy and rotation. - - Message delivery confirmation. - - Support multiple devices. -3. Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages: - - keep all your contacts and groups even if you lose the domain. - - the server doesn't have information about your contacts and groups. -4. Media server to optimize sending large files to groups. -5. Channels server for large groups and broadcast channels. +- ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies. +- ✅ Terminal (console) client with groups and files support. +- ✅ One-click SimpleX server deployment on Linode. +- ✅ End-to-end encryption using double-ratchet protocol with additional encryption layer. +- ✅ Mobile apps v1 for Android and iOS. +- ✅ Private instant notifications for Android using background service. +- ✅ Haskell chat bot templates +- 🏗 Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress). +- 🏗 Mobile app v2 - supporting files, images and groups etc. (in progress). +- 🏗 Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress). +- Chat database portability and encryption. +- End-to-end encrypted audio and video calls via the mobile apps. +- Web widgets for custom interactivity in the chats. +- SMP protocol improvements: + - SMP queue redundancy and rotation. + - Message delivery confirmation. + - Supporting the same profile on multiple devices. +- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages: + - keep all your contacts and groups even if you lose the domain. + - the server doesn't have information about your contacts and groups. +- Media server to optimize sending large files to groups. +- Channels server for large groups and broadcast channels. + +## Disclaimer + +[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit. + +You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved. ## License diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000000..1c49c8a1f5 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,224 @@ +# SimpleX Chat terminal (console) app for Linux/MacOS/Windows + +## Table of contents + +- [Terminal chat features](#terminal-chat-features) +- [Installation](#🚀-installation) + - [Download chat client](#download-chat-client) + - [Linux and MacOS](#linux-and-macos) + - [Windows](#windows) + - [Build from source](#build-from-source) + - [Using Docker](#using-docker) + - [Using Haskell stack](#using-haskell-stack) +- [Usage](#usage) + - [Running the chat client](#running-the-chat-client) + - [How to use SimpleX chat](#how-to-use-simplex-chat) + - [Groups](#groups) + - [Sending files](#sending-files) + - [User contact addresses](#user-contact-addresses) + - [Access chat history](#access-chat-history) + +## Terminal chat features + +- 1-to-1 chat with multiple people in the same terminal window. +- Group messaging. +- Sending files to contacts and groups. +- User contact addresses - establish connections via multiple-use contact links. +- Messages persisted in a local SQLite database. +- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established. +- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent). +- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations. +- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)). +- Message integrity validation (via including the digests of the previous messages). +- Authentication of each command/message by SMP servers with automatically generated Ed448 keys. +- TLS 1.3 transport encryption. +- Additional encryption of messages from SMP server to recipient to reduce traffic correlation. + +Public keys involved in key exchange are not used as identity, they are randomly generated for each contact. + +See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details. + + + +## 🚀 Installation + +### Download chat client + +#### Linux and MacOS + +To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command: + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +```sh +wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +Once the chat client downloads, you can run it with `simplex-chat` command in your terminal. + +Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below. + +```sh +chmod +x +mv ~/.local/bin/simplex-chat +``` + +(or any other preferred location on `PATH`). + +On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491). + +#### Windows + +```sh +move %APPDATA%/local/bin/simplex-chat.exe +``` + +### Build from source + +> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable). + +#### Using Docker + +On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): + +```shell +$ git clone git@github.com:simplex-chat/simplex-chat.git +$ cd simplex-chat +$ git checkout stable +$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . +``` + +> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)). + +#### Using Haskell stack + +Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/): + +```shell +curl -sSL https://get.haskellstack.org/ | sh +``` + +and build the project: + +```shell +$ git clone git@github.com:simplex-chat/simplex-chat.git +$ cd simplex-chat +$ git checkout stable +$ stack install +``` + +## Usage + +### Running the chat client + +To start the chat client, run `simplex-chat` from the terminal. + +By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it. + +To specify a different file path prefix for the database files use `-d` command line option: + +```shell +$ simplex-chat -d alice +``` + +Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory. + +Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/stable/src/Simplex/Chat/Options.hs#L42). + +If you deployed your own SMP server(s) you can configure client via `-s` option: + +```shell +$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com +``` + +Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake. + +You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client). + +Run `simplex-chat -h` to see all available options. + +### How to use SimpleX chat + +Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name. + +The diagram below shows how to connect and message a contact: + +
+ +
+ +Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel. + +You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with. + +The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request). + +The contact who received the invitation should enter `/c ` to accept the connection. This establishes the connection, and both parties are notified. + +They would then use `@ ` commands to send messages. You may also just start typing a message to send it to the contact that was the last. + +Use `/help` in chat to see the list of available commands. + +### Groups + +To create a group use `/g `, then add contacts to it with `/a `. You can then send messages to the group by entering `# `. Use `/help groups` for other commands. + +![simplex-chat](./images/groups.gif) + +> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent. + +### Sending files + +You can send a file to your contact with `/f @ ` - the recipient will have to accept it before it is sent. Use `/help files` for other commands. + +![simplex-chat](./images/files.gif) + +You can send files to a group with `/f # `. + +### User contact addresses + +As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c `. + +You can accept or reject incoming requests with `/ac ` and `/rc ` commands. + +User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you). + +Use `/help address` for other commands. + +![simplex-chat](./images/user-addresses.gif) + +### Access chat history + +SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user. + +You can view and search your chat history by querying your database. Run the below script to create message views in your database. + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/scripts/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db +``` + +Open SQLite Command Line Shell: + +```sh +sqlite3 ~/.simplex/simplex_v1_chat.db +``` + +See [Message queries](./SQL.md) for examples. + +> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state. + +**Convenience queries** + +Get all messages from today (`chat_dt` is in UTC): + +```sql +select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; +``` + +Get overnight messages in the morning: + +```sql +select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; +``` diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md new file mode 100644 index 0000000000..9fe344f1e5 --- /dev/null +++ b/docs/COMPARISON.md @@ -0,0 +1,61 @@ +# SimpleX platform - motivation and comparison + +## Problems + +Existing chat platforms and protocols have some or all of the following problems: + +- Lack of privacy of the user profile and contacts (meta-data privacy). +- No protection (or only optional protection) of [E2EE][1] implementations from MITM attacks via provider. +- Unsolicited messages (spam and abuse). +- Lack of data ownership and protection. +- Complexity of usage for all non-centralized protocols to non-technical users. + +The concentration of the communication in a small number of centralized platforms makes resolving these problems quite difficult. + +## Proposed solution + +Proposed stack of protocols solves these problems by making both messages and contacts stored only on client devices, reducing the role of the servers to simple message relays that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected becuse users do not have any identifiers assiged to them - unlike with any other platforms. + +See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. + +## Comparison with other protocols + +| | SimpleX chat | Signal, big platforms | XMPP, Matrix | P2P protocols | +| :--------------------------------------------- | :----------------: | :-------------------: | :-------------: | :-------------: | +| Requires user identifiers | No = private | Yes1 | Yes2 | Yes3 | +| Possibility of MITM | No = secure | Yes4 | Yes | Yes | +| Dependence on DNS | No = resilient | Yes | Yes | No | +| Single operator or network | No = decentralized | Yes | No | Yes5 | +| Central component or other network-wide attack | No = resilient | Yes | Yes2 | Yes6 | + +1. Usually based on a phone number, in some cases on usernames. +2. DNS based. +3. Public key or some other globally unique ID. +4. If operator’s servers are compromised. +5. While P2P networks and cryptocurrency-based networks are distributed, they are not decentralized - they operate as a single network, with a single namespace of user addresses. +6. P2P networks either have a central authority or the whole network can be compromised - see the next section. + +## Comparison with [P2P][9] messaging protocols + +There are several P2P chat/messaging protocols and implementations that aim to solve privacy and centralisation problem, but they have their own set of problems that makes them less reliable than the proposed design, more complex to implement and analyse and more vulnerable to attacks. + +1. [P2P][9] networks use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency. The proposeddesign has both better delivery guarantees and lower latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). + +2. The proposed design, unlike most P2P networks, has no global user identitifiers of any kind, even temporary. + +3. P2P itself does not solve [MITM attack][2] problem, and most existing solutions do not use out-of-band messages for the initial key exchange. The proposed design uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange. + +4. P2P implementations can be blocked by some Internet providers (like [BitTorrent][11]). The proposed design is transport agnostic - it can work over standard web protocols, and the servers can be deployed on the same domains as the websites. + +5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The SimpleX network is fragmented and operates as multiple isolated connections. It makes network-wide attacks on SimpleX network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can switch to using other servers without losing contacts or messages. + +6. P2P networks are likely to be vulnerable to [DRDoS attack][14]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. + +[1]: https://en.wikipedia.org/wiki/End-to-end_encryption +[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack +[9]: https://en.wikipedia.org/wiki/Peer-to-peer +[10]: https://en.wikipedia.org/wiki/Distributed_hash_table +[11]: https://en.wikipedia.org/wiki/BitTorrent +[12]: https://en.wikipedia.org/wiki/Sybil_attack +[13]: https://en.wikipedia.org/wiki/Proof_of_work +[14]: https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent diff --git a/message_queries.md b/docs/SQL.md similarity index 94% rename from message_queries.md rename to docs/SQL.md index 0f5c8d70ad..c422c53a31 100644 --- a/message_queries.md +++ b/docs/SQL.md @@ -1,4 +1,4 @@ -# Message queries +# Accessing message history via SQL queries You can run queries against `direct_messages`, `group_messages` and `all_messages` (or their simpler alternatives `direct_messages_plain`, `group_messages_plain` and `all_messages_plain`), for example: @@ -21,7 +21,7 @@ select * from all_messages_plain; -- files you offered for sending select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; -- everything catherine sent related to cats -select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; +select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; -- all correspondence with alice in #team select * from group_messages where group_name = 'team' and contact = 'alice'; diff --git a/protocol/types.ts b/docs/protocol/types.ts similarity index 100% rename from protocol/types.ts rename to docs/protocol/types.ts diff --git a/rfcs/2022-01-26-mobile-app.md b/docs/rfcs/2022-01-26-mobile-app.md similarity index 100% rename from rfcs/2022-01-26-mobile-app.md rename to docs/rfcs/2022-01-26-mobile-app.md diff --git a/rfcs/2022-02-10-deduplicate-contact-requests.md b/docs/rfcs/2022-02-10-deduplicate-contact-requests.md similarity index 100% rename from rfcs/2022-02-10-deduplicate-contact-requests.md rename to docs/rfcs/2022-02-10-deduplicate-contact-requests.md diff --git a/rfcs/2022-02-24-servers-configuration.md b/docs/rfcs/2022-02-24-servers-configuration.md similarity index 100% rename from rfcs/2022-02-24-servers-configuration.md rename to docs/rfcs/2022-02-24-servers-configuration.md diff --git a/rfcs/2022-03-02-avatars.md b/docs/rfcs/2022-03-02-avatars.md similarity index 100% rename from rfcs/2022-03-02-avatars.md rename to docs/rfcs/2022-03-02-avatars.md diff --git a/rfcs/2022-03-02-number-chat-items.md b/docs/rfcs/2022-03-02-number-chat-items.md similarity index 100% rename from rfcs/2022-03-02-number-chat-items.md rename to docs/rfcs/2022-03-02-number-chat-items.md diff --git a/message_views.sql b/scripts/message_views.sql similarity index 100% rename from message_views.sql rename to scripts/message_views.sql diff --git a/simplex.md b/simplex.md deleted file mode 100644 index 6dc22096d1..0000000000 --- a/simplex.md +++ /dev/null @@ -1,84 +0,0 @@ -# Federated chat system with [E2EE][1] and low risk of [MITM attack][2] - -## Problems - -Existing chat platforms and protocols have some or all of the following problems: - -- Lack of privacy of the user profile and connections (meta-data privacy). -- No protection (or only optional protection) of [E2EE][1] implementations from MITM attacks. -- Unsolicited messages (spam and abuse). -- Lack of data ownership and protection. -- Complexity of usage for all non-centralized protocols to non-technical users. - -The concentration of the communication in a small number of centralized platforms makes resolving these problems quite difficult. - -## Proposed solution - -Proposed stack of protocols solves these and other problems by making both messages and contacts accessible only on client devices, reducing the role of the servers to simple message brokers that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected. - -See [SMP protocol][6] and [SMP agent protocol][8]. - -## Comparison with other protocols - -| | SimpleX chat | Signal, big platforms | XMPP, Matrix | P2P protocols | -|:-------- |:------------:|:---------------------:|:------------:|:-------------:| -| Requires global identity | No = private | Yes1 | Yes2 | Yes3 | -| Possibility of MITM | No = secure | Yes4 | Yes | Yes | -| Dependence on DNS | No = resilient | Yes | Yes | No | -| Federation | Yes | No | Yes | No5 | -| Central component or other network-wide attack | No = resilient | Yes | Yes2 | Yes6 | - -1. Usually based on a phone number, in some cases on usernames. -2. DNS based. -3. Public key or some other globally unique ID. -4. If operator’s servers are compromised. -5. While P2P networks are distributed, they are not federated - they operate as a single network. -6. P2P networks either have a central authority or the whole network can be compromised - see the next section. - -## Comparison with [P2P][9] messaging protocols - -There are several P2P chat/messaging protocols and implementations that aim to solve privacy and centralisation problem, but they have their own set of problems that makes them less reliable than the proposed chat system design, more complex to implement and analyse and more vulnerable to attacks. - -1. [P2P][9] networks either have some centralized component, which makes them highly vulnerable, or, more commonly, use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency, and also have some other problems. The proposed chat system design has both higher delivery guarantee and low latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). - -2. The proposed design, unlike most P2P networks, has no global identity of any form, even temporary. - -3. P2P itself does not solve [MITM attack][2] problem, but most existing solutions do not use out-of-band messages for the initial key exchange. The proposed design uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange. - -4. P2P implementations can be blocked by some Internet providers (like [BitTorrent][11]). The proposed design is transport agnostic - it can work over standard web protocols, and the servers can be deployed on the same domains as the websites. - -5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a vulnerable centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The chat network is fragmented and operates as multiple isolated connections. It makes Sybil attack on the whole simplex messaging network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can always switch to using other servers without losing contacts or messages. - -6. P2P networks are likely to be vulnerable to [DRDoS attack][14]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. - -## Network features - -- No user identity known to system servers - no phone numbers, user names and no DNS are needed to authorize users to the network. -- Each user can be connected to multiple servers to ensure message delivery, even if some of the servers are compromised. -- No single server in the system has visibility of all connections or messages of any user, as user profiles are identified by multiple rotating public keys, using separate key for each profile connection. -- Uses standard asymmetric cryptographic protocols, so that system users can create independent server and client implementations complying with the protocols. -- Open-source server implementations that can be easily deployed by any user with minimal technical expertise (e.g. on Heroku via web UI). -- Open-source client implementations so that system users can independently assess system security model. -- Only client applications store user profiles, contacts of other user profiles, messages; servers do NOT have access to any of this information and (unless compromised) do NOT store encrypted messages or any logs. -- Multiple client applications and devices can be used by each user profile to communicate and to share connections and message history - the devices are not known to the servers. -- Initial key exchange and establishing connections between user profiles is done by sharing the invitation (e.g. QR code via any independent communication channel (or directly via screen and camera), system servers are NOT used for key exchange - to reduce risk of key substitution in [MITM attack][2]. QR code contains the connection-specific public key and other information needed to establish the connection. -- Connections between users can be established via shared trusted connections to simplify key exchange. -- Servers do NOT communicate with each other, they only communicate with client applications. -- Unique public key is used for each user profile connection in order to: - - reduce the risk of attacker posing as user's connection; - - avoid exposing all user connections to the servers. -- Unique public key is used to identify each connection participant to each server. -- Public keys used between connections are regularly rotated to prevent decryption of the full message history ([forward secrecy][4]) in case when some servers or middlemen preserve message history and the current key is compromised. -- Users can repeat key exchange using QR code and alternative channel at any point to increase communication security and trust. - -[1]: https://en.wikipedia.org/wiki/End-to-end_encryption -[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack -[4]: https://en.wikipedia.org/wiki/Forward_secrecy -[6]: https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md -[8]: https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md -[9]: https://en.wikipedia.org/wiki/Peer-to-peer -[10]: https://en.wikipedia.org/wiki/Distributed_hash_table -[11]: https://en.wikipedia.org/wiki/BitTorrent -[12]: https://en.wikipedia.org/wiki/Sybil_attack -[13]: https://en.wikipedia.org/wiki/Proof_of_work -[14]: https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent From 07c779952379e2d1a292843e974241bed5f33826 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 12 Apr 2022 19:30:17 +0100 Subject: [PATCH 27/37] reduce text in readme (#525) * reduce text in readme * update "why" * typo Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * dot Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- README.md | 39 +++++++++++++++++------------- docs/{COMPARISON.md => SIMPLEX.md} | 34 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) rename docs/{COMPARISON.md => SIMPLEX.md} (61%) diff --git a/README.md b/README.md index 11964c6bc1..22291c6744 100644 --- a/README.md +++ b/README.md @@ -24,37 +24,42 @@ - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! - 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows. -## SimpleX unique approach to privacy and security +## Why privacy of communications matter -Everyone should care about privacy and security of their communications - even ordinary conversations can put you in danger. +Everyone should care about privacy and security of their communications - ordinary conversations can put you in danger even if you are innocent. + +One of the most shocking stories is the experience of [Mohamedou Ould Slahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany since 10 years prior to the attacks. + +This is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with. + +## SimpleX unique approach to privacy and security ### Full privacy of your identity, profile, contacts and metadata -**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - it does not use phone numbers (like Signal or WhatsApp), domain-based addresses (like email, XMPP or Matrix), usernames (like Telegram), public keys or even random numbers (like all other messengers) to identify its users - we do not even know how many people use SimpleX. - -To deliver the messages instead of user identifiers that all other platforms use, SimpleX uses the addresses of unidirectional (simplex) message queues. Using SimpleX is like having a different email address or a phone number for each contact you have, but without the hassle of managing all these addresses. In the near future SimpleX apps will also change the message queues automatically, moving the conversations from one server to another, to provide even better privacy to the users. - -This approach protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. You can further improve your privacy by configuring your network access to connect to SimpleX servers via some overlay transport network, e.g. Tor. +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). ### The best protection against spam and abuse -As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optinal user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. [Read more](./docs/SIMPLEX.md#the-best-protection-against-spam-and-abuse). ### Complete ownership, control and security of your data -SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. - -We use portable database format that can be used on all supported devices - we will soon add the ability to export the chat database from the mobile app so it can be used on another device. - -Unlike servers of federated networks (email, XMPP or Matrix), SimpleX servers do not store user accounts, they simply relay messages to the recipients, protecting the privacy of both parties. There are no identifiers or encrypted messages in common between sent and received traffic of the server, thanks to the additional encryption layer for delivered messages. So if anybody is observing server traffic, they cannot easily determine who is communicating with whom (see [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for the known traffic correlation attacks). +SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. [Read more](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data). ### Users own SimpleX network -You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. +You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network). -SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps – we are really looking forward to see what SimpleX services can be built. +## For developers -If you are considering developing with SimpleX platform, whether to build chat bot service for SimpleX apps users or to integrate SimpleX Chat library in your mobile apps, please get in touch for any advice and support. +We plan that SimpleX platform will grow into the platform for any distributed Internet applications, allowing to build any services that people can access via chat, with custom web-based UI widgets that anybody with a basic HTML/CSS/JavaScript knowledge can create in a few hours. + +You already can: + +- use SimpleX Chat library to integrate chat functionality into your apps. +- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon). + +If you are considering developing with SimpleX platform please get in touch for any advice and support. ## News and updates @@ -90,7 +95,7 @@ Read more about [installing and using the terminal app](./docs/CLI.md). SimpleX is a client-server network with a unique network topology that uses redundant, disposable message relay nodes to asynchronously pass messages via unidirectional (simplex) message queues, providing recipient and sender anonymity. -Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/COMPARISON.md#comparison-with-p2p-messaging-protocols). +Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols). Unlike federated networks, the server nodes **do not have records of the users**, **do not communicate with each other** and **do not store messages** after they are delivered to the recipients. There is no way to discover the full list of servers participating in SimpleX network. This design avoids the problem of metadata visibility that all federated networks have and better protects from the network-wide attacks. diff --git a/docs/COMPARISON.md b/docs/SIMPLEX.md similarity index 61% rename from docs/COMPARISON.md rename to docs/SIMPLEX.md index 9fe344f1e5..aebf274367 100644 --- a/docs/COMPARISON.md +++ b/docs/SIMPLEX.md @@ -18,6 +18,40 @@ Proposed stack of protocols solves these problems by making both messages and co See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. +## Why use SimpleX + +## SimpleX unique approach to privacy and security + +Everyone should care about privacy and security of their communications - even ordinary conversations can put you in danger. + +### Full privacy of your identity, profile, contacts and metadata + +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - it does not use phone numbers (like Signal or WhatsApp), domain-based addresses (like email, XMPP or Matrix), usernames (like Telegram), public keys or even random numbers (like all other messengers) to identify its users - we do not even know how many people use SimpleX. + +To deliver the messages instead of user identifiers that all other platforms use, SimpleX uses the addresses of unidirectional (simplex) message queues. Using SimpleX is like having a different email address or a phone number for each contact you have, but without the hassle of managing all these addresses. In the near future SimpleX apps will also change the message queues automatically, moving the conversations from one server to another, to provide even better privacy to the users. + +This approach protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. You can further improve your privacy by configuring your network access to connect to SimpleX servers via some overlay transport network, e.g. Tor. + +### The best protection against spam and abuse + +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optinal user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. + +### Complete ownership, control and security of your data + +SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. + +We use portable database format that can be used on all supported devices - we will soon add the ability to export the chat database from the mobile app so it can be used on another device. + +Unlike servers of federated networks (email, XMPP or Matrix), SimpleX servers do not store user accounts, they simply relay messages to the recipients, protecting the privacy of both parties. There are no identifiers or encrypted messages in common between sent and received traffic of the server, thanks to the additional encryption layer for delivered messages. So if anybody is observing server traffic, they cannot easily determine who is communicating with whom (see [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for the known traffic correlation attacks). + +### Users own SimpleX network + +You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. + +SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps – we are really looking forward to see what SimpleX services can be built. + +If you are considering developing with SimpleX platform, whether to build chat bot service for SimpleX apps users or to integrate SimpleX Chat library in your mobile apps, please get in touch for any advice and support. + ## Comparison with other protocols | | SimpleX chat | Signal, big platforms | XMPP, Matrix | P2P protocols | From f1356ca6421384dd6f3aaba71ef39f1836a112bc Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Tue, 12 Apr 2022 19:57:36 +0100 Subject: [PATCH 28/37] readme changes (#527) * readme changes * more changes * response to comments --- README.md | 8 ++++---- docs/SIMPLEX.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 22291c6744..ffe1d85d40 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ ## Why privacy of communications matter -Everyone should care about privacy and security of their communications - ordinary conversations can put you in danger even if you are innocent. +Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even even if there is nothing to hide. -One of the most shocking stories is the experience of [Mohamedou Ould Slahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany since 10 years prior to the attacks. +One of the most shocking stories is the experience of [Mohamedou Ould Slahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks. -This is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with. +It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with. ## SimpleX unique approach to privacy and security @@ -52,7 +52,7 @@ You can use SimpleX with your own servers and still communicate with people usin ## For developers -We plan that SimpleX platform will grow into the platform for any distributed Internet applications, allowing to build any services that people can access via chat, with custom web-based UI widgets that anybody with a basic HTML/CSS/JavaScript knowledge can create in a few hours. +We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours. You already can: diff --git a/docs/SIMPLEX.md b/docs/SIMPLEX.md index aebf274367..86b4320bce 100644 --- a/docs/SIMPLEX.md +++ b/docs/SIMPLEX.md @@ -14,7 +14,7 @@ The concentration of the communication in a small number of centralized platform ## Proposed solution -Proposed stack of protocols solves these problems by making both messages and contacts stored only on client devices, reducing the role of the servers to simple message relays that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected becuse users do not have any identifiers assiged to them - unlike with any other platforms. +Proposed stack of protocols solves these problems by making both messages and contacts stored only on client devices, reducing the role of the servers to simple message relays that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected because users do not have any identifiers assigned to them - unlike with any other platforms. See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. @@ -34,7 +34,7 @@ This approach protects the privacy of who are you communicating with, hiding it ### The best protection against spam and abuse -As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optinal user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optional user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. ### Complete ownership, control and security of your data @@ -50,7 +50,7 @@ You can use SimpleX with your own servers and still communicate with people usin SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps – we are really looking forward to see what SimpleX services can be built. -If you are considering developing with SimpleX platform, whether to build chat bot service for SimpleX apps users or to integrate SimpleX Chat library in your mobile apps, please get in touch for any advice and support. +If you are considering developing with the SimpleX platform, whether for chat bot services for SimpleX app users or to integrate the SimpleX Chat library into your mobile apps, please get in touch for any advice and support. ## Comparison with other protocols @@ -73,9 +73,9 @@ If you are considering developing with SimpleX platform, whether to build chat b There are several P2P chat/messaging protocols and implementations that aim to solve privacy and centralisation problem, but they have their own set of problems that makes them less reliable than the proposed design, more complex to implement and analyse and more vulnerable to attacks. -1. [P2P][9] networks use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency. The proposeddesign has both better delivery guarantees and lower latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). +1. [P2P][9] networks use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency. The proposed design has both better delivery guarantees and lower latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). -2. The proposed design, unlike most P2P networks, has no global user identitifiers of any kind, even temporary. +2. The proposed design, unlike most P2P networks, has no global user identifiers of any kind, even temporary. 3. P2P itself does not solve [MITM attack][2] problem, and most existing solutions do not use out-of-band messages for the initial key exchange. The proposed design uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange. From 1431002829a16ed03c89ea25be6c14afaa3562de Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Tue, 12 Apr 2022 20:38:38 +0100 Subject: [PATCH 29/37] add status icons to messages (#524) * add status icons to messages * prettier spacing * tighten status icons Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../simplex/app/views/chat/item/CIMetaView.kt | 105 ++++++++++++++++-- .../app/views/chat/item/TextItemView.kt | 2 +- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index ec00538bce..be21371f30 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -4,43 +4,62 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.CIDirection -import chat.simplex.app.model.ChatItem +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimplexBlue import kotlinx.datetime.Clock @Composable fun CIMetaView(chatItem: ChatItem) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(verticalAlignment = Alignment.CenterVertically) { if (!chatItem.isDeletedContent) { if (chatItem.meta.itemEdited) { Icon( Icons.Filled.Edit, - modifier = Modifier.height(12.dp), + modifier = Modifier.height(12.dp).padding(end = 1.dp), contentDescription = "Edited", tint = HighOrLowlight, ) } - // TODO status + CIStatusView(chatItem.meta.itemStatus) } Text( chatItem.timestampText, color = HighOrLowlight, - fontSize = 14.sp + fontSize = 14.sp, + modifier = Modifier.padding(start = 3.dp) ) } } + +@Composable +fun CIStatusView(status: CIStatus) { + when (status) { + is CIStatus.SndSent -> { + Icon(Icons.Filled.Check, "sent", Modifier.height(12.dp), tint = HighOrLowlight) + } + is CIStatus.SndErrorAuth -> { + Icon(Icons.Filled.Close, "unauthorized send", Modifier.height(12.dp), tint = Color.Red) + } + is CIStatus.SndError -> { + Icon(Icons.Filled.WarningAmber, "send failed", Modifier.height(12.dp), tint = Color.Yellow) + } + is CIStatus.RcvNew -> { + Icon(Icons.Filled.Circle, "unread", Modifier.height(12.dp), tint = SimplexBlue) + } + else -> {} + } +} + @Preview @Composable fun PreviewCIMetaView() { @@ -51,6 +70,48 @@ fun PreviewCIMetaView() { ) } +@Preview +@Composable +fun PreviewCIMetaViewUnread() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + status = CIStatus.RcvNew() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendFailed() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX())) + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendNoAuth() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendSent() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent() + ) + ) +} + @Preview @Composable fun PreviewCIMetaViewEdited() { @@ -62,6 +123,30 @@ fun PreviewCIMetaViewEdited() { ) } +@Preview +@Composable +fun PreviewCIMetaViewEditedUnread() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", + itemEdited = true, + status=CIStatus.RcvNew() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewEditedSent() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + itemEdited = true, + status=CIStatus.SndSent() + ) + ) +} + @Preview @Composable fun PreviewCIMetaViewDeletedContent() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 8d25881786..6f971eaaa9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -47,7 +47,7 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier ) { - val reserve = if (edited) " " else " " + val reserve = if (edited) " " else " " if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) From f83704c96414a19b110542f5ba33eb13f28aebf9 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 13 Apr 2022 11:37:13 +0400 Subject: [PATCH 30/37] fix typos in readme (#528) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ffe1d85d40..2f768bb9bf 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ## Why privacy of communications matter -Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even even if there is nothing to hide. +Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide. One of the most shocking stories is the experience of [Mohamedou Ould Slahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks. @@ -36,7 +36,7 @@ It is not enough to use an end-to-end encrypted messenger, we all should use the ### Full privacy of your identity, profile, contacts and metadata -**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). ### The best protection against spam and abuse From 5281871aa6ae8191e19d15f1be7f45144bc9568d Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 13 Apr 2022 11:49:09 +0100 Subject: [PATCH 31/37] typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2f768bb9bf..30c565f574 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide. -One of the most shocking stories is the experience of [Mohamedou Ould Slahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks. +One of the most shocking stories is the experience of [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks. It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with. From e560ed83276f9da42bad24dd43361593963b2aa7 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 15 Apr 2022 09:36:38 +0400 Subject: [PATCH 32/37] core: support files folder for mobile, delete files, chat item in CRRcvFileComplete (#530) --- src/Simplex/Chat.hs | 91 +++++++++++++++++++++++++++------- src/Simplex/Chat/Controller.hs | 6 ++- src/Simplex/Chat/Mobile.hs | 3 +- src/Simplex/Chat/Store.hs | 45 +++++++++++++++++ src/Simplex/Chat/View.hs | 9 +++- tests/ChatTests.hs | 37 ++++++++++++++ 6 files changed, 169 insertions(+), 22 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index ae43c2ac0e..764170f276 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -64,7 +64,7 @@ import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkIO, threadDelay) -import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory) +import UnliftIO.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory, removeFile, removePathForcibly) import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell) import UnliftIO.STM @@ -118,7 +118,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} + filesFolder <- newTVarIO Nothing + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification, filesFolder} where resolveServers :: IO (NonEmpty SMPServer) resolveServers = case user of @@ -172,6 +173,11 @@ processChatCommand = \case asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning _ -> startChatController user $> CRChatStarted + SetFilesFolder filesFolder' -> withUser' $ \_ -> do + createDirectoryIfMissing True filesFolder' + ff <- asks filesFolder + atomically . writeTVar ff $ Just filesFolder' + pure CRCmdOk APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user)) APIGetChat cType cId pagination -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) @@ -291,13 +297,15 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId + (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do + deleteFile file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) + deleteFile file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -317,6 +325,10 @@ processChatCommand = \case pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError "not supported" + where + deleteFile :: MsgDirectionI d => Maybe (CIFile d) -> m () + deleteFile (Just CIFile {fileId, filePath, fileStatus}) = deleteFiles [(fileId, filePath, AFS msgDirection fileStatus)] + deleteFile Nothing = pure () APIChatRead cType chatId fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -326,8 +338,10 @@ processChatCommand = \case ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do + files <- withStore $ \st -> getContactFiles st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do + deleteFiles files withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId ct @@ -641,8 +655,9 @@ processChatCommand = \case cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: FilePath -> m (Integer, Integer) checkSndFile f = do - unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f - (,) <$> getFileSize f <*> asks (fileChunkSize . config) + fsFilePath <- toFSFilePath f + unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f + (,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config) updateProfile :: User -> Profile -> m ChatResponse updateProfile user@User {profile = p} p'@Profile {displayName} | p' == p = pure CRUserProfileNoChange @@ -660,6 +675,33 @@ processChatCommand = \case let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady +-- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), +-- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path +-- used during file transfer for actual operations with file system +toFSFilePath :: ChatMonad m => FilePath -> m FilePath +toFSFilePath f = do + ff <- asks filesFolder + readTVarIO ff >>= \case + Nothing -> pure f + Just filesFolder -> pure $ filesFolder <> "/" <> f + +deleteFiles :: ChatMonad m => [(Int64, Maybe FilePath, ACIFileStatus)] -> m () +deleteFiles files = do + ff <- asks filesFolder + readTVarIO ff >>= \case + Nothing -> pure () -- only delete files if filesFolder is set (i.e. on mobile devices) + Just filesFolder -> + forM_ files $ \(fileId, filePath_, status) -> do + case status of + AFS _ CIFSRcvTransfer -> closeFileHandle fileId rcvFiles + _ -> pure () + case filePath_ of + Just filePath -> do + let fsFilePath = filesFolder <> "/" <> filePath + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> + removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + Nothing -> pure () + acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName @@ -697,10 +739,17 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F getRcvFilePath :: Maybe FilePath -> String -> m FilePath getRcvFilePath fPath_ fn = case fPath_ of Nothing -> do - dir <- (`combine` "Downloads") <$> getHomeDirectory - ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory - >>= (`uniqueCombine` fn) - >>= createEmptyFile + ff <- asks filesFolder + readTVarIO ff >>= \case + Nothing -> do + dir <- (`combine` "Downloads") <$> getHomeDirectory + ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory + >>= (`uniqueCombine` fn) + >>= createEmptyFile + Just filesFolder -> + filesFolder `uniqueCombine` fn + >>= createEmptyFile + >>= pure <$> takeFileName Just fPath -> ifM (doesDirectoryExist fPath) @@ -1109,11 +1158,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage then badRcvFileChunk ft "incorrect chunk size" else do appendFileChunk ft chunkNo chunk - withStore $ \st -> do + ci <- withStore $ \st -> do updateRcvFileStatus st ft FSComplete updateCIFileStatus st userId fileId CIFSRcvComplete deleteRcvFileChunks st ft - toView $ CRRcvFileComplete ft + getChatItemByFileId st user fileId + toView $ CRRcvFileComplete ci closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () @@ -1239,6 +1289,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do + -- TODO either allow to locally delete items that were broadcast deleted by sender, or delete attached files toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi checkIntegrity msgMeta $ toView . CRMsgIntegrityError @@ -1516,11 +1567,12 @@ sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do withStore $ \st -> updateSndFileChunkMsg st ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString -readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = - read_ `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) +readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do + fsFilePath <- toFSFilePath filePath + read_ fsFilePath `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) where - read_ = do - h <- getFileHandle fileId filePath sndFiles ReadMode + read_ fsFilePath = do + h <- getFileHandle fileId fsFilePath sndFiles ReadMode pos <- hTell h let pos' = (chunkNo - 1) * chunkSize when (pos /= pos') $ hSeek h AbsoluteSeek pos' @@ -1548,12 +1600,14 @@ parseFileChunk msg = appendFileChunk :: ChatMonad m => RcvFileTransfer -> Integer -> ByteString -> m () appendFileChunk ft@RcvFileTransfer {fileId, fileStatus} chunkNo chunk = case fileStatus of - RFSConnected RcvFileInfo {filePath} -> append_ filePath + RFSConnected RcvFileInfo {filePath} -> do + fsFilePath <- toFSFilePath filePath + append_ filePath fsFilePath RFSCancelled _ -> pure () _ -> throwChatError $ CEFileInternal "receiving file transfer not in progress" where - append_ fPath = do - h <- getFileHandle fileId fPath rcvFiles AppendMode + append_ fPath fPathUsed = do + h <- getFileHandle fileId fPathUsed rcvFiles AppendMode E.try (liftIO $ B.hPut h chunk >> hFlush h) >>= \case Left (e :: E.SomeException) -> throwChatError . CEFileWrite fPath $ show e Right () -> withStore $ \st -> updatedRcvFileChunkStored st ft chunkNo @@ -1815,6 +1869,7 @@ chatCommandP = ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat + <|> "/_files_folder " *> (SetFilesFolder <$> filePath) <|> "/_get chats" $> APIGetChats <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1842b632c9..dcd334c610 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -77,7 +77,8 @@ data ChatController = ChatController chatLock :: TMVar (), sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), - config :: ChatConfig + config :: ChatConfig, + filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps } data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages @@ -91,6 +92,7 @@ data ChatCommand = ShowActiveUser | CreateActiveUser Profile | StartChat + | SetFilesFolder FilePath | APIGetChats | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int @@ -200,7 +202,7 @@ data ChatResponse | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileStart {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileComplete {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileComplete {chatItem :: AChatItem} | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} | CRSndFileStart {sndFileTransfer :: SndFileTransfer} diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 9869b3692c..64d6587834 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Mobile where @@ -48,7 +49,7 @@ cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts - { dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db + { dbFilePrefix = undefined, smpServers = [], logConnections = False, logAgent = False, diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 19a7cfefb3..ba2219bf4d 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -115,6 +115,7 @@ module Simplex.Chat.Store updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, + getContactFiles, createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, @@ -135,6 +136,7 @@ module Simplex.Chat.Store getGroupChatItemBySharedMsgId, getDirectChatItemIdByText, getGroupChatItemIdByText, + getChatItemByFileId, updateDirectChatItemStatus, updateDirectChatItem, deleteDirectChatItemInternal, @@ -2214,6 +2216,19 @@ getFileTransferMeta_ db userId fileId = fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} +getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, Maybe FilePath, ACIFileStatus)] +getContactFiles st userId Contact {contactId} = + liftIO . withTransaction st $ \db -> + DB.query + db + [sql| + SELECT f.file_id, f.file_path, f.ci_file_status + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? + |] + (userId, contactId) + createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage createNewSndMessage st gVar connOrGroupId mkMessage = liftIOEither . withTransaction st $ \db -> @@ -3367,6 +3382,35 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c |] (userId, groupId, cName, quotedMsg <> "%") +getChatItemByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m AChatItem +getChatItemByFileId st user@User {userId} fileId = do + liftIOEither . withTransaction st $ \db -> runExceptT $ do + r <- ExceptT $ getChatItemIdByFileId_ db userId fileId + case r of + (itemId, Just contactId, Nothing) -> do + ct <- ExceptT $ getContact_ db userId contactId + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci + (itemId, Nothing, Just groupId) -> do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci + _ -> throwError $ SEChatItemNotFoundByFileId fileId + +getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, Maybe Int64, Maybe Int64)) +getChatItemIdByFileId_ db userId fileId = + firstRow id (SEChatItemNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.chat_item_id, i.contact_id, i.group_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + |] + (userId, fileId) + updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do currentTs <- liftIO getCurrentTime @@ -3608,6 +3652,7 @@ data StoreError | SEChatItemNotFound {itemId :: ChatItemId} | SEQuotedChatItemNotFound | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId} + | SEChatItemNotFoundByFileId {fileId :: FileTransferId} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c1d1d73a47..9a725a9936 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -100,7 +100,7 @@ responseToView testView = \case CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile CRRcvFileStart ft -> receivingFile_ "started" ft - CRRcvFileComplete ft -> receivingFile_ "completed" ft + CRRcvFileComplete ci -> receivingFile_' "completed" ci CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft CRSndFileStart ft -> sendingFile_ "started" ft CRSndFileComplete ft -> sendingFile_ "completed" ft @@ -548,6 +548,13 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 +receivingFile_' :: StyledString -> AChatItem -> [StyledString] +receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] +receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact m] +receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen + receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index f6875594e5..7abf2f6b56 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -66,6 +66,7 @@ chatTests = do describe "messages with files" $ do it "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage + it "send and receive image with files folders (for mobile)" testSendImageWithFilesFolders it "send and receive image with text and quote" testSendImageWithTextAndQuote it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote @@ -1261,6 +1262,42 @@ testSendImage = dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + -- deleting contact without files folder set should not remove file + bob ##> "/d alice" + bob <## "alice: contact is deleted" + fileExists <- doesFileExist "./tests/tmp/test.jpg" + fileExists `shouldBe` True + +testSendImageWithFilesFolders :: IO () +testSendImageWithFilesFolders = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/fixtures", id, "ok") + bob #$> ("/_files_folder ./tests/tmp", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "test.jpg")]) + -- deleting contact with files folder set should remove file + bob ##> "/d alice" + bob <## "alice: contact is deleted" + fileExists <- doesFileExist "./tests/tmp/test.jpg" + fileExists `shouldBe` False testSendImageWithTextAndQuote :: IO () testSendImageWithTextAndQuote = From 2058e904e60f96c2e1c4398bc450b68176736f1c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 15 Apr 2022 13:16:34 +0100 Subject: [PATCH 33/37] core: refactor files folder support (#532) Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- src/Simplex/Chat.hs | 116 ++++++++++++++++++++----------------- src/Simplex/Chat/Mobile.hs | 1 - src/Simplex/Chat/Store.hs | 4 +- tests/ChatTests.hs | 92 ++++++++++++++++++++++++----- 4 files changed, 143 insertions(+), 70 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 764170f276..49c915aeb6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -64,7 +64,7 @@ import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkIO, threadDelay) -import UnliftIO.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory, removeFile, removePathForcibly) +import UnliftIO.Directory import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell) import UnliftIO.STM @@ -173,7 +173,7 @@ processChatCommand = \case asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning _ -> startChatController user $> CRChatStarted - SetFilesFolder filesFolder' -> withUser' $ \_ -> do + SetFilesFolder filesFolder' -> withUser $ \_ -> do createDirectoryIfMissing True filesFolder' ff <- asks filesFolder atomically . writeTVar ff $ Just filesFolder' @@ -300,12 +300,12 @@ processChatCommand = \case (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do - deleteFile file + deleteFile userId file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) - deleteFile file + deleteFile userId file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -313,22 +313,27 @@ processChatCommand = \case CTGroup -> do Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}} <- withStore $ \st -> getGroupChatItem st user chatId itemId + CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do + deleteFile userId file toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) + deleteFile userId file toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError "not supported" where - deleteFile :: MsgDirectionI d => Maybe (CIFile d) -> m () - deleteFile (Just CIFile {fileId, filePath, fileStatus}) = deleteFiles [(fileId, filePath, AFS msgDirection fileStatus)] - deleteFile Nothing = pure () + deleteFile :: MsgDirectionI d => UserId -> Maybe (CIFile d) -> m () + deleteFile userId file = + forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do + cancelFiles userId [(fileId, AFS msgDirection fileStatus)] + withFilesFolder $ \filesFolder -> + deleteFiles filesFolder [filePath] APIChatRead cType chatId fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -341,7 +346,9 @@ processChatCommand = \case files <- withStore $ \st -> getContactFiles st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do - deleteFiles files + cancelFiles userId (map (\(fId, fStatus, _) -> (fId, fStatus)) files) + withFilesFolder $ \filesFolder -> do + deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId ct @@ -592,24 +599,8 @@ processChatCommand = \case ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft e -> throwError e CancelFile fileId -> withUser $ \User {userId} -> do - ft' <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ do - case ft' of - FTSnd ftm fts -> do - cancelFileTransfer userId ft' CIFSSndCancelled - forM_ fts $ \ft -> cancelSndFileTransfer ft - pure $ CRSndGroupFileCancelled ftm fts - FTRcv ft -> do - cancelFileTransfer userId ft' CIFSRcvCancelled - cancelRcvFileTransfer ft - pure $ CRRcvFileCancelled ft - where - cancelFileTransfer :: MsgDirectionI d => UserId -> FileTransfer -> CIFileStatus d -> m () - cancelFileTransfer userId ft ciFileStatus = - unless (fileTransferCancelled ft) $ - withStore $ \st -> do - updateFileCancelled st userId fileId - updateCIFileStatus st userId fileId ciFileStatus + ft <- withStore (\st -> getFileTransfer st userId fileId) + withChatLock . procCmd $ cancelFile userId fileId ft FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile @@ -674,33 +665,53 @@ processChatCommand = \case isReady ct = let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady + -- perform an action only if filesFolder is set (i.e. on mobile devices) + withFilesFolder :: (FilePath -> m ()) -> m () + withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action + deleteFiles :: FilePath -> [Maybe FilePath] -> m () + deleteFiles filesFolder filePaths = + forM_ filePaths $ \filePath_ -> + forM_ filePath_ $ \filePath -> do + let fsFilePath = filesFolder <> "/" <> filePath + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> + removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + cancelFiles :: UserId -> [(Int64, ACIFileStatus)] -> m () + cancelFiles userId files = + forM_ files $ \(fileId, status) -> do + case status of + AFS _ CIFSSndStored -> cancelById fileId + AFS _ CIFSRcvInvitation -> cancelById fileId + AFS _ CIFSRcvTransfer -> cancelById fileId + _ -> pure () + where + cancelById fileId = do + ft <- withStore (\st -> getFileTransfer st userId fileId) + void $ cancelFile userId fileId ft + cancelFile :: UserId -> Int64 -> FileTransfer -> m ChatResponse + cancelFile userId fileId ft = + case ft of + FTSnd ftm fts -> do + cancelFileTransfer CIFSSndCancelled + forM_ fts $ \ft' -> cancelSndFileTransfer ft' + pure $ CRSndGroupFileCancelled ftm fts + FTRcv ftr -> do + cancelFileTransfer CIFSRcvCancelled + cancelRcvFileTransfer ftr + pure $ CRRcvFileCancelled ftr + where + cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () + cancelFileTransfer ciFileStatus = + unless (fileTransferCancelled ft) $ + withStore $ \st -> do + updateFileCancelled st userId fileId + updateCIFileStatus st userId fileId ciFileStatus -- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), -- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path -- used during file transfer for actual operations with file system toFSFilePath :: ChatMonad m => FilePath -> m FilePath -toFSFilePath f = do - ff <- asks filesFolder - readTVarIO ff >>= \case - Nothing -> pure f - Just filesFolder -> pure $ filesFolder <> "/" <> f - -deleteFiles :: ChatMonad m => [(Int64, Maybe FilePath, ACIFileStatus)] -> m () -deleteFiles files = do - ff <- asks filesFolder - readTVarIO ff >>= \case - Nothing -> pure () -- only delete files if filesFolder is set (i.e. on mobile devices) - Just filesFolder -> - forM_ files $ \(fileId, filePath_, status) -> do - case status of - AFS _ CIFSRcvTransfer -> closeFileHandle fileId rcvFiles - _ -> pure () - case filePath_ of - Just filePath -> do - let fsFilePath = filesFolder <> "/" <> filePath - removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> - removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () - Nothing -> pure () +toFSFilePath f = + maybe f (<> "/" <> f) <$> (readTVarIO =<< asks filesFolder) acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do @@ -738,9 +749,8 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = F where getRcvFilePath :: Maybe FilePath -> String -> m FilePath getRcvFilePath fPath_ fn = case fPath_ of - Nothing -> do - ff <- asks filesFolder - readTVarIO ff >>= \case + Nothing -> + asks filesFolder >>= readTVarIO >>= \case Nothing -> do dir <- (`combine` "Downloads") <$> getHomeDirectory ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory @@ -1289,7 +1299,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do - -- TODO either allow to locally delete items that were broadcast deleted by sender, or delete attached files + -- TODO allow to locally delete items that were broadcast deleted by sender toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi checkIntegrity msgMeta $ toView . CRMsgIntegrityError diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 64d6587834..b830860ef5 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -2,7 +2,6 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Mobile where diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index ba2219bf4d..bb10843521 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -2216,13 +2216,13 @@ getFileTransferMeta_ db userId fileId = fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} -getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, Maybe FilePath, ACIFileStatus)] +getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, ACIFileStatus, Maybe FilePath)] getContactFiles st userId Contact {contactId} = liftIO . withTransaction st $ \db -> DB.query db [sql| - SELECT f.file_id, f.file_path, f.ci_file_status + SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? AND i.contact_id = ? diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 7abf2f6b56..dc49a12808 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -15,7 +15,7 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) import Simplex.Chat.Types (ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) -import System.Directory (doesFileExist) +import System.Directory (copyFile, doesFileExist) import Test.Hspec aliceProfile :: Profile @@ -66,7 +66,9 @@ chatTests = do describe "messages with files" $ do it "send and receive message with file" testMessageWithFile it "send and receive image" testSendImage - it "send and receive image with files folders (for mobile)" testSendImageWithFilesFolders + it "files folder: send and receive image" testFilesFoldersSendImage + it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete + it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete it "send and receive image with text and quote" testSendImageWithTextAndQuote it "send and receive image to group" testGroupSendImage it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote @@ -1038,8 +1040,6 @@ testFileRcvCancel = alice <## "sending file 1 (test.jpg) cancelled: bob" ] checkPartialTransfer - where - waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f testGroupFileTransfer :: IO () testGroupFileTransfer = @@ -1167,8 +1167,6 @@ testFileRcvCancelV2 = alice <## "sending file 1 (test.jpg) cancelled: bob" ] checkPartialTransfer - where - waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f testGroupFileTransferV2 :: IO () testGroupFileTransferV2 = @@ -1268,13 +1266,13 @@ testSendImage = fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists `shouldBe` True -testSendImageWithFilesFolders :: IO () -testSendImageWithFilesFolders = +testFilesFoldersSendImage :: IO () +testFilesFoldersSendImage = testChat2 aliceProfile bobProfile $ \alice bob -> do connectUsers alice bob alice #$> ("/_files_folder ./tests/fixtures", id, "ok") - bob #$> ("/_files_folder ./tests/tmp", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" alice <# "/f @bob test.jpg" alice <## "use /fc 1 to cancel sending" @@ -1289,15 +1287,70 @@ testSendImageWithFilesFolders = (bob <## "completed receiving file 1 (test.jpg) from alice") (alice <## "completed sending file 1 (test.jpg) to bob") src <- B.readFile "./tests/fixtures/test.jpg" - dest <- B.readFile "./tests/tmp/test.jpg" + dest <- B.readFile "./tests/tmp/app_files/test.jpg" dest `shouldBe` src alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "test.jpg")]) bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "test.jpg")]) -- deleting contact with files folder set should remove file - bob ##> "/d alice" - bob <## "alice: contact is deleted" - fileExists <- doesFileExist "./tests/tmp/test.jpg" - fileExists `shouldBe` False + checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do + bob ##> "/d alice" + bob <## "alice: contact is deleted" + +testFilesFoldersImageSndDelete :: IO () +testFilesFoldersImageSndDelete = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") + copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" + bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + -- deleting contact should cancel and remove file + checkActionDeletesFile "./tests/tmp/alice_app_files/test.jpg" $ do + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice cancelled sending file 1 (test.jpg)" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: test.jpg" + -- deleting contact should remove cancelled file + checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do + bob ##> "/d alice" + bob <## "alice: contact is deleted" + +testFilesFoldersImageRcvDelete :: IO () +testFilesFoldersImageRcvDelete = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/fixtures", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + -- deleting contact should cancel and remove file + waitFileExists "./tests/tmp/app_files/test.jpg" + checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob cancelled receiving file 1 (test.jpg)" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" testSendImageWithTextAndQuote :: IO () testSendImageWithTextAndQuote = @@ -1709,6 +1762,17 @@ checkPartialTransfer = do B.unpack src `shouldStartWith` B.unpack dest B.length src > B.length dest `shouldBe` True +checkActionDeletesFile :: FilePath -> IO () -> IO () +checkActionDeletesFile file action = do + fileExistsBefore <- doesFileExist file + fileExistsBefore `shouldBe` True + action + fileExistsAfter <- doesFileExist file + fileExistsAfter `shouldBe` False + +waitFileExists :: FilePath -> IO () +waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f + connectUsers :: TestCC -> TestCC -> IO () connectUsers cc1 cc2 = do name1 <- showName cc1 From d201c9528a49fcaf335c6087bf540119517149e7 Mon Sep 17 00:00:00 2001 From: IanRDavies Date: Sat, 16 Apr 2022 09:29:29 +0100 Subject: [PATCH 34/37] android: i18n (#529) * internationalization framework * rearrange strings * typo * minor id & xliff changes * response to comments * colour comments and verb suffixes * add russian language file * fix interpolation error * final strings * russian translations * update Russian translations, refactor strings to full sentences, add prefixes to content description names * fix layouts, improve font spacing * split sentence about User address, font line height * typo Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update Russian translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * remove an * update Russian translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * commas Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- .../java/chat/simplex/app/MainActivity.kt | 20 +- .../main/java/chat/simplex/app/SimplexApp.kt | 6 +- .../java/chat/simplex/app/model/ChatModel.kt | 30 +-- .../java/chat/simplex/app/model/SimpleXAPI.kt | 44 ++-- .../java/chat/simplex/app/ui/theme/Color.kt | 2 +- .../java/chat/simplex/app/ui/theme/Theme.kt | 4 +- .../chat/simplex/app/views/TerminalView.kt | 4 +- .../chat/simplex/app/views/WelcomeView.kt | 21 +- .../simplex/app/views/chat/ChatInfoView.kt | 18 +- .../chat/simplex/app/views/chat/ChatView.kt | 4 +- .../simplex/app/views/chat/ComposeView.kt | 2 +- .../simplex/app/views/chat/ContextItemView.kt | 4 +- .../simplex/app/views/chat/SendMsgView.kt | 8 +- .../simplex/app/views/chat/item/CIMetaView.kt | 12 +- .../app/views/chat/item/ChatItemView.kt | 20 +- .../app/views/chat/item/DeletedItemView.kt | 9 +- .../app/views/chatlist/ChatHelpView.kt | 70 ++---- .../app/views/chatlist/ChatListNavLinkView.kt | 13 +- .../app/views/chatlist/ChatListView.kt | 22 +- .../app/views/chatlist/ChatPreviewView.kt | 6 +- .../app/views/chatlist/ContactRequestView.kt | 4 +- .../simplex/app/views/helpers/AlertManager.kt | 7 +- .../app/views/helpers/ChatInfoImage.kt | 5 +- .../app/views/helpers/CloseSheetBar.kt | 3 +- .../simplex/app/views/helpers/GetImageView.kt | 10 +- .../simplex/app/views/helpers/LinkPreviews.kt | 7 +- .../simplex/app/views/helpers/ModalView.kt | 3 +- .../chat/simplex/app/views/helpers/Util.kt | 159 ++++++++++++- .../app/views/newchat/AddContactView.kt | 19 +- .../app/views/newchat/ConnectContactView.kt | 41 ++-- .../simplex/app/views/newchat/NewChatSheet.kt | 21 +- .../chat/simplex/app/views/newchat/QRCode.kt | 4 +- .../app/views/usersettings/HelpView.kt | 13 +- .../views/usersettings/MarkdownHelpView.kt | 31 ++- .../app/views/usersettings/SMPServers.kt | 28 +-- .../app/views/usersettings/SettingsView.kt | 48 ++-- .../app/views/usersettings/UserAddressView.kt | 25 +- .../app/views/usersettings/UserProfileView.kt | 25 +- .../app/src/main/res/values-ru/strings.xml | 218 +++++++++++++++++ .../app/src/main/res/values/strings.xml | 219 +++++++++++++++++- 40 files changed, 896 insertions(+), 313 deletions(-) create mode 100644 apps/android/app/src/main/res/values-ru/strings.xml diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 2435f012bd..1b08da4697 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -25,9 +25,9 @@ import chat.simplex.app.views.WelcomeView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.openChat -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.newchat.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.connectViaUri +import chat.simplex.app.views.newchat.withUriAction import java.util.concurrent.TimeUnit //import kotlinx.serialization.decodeFromString @@ -122,10 +122,18 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { chatModel.appOpenUrl.value = uri } else { withUriAction(uri) { action -> + val title = when (action) { + "contact" -> generalGetString(R.string.connect_via_contact_link) + "invitation" -> generalGetString(R.string.connect_via_invitation_link) + else -> { + Log.e(TAG, "URI has unexpected action. Alert shown.") + action + } + } AlertManager.shared.showAlertMsg( - title = "Connect via $action link?", - text = "Your profile will be sent to the contact that you received this link from.", - confirmText = "Connect", + title = title, + text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link), + confirmText = generalGetString(R.string.connect_via_link_verb), onConfirm = { withApi { Log.d(TAG, "connectIfOpenedViaUri: connecting") diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 5506c72daf..73397f1908 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -41,6 +41,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onCreate() { super.onCreate() + context = this ProcessLifecycleOwner.get().lifecycle.addObserver(this) withApi { val user = chatController.apiGetActiveUser() @@ -65,9 +66,10 @@ class SimplexApp: Application(), LifecycleEventObserver { } companion object { + lateinit var context: SimplexApp private set + init { val socketName = "local.socket.address.listen.native.cmd2" - val s = Semaphore(0) thread(name="stdout/stderr pipe") { Log.d(TAG, "starting server") @@ -82,7 +84,7 @@ class SimplexApp: Application(), LifecycleEventObserver { val inStreamReader = InputStreamReader(inStream) val input = BufferedReader(inStreamReader) - while(true) { + while (true) { val line = input.readLine() ?: break Log.w("$TAG (stdout/stderr)", line) logbuffer.add(line) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index a7e0bada62..8a4dbe9235 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -7,8 +7,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.app.R import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -267,12 +269,12 @@ data class Chat ( @Serializable sealed class NetworkStatus { - val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…" + val statusString: String get() = if (this is Connected) generalGetString(R.string.server_connected) else generalGetString(R.string.server_connecting) val statusExplanation: String get() = - when { - this is Connected -> "You are connected to the server used to receive messages from this contact." - this is Error -> "Trying to connect to the server used to receive messages from this contact (error: $error)." - else -> "Trying to connect to the server used to receive messages from this contact." + when (this) { + is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) + is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) + else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @@ -568,7 +570,7 @@ data class ChatItem ( id: Long = 1, dir: CIDirection = CIDirection.DirectRcv(), ts: Instant = Clock.System.now(), - text: String = "this item is deleted", + text: String = "this item is deleted", // sample not localized status: CIStatus = CIStatus.RcvRead() ) = ChatItem( @@ -694,25 +696,25 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = "deleted" + override val text get() = generalGetString(R.string.deleted_description) override val msgContent get() = null } @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = "deleted" + override val text get() = generalGetString(R.string.deleted_description) override val msgContent get() = null } @Serializable @SerialName("sndFileInvitation") class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { - override val text get() = "sending files is not supported yet" + override val text get() = generalGetString(R.string.sending_files_not_yet_supported) override val msgContent get() = null } @Serializable @SerialName("rcvFileInvitation") class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { - override val text get() = "receiving files is not supported yet" + override val text get() = generalGetString(R.string.receiving_files_not_yet_supported) override val msgContent get() = null } } @@ -729,7 +731,7 @@ class CIQuote ( override val text: String get() = content.text fun sender(user: User): String? = when (chatDir) { - is CIDirection.DirectSnd -> "you" + is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun) is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> user.displayName is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName @@ -782,7 +784,7 @@ object MsgContentSerializer : KSerializer { return if (json is JsonObject) { if ("type" in json) { val t = json["type"]?.jsonPrimitive?.content ?: "" - val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format" + val text = json["text"]?.jsonPrimitive?.content ?: generalGetString(R.string.unknown_message_format) when (t) { "text" -> MsgContent.MCText(text) "link" -> { @@ -792,10 +794,10 @@ object MsgContentSerializer : KSerializer { else -> MsgContent.MCUnknown(t, text, json) } } else { - MsgContent.MCUnknown(text = "invalid message format", json = json) + MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json) } } else { - MsgContent.MCUnknown(text = "invalid message format", json = json) + MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e605b53efd..0234dfce44 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* +import chat.simplex.app.R import chat.simplex.app.views.helpers.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -182,8 +183,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt else -> { Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( - "Error saving SMP servers", - "Make sure SMP server addresses are in correct format, line separated and are not duplicated." + generalGetString(R.string.error_saving_smp_servers), + generalGetString(R.string.ensure_smp_server_address_are_correct_format_and_unique) ) false } @@ -202,15 +203,17 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { - AlertManager.shared.showAlertMsg("Contact already exists", - "You are already connected to ${r.contact.displayName} via this link." + AlertManager.shared.showAlertMsg( + generalGetString(R.string.contact_already_exists), + String.format(generalGetString(R.string.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) return false } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { - AlertManager.shared.showAlertMsg("Invalid connection link", - "Please check that you used the correct link or ask your contact to send you another one." + AlertManager.shared.showAlertMsg( + generalGetString(R.string.invalid_connection_link), + generalGetString(R.string.please_check_correct_link_and_maybe_ask_for_a_new_one) ) return false } @@ -229,8 +232,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val e = r.chatError if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { AlertManager.shared.showAlertMsg( - "Can't delete contact!", - "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}." + generalGetString(R.string.cannot_delete_contact), + String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames) ) } } @@ -410,35 +413,22 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Row { Icon( Icons.Outlined.Bolt, - contentDescription = "Instant notifications", + contentDescription = generalGetString(R.string.icon_descr_instant_notifications), ) - Text("Private instant notifications!", fontWeight = FontWeight.Bold) + Text(generalGetString(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { Column { Text( - buildAnnotatedString { - append("To preserve your privacy, instead of push notifications the app has a ") - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { - append("SimpleX background service") - } - append(" – it uses a few percent of the battery per day.") - }, + annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), Modifier.padding(bottom = 8.dp) ) - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { - append("It can be disabled via settings") - } - append(" – notifications will still be shown while the app is running.") - } - ) + Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)) } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text("Ok") } + Button(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(R.string.ok)) } } ) } @@ -716,7 +706,7 @@ sealed class CR { is Invalid -> str } - fun noDetails(): String ="${responseType}: no details" + fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details) } abstract class TerminalItem { diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 3f4b1c6abf..7aeecca387 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -7,7 +7,7 @@ val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) val Gray = Color(0x22222222) -val SimplexBlue = Color(0, 136, 255, 255) +val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files val SimplexGreen = Color(98, 196, 103, 255) val SecretColor = Color(0x40808080) val LightGray = Color(241, 242, 246, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt index 3719e860a6..8b1ccfeded 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -5,7 +5,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable private val DarkColorPalette = darkColors( - primary = SimplexBlue, + primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = DarkGray, // background = Color.Black, @@ -20,7 +20,7 @@ private val DarkColorPalette = darkColors( // onError: Color = Color.Black, ) private val LightColorPalette = lightColors( - primary = SimplexBlue, + primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = LightGray, // background = Color.White, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 8f828f4e19..2042ef469e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -19,9 +19,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.SendMsgView -import chat.simplex.app.views.helpers.CloseSheetBar -import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.launch diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 996b0bd2e1..675655bb9d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -17,6 +17,7 @@ import chat.simplex.app.R import chat.simplex.app.SimplexService import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.withApi import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding @@ -39,22 +40,22 @@ fun WelcomeView(chatModel: ChatModel) { ) { Image( painter = painterResource(R.drawable.logo), - contentDescription = "Simplex Logo", + contentDescription = generalGetString(R.string.image_descr_simplex_logo), modifier = Modifier.padding(vertical = 15.dp) ) Text( - "You control your chat!", + generalGetString(R.string.you_control_your_chat), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground ) Text( - "The messaging and application platform protecting your privacy and security.", + generalGetString(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(8.dp)) Text( - "We don't store any of your contacts or messages (once delivered) on the servers.", + generalGetString(R.string.we_do_not_store_contacts_or_messages_on_servers), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) @@ -79,19 +80,19 @@ fun CreateProfilePanel(chatModel: ChatModel) { modifier=Modifier.fillMaxSize() ) { Text( - "Create profile", + generalGetString(R.string.create_profile), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(vertical = 5.dp) ) Text( - "Your profile is stored on your device and shared only with your contacts.", + generalGetString(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(10.dp)) Text( - "Display Name", + generalGetString(R.string.display_name), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 3.dp) @@ -113,7 +114,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { ), singleLine = true ) - val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else "" + val errorText = if(!isValidDisplayName(displayName)) generalGetString(R.string.display_name_cannot_contain_whitespace) else "" Text( errorText, @@ -123,7 +124,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { Spacer(Modifier.height(3.dp)) Text( - "Full Name (Optional)", + generalGetString(R.string.full_name_optional__prompt), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 5.dp) @@ -157,6 +158,6 @@ fun CreateProfilePanel(chatModel: ChatModel) { } }, enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName)) - ) { Text("Create") } + ) { Text(generalGetString(R.string.create_profile_button)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index ed43311f96..d37c752cbe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -29,9 +30,9 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) { close = close, deleteContact = { AlertManager.shared.showAlertMsg( - title = "Delete contact?", - text = "Contact and all messages will be deleted - this cannot be undone!", - confirmText = "Delete", + title = generalGetString(R.string.delete_contact__question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), onConfirm = { val cInfo = chat.chatInfo withApi { @@ -96,7 +97,8 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { Box(Modifier.padding(48.dp)) { SimpleButton( - "Delete contact", icon = Icons.Outlined.Delete, + generalGetString(R.string.button_delete_contact), + icon = Icons.Outlined.Delete, color = Color.Red, click = deleteContact ) @@ -110,13 +112,13 @@ fun ServerImage(chat: Chat) { val status = chat.serverInfo.networkStatus when { status is Chat.NetworkStatus.Connected -> - Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant) + Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) status is Chat.NetworkStatus.Disconnected -> - Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight) + Icon(Icons.Filled.Pending, generalGetString(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) status is Chat.NetworkStatus.Error -> - Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight) + Icon(Icons.Filled.Error, generalGetString(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> - Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight) + Icon(Icons.Outlined.Circle, generalGetString(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 7c82f7659a..3ffab8066e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -22,13 +22,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ChatItemView import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.ModalManager import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* @@ -169,7 +169,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { IconButton(onClick = back) { Icon( Icons.Outlined.ArrowBackIos, - "Back", + generalGetString(R.string.back), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 2c1973b054..79e4b71ad4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,6 +1,6 @@ package chat.simplex.app.views.chat -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.* import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 3690e79883..8a16d670ec 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -12,10 +12,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -50,7 +52,7 @@ fun ContextItemView( }) { Icon( Icons.Outlined.Close, - contentDescription = "Cancel", + contentDescription = generalGetString(R.string.cancel_verb), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 7d1cb8d05b..7fcda1bd03 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -1,7 +1,6 @@ package chat.simplex.app.views.chat import android.content.res.Configuration -import android.util.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -21,13 +20,12 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.TAG +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.helpers.getLinkPreview -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay @Composable @@ -129,7 +127,7 @@ fun SendMsgView( val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray Icon( if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, - "Send Message", + generalGetString(R.string.icon_descr_send_message), tint = Color.White, modifier = Modifier .size(36.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index be21371f30..ca6e5a71c0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -12,9 +12,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -25,7 +27,7 @@ fun CIMetaView(chatItem: ChatItem) { Icon( Icons.Filled.Edit, modifier = Modifier.height(12.dp).padding(end = 1.dp), - contentDescription = "Edited", + contentDescription = generalGetString(R.string.icon_descr_edited), tint = HighOrLowlight, ) } @@ -45,16 +47,16 @@ fun CIMetaView(chatItem: ChatItem) { fun CIStatusView(status: CIStatus) { when (status) { is CIStatus.SndSent -> { - Icon(Icons.Filled.Check, "sent", Modifier.height(12.dp), tint = HighOrLowlight) + Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = HighOrLowlight) } is CIStatus.SndErrorAuth -> { - Icon(Icons.Filled.Close, "unauthorized send", Modifier.height(12.dp), tint = Color.Red) + Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) } is CIStatus.SndError -> { - Icon(Icons.Filled.WarningAmber, "send failed", Modifier.height(12.dp), tint = Color.Yellow) + Icon(Icons.Filled.WarningAmber, generalGetString(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) } is CIStatus.RcvNew -> { - Icon(Icons.Filled.Circle, "unread", Modifier.height(12.dp), tint = SimplexBlue) + Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) } else -> {} } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index dfce857f10..f067e6f8df 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -16,8 +16,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -55,21 +55,21 @@ fun ChatItemView( } if (cItem.isMsgContent) { DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - ItemAction("Reply", Icons.Outlined.Reply, onClick = { + ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = { editingItem.value = null quotedItem.value = cItem showMenu = false }) - ItemAction("Share", Icons.Outlined.Share, onClick = { + ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = { shareText(cxt, cItem.content.text) showMenu = false }) - ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = { + ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { copyText(cxt, cItem.content.text) showMenu = false }) if (cItem.chatDir.sent && cItem.meta.editable) { - ItemAction("Edit", Icons.Filled.Edit, onClick = { + ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = { quotedItem.value = null editingItem.value = cItem msg.value = cItem.content.text @@ -77,7 +77,7 @@ fun ChatItemView( }) } ItemAction( - "Delete", + generalGetString(R.string.delete_verb), Icons.Outlined.Delete, onClick = { showMenu = false @@ -109,8 +109,8 @@ private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, col fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialogButtons( - title = "Delete message?", - text = "Message will be deleted - this cannot be undone!", + title = generalGetString(R.string.delete_message__question), + text = generalGetString(R.string.delete_message_cannot_be_undone_warning), buttons = { Row( Modifier @@ -121,13 +121,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() - }) { Text("For me only") } + }) { Text(generalGetString(R.string.for_me_only)) } // if (chatItem.meta.editable) { // Spacer(Modifier.padding(horizontal = 4.dp)) // Button(onClick = { // deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) // AlertManager.shared.hideAlert() -// }) { Text("For everyone") } +// }) { Text(generalGetString(R.string.for_everybody)) } // } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt index 849550e557..5ed2f39f2b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt @@ -1,9 +1,8 @@ package chat.simplex.app.views.chat.item import android.content.res.Configuration -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -11,10 +10,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.tooling.preview.* +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.* +import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index 306797e628..aa03a7d0cf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.app.views.chatlist import android.content.res.Configuration import androidx.compose.foundation.clickable @@ -10,11 +10,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.* +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.annotatedStringResource +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.usersettings.simplexTeamUri val bold = SpanStyle(fontWeight = FontWeight.Bold) @@ -27,18 +31,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { ) { val uriHandler = LocalUriHandler.current - Text("Thank you for installing SimpleX Chat!") + Text(generalGetString(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) Text( - buildAnnotatedString { - append("You can ") - withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { - append("connect to SimpleX Chat founder") - } - append(".") - }, + annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder), modifier = Modifier.clickable(onClick = { uriHandler.openUri(simplexTeamUri) - }) + }), + lineHeight = 22.sp ) Column( @@ -47,33 +46,24 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - "To start a new chat", - style = MaterialTheme.typography.h2 + generalGetString(R.string.to_start_a_new_chat_help_header), + style = MaterialTheme.typography.h2, + lineHeight = 22.sp ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Tap button") + Text(generalGetString(R.string.chat_help_tap_button)) Icon( Icons.Outlined.PersonAdd, - "Add Contact", + generalGetString(R.string.add_contact), modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier, ) - Text("above, then:") + Text(generalGetString(R.string.above_then_preposition_continuation)) } - Text( - buildAnnotatedString { - withStyle(bold) { append("Add new contact") } - append(": to create your one-time QR Code for your contact.") - } - ) - Text( - buildAnnotatedString { - withStyle(bold) { append("Scan QR code") } - append(": to connect to your contact who shows QR code to you.") - } - ) + Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp) } Column( @@ -81,24 +71,10 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text("To connect via link", style = MaterialTheme.typography.h2) - Text("If you received SimpleX Chat invitation link you can open it in your browser:") - Text( - buildAnnotatedString { - append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ") - withStyle(bold) { append("Scan QR code") } - append(".") - } - ) - Text( - buildAnnotatedString { - append("\uD83D\uDCF1 mobile: tap ") - withStyle(bold) { append("Open in mobile app") } - append(", then tap ") - withStyle(bold) { append("Connect") } - append(" in the app.") - } - ) + Text(generalGetString(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) + Text(generalGetString(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp) } } } @@ -112,6 +88,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { @Composable fun PreviewChatHelpLayout() { SimpleXTheme { - ChatHelpView({}) + ChatHelpView {} } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index fbd8b235a5..f707e62c8b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -11,11 +11,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp - +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @Composable @@ -42,9 +41,9 @@ suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) { fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( - title = "Accept connection request?", - text = "If you choose to reject sender will NOT be notified.", - confirmText = "Accept", + title = generalGetString(R.string.accept_connection_request__question), + text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified), + confirmText = generalGetString(R.string.accept_contact_button), onConfirm = { withApi { val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId) @@ -54,7 +53,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel } } }, - dismissText = "Reject", + dismissText = generalGetString(R.string.reject_contact_button), onDismiss = { withApi { chatModel.controller.apiRejectContactRequest(contactRequest.apiId) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 1f1c6b912e..704fcd4df9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -14,11 +14,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight -import chat.simplex.app.views.chat.ChatHelpView -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.usersettings.SettingsView import kotlinx.coroutines.CoroutineScope @@ -110,25 +111,28 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { .fillMaxWidth() .padding(16.dp) ) { + val welcomeMsg = if (displayName != null) { + String.format(generalGetString(R.string.personal_welcome), displayName) + } else generalGetString(R.string.welcome) Text( - text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!", + text = welcomeMsg, Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) - ChatHelpView({ scaffoldCtrl.toggleSheet() }) + ChatHelpView { scaffoldCtrl.toggleSheet() } Row( Modifier.padding(top = 30.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "This text is available in settings", + generalGetString(R.string.this_text_is_available_in_settings), color = MaterialTheme.colors.onBackground ) Icon( Icons.Outlined.Settings, - "Settings", + generalGetString(R.string.icon_descr_settings), tint = MaterialTheme.colors.onBackground, modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() }) ) @@ -150,13 +154,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { Icon( Icons.Outlined.Menu, - "Settings", + generalGetString(R.string.icon_descr_settings), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) } Text( - "Your chats", + generalGetString(R.string.your_chats), color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) @@ -164,7 +168,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( Icons.Outlined.PersonAdd, - "Add Contact", + generalGetString(R.string.add_contact), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 45f7fe5322..81b10548be 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -14,13 +14,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.ChatInfoImage -import chat.simplex.app.views.helpers.badgeLayout +import chat.simplex.app.views.helpers.* @Composable fun ChatPreviewView(chat: Chat) { @@ -63,7 +63,7 @@ fun ChatPreviewView(chat: Chat) { val n = chat.chatStats.unreadCount if (n > 0) { Text( - if (n < 1000) "$n" else "${n / 1000}k", + if (n < 1000) "$n" else "${n / 1000}" + generalGetString(R.string.thousand_abbreviation), color = MaterialTheme.colors.onPrimary, fontSize = 14.sp, modifier = Modifier diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 4cfa5c48c9..08a7b46795 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -8,10 +8,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage +import chat.simplex.app.views.helpers.generalGetString @Composable fun ContactRequestView(chat: Chat) { @@ -31,7 +33,7 @@ fun ContactRequestView(chat: Chat) { color = MaterialTheme.colors.primary ) Text( - "wants to connect to you!", + generalGetString(R.string.contact_wants_to_connect_with_you), maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index a746df1925..7e22b00d2d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf +import chat.simplex.app.R import chat.simplex.app.TAG class AlertManager { @@ -40,9 +41,9 @@ class AlertManager { fun showAlertDialog( title: String, text: String? = null, - confirmText: String = "Ok", + confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null, - dismissText: String = "Cancel", + dismissText: String = generalGetString(R.string.cancel_verb), onDismiss: (() -> Unit)? = null ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } @@ -69,7 +70,7 @@ class AlertManager { fun showAlertMsg( title: String, text: String? = null, - confirmText: String = "Ok", onConfirm: (() -> Unit)? = null + confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } showAlert { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index d5d582e9ed..3b92c1bf13 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme @@ -39,7 +40,7 @@ fun ProfileImage( if (image == null) { Icon( icon, - contentDescription = "profile image placeholder", + contentDescription = generalGetString(R.string.icon_descr_profile_image_placeholder), tint = MaterialTheme.colors.secondary, modifier = Modifier.fillMaxSize() ) @@ -47,7 +48,7 @@ fun ProfileImage( val imageBitmap = base64ToBitmap(image).asImageBitmap() Image( imageBitmap, - "profile image", + generalGetString(R.string.image_descr_profile_image), contentScale = ContentScale.Crop, modifier = Modifier.size(size).padding(size / 12).clip(CircleShape) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt index 0ee1884b67..2577ae9251 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme @Composable @@ -24,7 +25,7 @@ fun CloseSheetBar(close: () -> Unit) { IconButton(onClick = close) { Icon( Icons.Outlined.Close, - "Close button", + generalGetString(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 8c196e6ada..21b9166dbe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -27,8 +27,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import chat.simplex.app.BuildConfig -import chat.simplex.app.TAG +import chat.simplex.app.* +import chat.simplex.app.R import chat.simplex.app.views.newchat.ActionButton import java.io.ByteArrayOutputStream import java.io.File @@ -148,7 +148,7 @@ fun GetImageBottomSheet( else galleryLauncher.launch("image/*") hideBottomSheet() } else { - Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show() } } @@ -166,7 +166,7 @@ fun GetImageBottomSheet( .padding(horizontal = 8.dp, vertical = 30.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { - ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) { + ActionButton(null, generalGetString(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { cameraLauncher.launch(null) @@ -178,7 +178,7 @@ fun GetImageBottomSheet( } } } - ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) { + ActionButton(null, generalGetString(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { galleryLauncher.launch("image/*") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt index 102097457f..ec13b5dd27 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.LinkPreview import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme @@ -72,7 +73,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() Image( imageBitmap, - "preview image", + generalGetString(R.string.image_descr_link_preview), modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) ) Column(Modifier.fillMaxWidth().weight(1F)) { @@ -85,7 +86,7 @@ fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { Icon( Icons.Outlined.Close, - contentDescription = "Cancel Preview", + contentDescription = generalGetString(R.string.icon_descr_cancel_link_preview), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -98,7 +99,7 @@ fun ChatItemLinkView(linkPreview: LinkPreview) { Column { Image( base64ToBitmap(linkPreview.image).asImageBitmap(), - "link image", + generalGetString(R.string.image_descr_link_preview), modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index 298867bc8e..6349354c84 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.newchat +package chat.simplex.app.views.helpers import android.util.Log import androidx.activity.compose.BackHandler @@ -11,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.app.TAG -import chat.simplex.app.views.helpers.CloseSheetBar @Composable fun ModalView(close: () -> Unit, content: @Composable () -> Unit) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 454e880837..c1838b1256 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -1,9 +1,23 @@ package chat.simplex.app.views.helpers +import android.content.res.Resources import android.graphics.Rect +import android.graphics.Typeface +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* import android.view.ViewTreeObserver +import androidx.annotation.StringRes import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.* +import androidx.core.text.HtmlCompat +import chat.simplex.app.SimplexApp import kotlinx.coroutines.* fun withApi(action: suspend CoroutineScope.() -> Unit): Job = @@ -38,3 +52,146 @@ fun getKeyboardState(): State { return keyboardState } + +// Resource to annotated string from +// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources + +fun generalGetString(id: Int) : String { + return SimplexApp.context.getString(id) +} + +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} + +fun Spanned.toHtmlWithoutParagraphs(): String { + return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .substringAfter("

").substringBeforeLast("

") +} + +fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence { + val escapedArgs = args.map { + if (it is Spanned) it.toHtmlWithoutParagraphs() else it + }.toTypedArray() + val resource = SpannedString(getText(id)) + val htmlResource = resource.toHtmlWithoutParagraphs() + val formattedHtml = String.format(htmlResource, *escapedArgs) + return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) +} + +@Composable +fun annotatedStringResource(@StringRes id: Int): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id) { + val text = resources.getText(id) + spannableStringToAnnotatedString(text, density) + } +} + +private fun spannableStringToAnnotatedString( + text: CharSequence, + density: Density, +): AnnotatedString { + return if (text is Spanned) { + with(density) { + buildAnnotatedString { + append((text.toString())) + text.getSpans(0, text.length, Any::class.java).forEach { + val start = text.getSpanStart(it) + val end = text.getSpanEnd(it) + when (it) { + is StyleSpan -> when (it.style) { + Typeface.NORMAL -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + ), + start, + end + ) + Typeface.BOLD -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ), + start, + end + ) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start, + end + ) + } + is TypefaceSpan -> addStyle( + SpanStyle( + fontFamily = when (it.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default + } + ), + start, + end + ) + is AbsoluteSizeSpan -> addStyle( + SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), + start, + end + ) + is RelativeSizeSpan -> addStyle( + SpanStyle(fontSize = it.sizeChange.em), + start, + end + ) + is StrikethroughSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + is SubscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(it.foregroundColor)), + start, + end + ) + else -> addStyle(SpanStyle(color = Color.White), start, end) + } + } + } + } + } else { + AnnotatedString(text.toString()) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 545bf105f4..4e668e3580 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -10,15 +10,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -42,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { verticalArrangement = Arrangement.SpaceBetween, ) { Text( - "Add contact", + generalGetString(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - "Show QR code to your contact\nto scan from the app", + generalGetString(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, ) @@ -57,20 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { .padding(vertical = 3.dp) ) Text( - buildAnnotatedString { - append("If you cannot meet in person, you can ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append("scan QR code in the video call") - } - append(", or you can share the invitation link via any other channel.") - }, + generalGetString(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), textAlign = TextAlign.Center, - style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp), + lineHeight = 22.sp, modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share) + SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) Spacer(Modifier.height(10.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt index 06f34cb0a6..f65b000987 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt @@ -8,15 +8,15 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* @Composable fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { @@ -31,8 +31,8 @@ fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( - title = "Invalid QR code", - text = "This QR code is not a link!" + title = generalGetString(R.string.invalid_QR_code), + text = generalGetString(R.string.this_QR_code_is_not_a_link) ) } close() @@ -48,8 +48,8 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) { withApi { run(action) } } else { AlertManager.shared.showAlertMsg( - title = "Invalid link!", - text = "This link is not a valid connection link!" + title = generalGetString(R.string.invalid_contact_link), + text = generalGetString(R.string.this_link_is_not_a_valid_connection_link) ) } } @@ -57,12 +57,11 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) { suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) { val r = chatModel.controller.apiConnect(uri.toString()) if (r) { - val whenConnected = - if (action == "contact") "your connection request is accepted" - else "your contact's device is online" AlertManager.shared.showAlertMsg( - title = "Connection request sent!", - text = "You will be connected when $whenConnected, please wait or check later!" + title = generalGetString(R.string.connection_request_sent), + text = + if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted) + else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online) ) } } @@ -75,11 +74,11 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - "Scan QR code", + generalGetString(R.string.scan_QR_code), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - "Your chat profile will be sent\nto your contact", + generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 4.dp) @@ -90,18 +89,8 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni .aspectRatio(ratio = 1F) ) { qrCodeScanner() } Text( - buildAnnotatedString { - append("If you cannot meet in person, you can ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append("scan QR code in the video call") - } - append(", or you can create the invitation link.") - }, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.caption, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp) + annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link), + lineHeight = 22.sp ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 61d2400a01..2d3ff4924a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -14,11 +14,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ScaffoldController -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberPermissionState @Composable @@ -57,8 +58,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Add contact", "(create QR code\nor link)", - Icons.Outlined.PersonAdd, click = addContact + generalGetString(R.string.add_contact), + generalGetString(R.string.create_QR_code_or_link__bracketed__multiline), + Icons.Outlined.PersonAdd, + click = addContact ) } Box( @@ -66,8 +69,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Scan QR code", "(in person or in video call)", - Icons.Outlined.QrCode, click = scanCode + generalGetString(R.string.scan_QR_code), + generalGetString(R.string.in_person_or_in_video_call__bracketed), + Icons.Outlined.QrCode, + click = scanCode ) } Box( @@ -75,8 +80,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Create Group", "(coming soon!)", - Icons.Outlined.GroupAdd, disabled = true + generalGetString(R.string.create_group), + generalGetString(R.string.coming_soon__bracketed), + Icons.Outlined.GroupAdd, + disabled = true ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index cbc56268f4..f55489c7d7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.tooling.preview.Preview +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter @@ -16,7 +18,7 @@ import com.google.zxing.qrcode.QRCodeWriter fun QRCode(connReq: String, modifier: Modifier = Modifier) { Image( bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(), - contentDescription = "QR Code", + contentDescription = generalGetString(R.string.image_descr_qr_code), modifier = modifier ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt index 9d7d380c7a..dddb7bef80 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt @@ -3,6 +3,8 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -10,9 +12,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.ChatHelpView +import chat.simplex.app.views.chatlist.ChatHelpView +import chat.simplex.app.views.helpers.generalGetString @Composable fun HelpView(chatModel: ChatModel) { @@ -24,9 +28,12 @@ fun HelpView(chatModel: ChatModel) { @Composable fun HelpLayout(displayName: String) { - Column(horizontalAlignment = Alignment.Start) { + Column( + Modifier.verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start + ){ Text( - "Welcome $displayName!", + String.format(generalGetString(R.string.personal_welcome), displayName), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt index ef6e29a6d4..b1cb3e68f2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt @@ -10,29 +10,38 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Format import chat.simplex.app.model.FormatColor import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString @Composable fun MarkdownHelpView() { Column { Text( - "How to use markdown", + generalGetString(R.string.how_to_use_markdown), style = MaterialTheme.typography.h1, ) Text( - "You can use markdown to format messages:", + generalGetString(R.string.you_can_use_markdown_to_format_messages__prompt), Modifier.padding(vertical = 16.dp) ) - MdFormat("*bold*", "bold", Format.Bold()) - MdFormat("_italic_", "italic", Format.Italic()) - MdFormat("~strike~", "strike", Format.StrikeThrough()) - MdFormat("`a + b`", "a + b", Format.Snippet()) + val bold = generalGetString(R.string.bold) + val italic = generalGetString(R.string.italic) + val strikethrough = generalGetString(R.string.strikethrough) + val equation = generalGetString(R.string.a_plus_b) + val colored = generalGetString(R.string.colored) + val secret = generalGetString(R.string.secret) + + MdFormat("*$bold*", bold, Format.Bold()) + MdFormat("_${italic}_", italic, Format.Italic()) + MdFormat("~$strikethrough~", strikethrough, Format.StrikeThrough()) + MdFormat("`$equation`", equation, Format.Snippet()) Row { - MdSyntax("!1 colored!") + MdSyntax("!1 $colored!") Text(buildAnnotatedString { - withStyle(Format.Colored(FormatColor.red).style) { append("colored") } + withStyle(Format.Colored(FormatColor.red).style) { append(colored) } append(" (") appendColor(this, "1", FormatColor.red, ", ") appendColor(this, "2", FormatColor.green, ", ") @@ -43,10 +52,10 @@ fun MarkdownHelpView() { }) } Row { - MdSyntax("#secret#") + MdSyntax("#$secret#") SelectionContainer { Text(buildAnnotatedString { - withStyle(Format.Secret().style) { append("secret") } + withStyle(Format.Secret().style) { append(secret) } }) } } @@ -56,7 +65,7 @@ fun MarkdownHelpView() { @Composable fun MdSyntax(markdown: String) { Text(markdown, Modifier - .width(100.dp) + .width(120.dp) .padding(bottom = 4.dp)) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index f9a6b4c9ce..6668018792 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -20,11 +20,11 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* @Composable fun SMPServersView(chatModel: ChatModel) { @@ -60,9 +60,9 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers != null) { if (userSMPServers.isNotEmpty()) { AlertManager.shared.showAlertMsg( - title = "Use SimpleX Chat servers?", - text = "Saved SMP servers will be removed.", - confirmText = "Confirm", + title = generalGetString(R.string.use_simplex_chat_servers__question), + text = generalGetString(R.string.saved_SMP_servers_will_br_removed), + confirmText = generalGetString(R.string.confirm_verb), onConfirm = { saveSMPServers(listOf()) isUserSMPServers = false @@ -108,14 +108,14 @@ fun SMPServersLayout( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Your SMP servers", + generalGetString(R.string.your_SMP_servers), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) Row( verticalAlignment = Alignment.CenterVertically ) { - Text("Configure SMP servers", Modifier.padding(end = 24.dp)) + Text(generalGetString(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) Switch( checked = isUserSMPServers, onCheckedChange = isUserSMPServersOnOff, @@ -127,9 +127,9 @@ fun SMPServersLayout( } if (!isUserSMPServers) { - Text("Using SimpleX Chat servers.") + Text(generalGetString(R.string.using_simplex_chat_servers), lineHeight = 22.sp) } else { - Text("Enter one SMP server per line:") + Text(generalGetString(R.string.enter_one_SMP_server_per_line)) if (editSMPServers) { BasicTextField( value = userSMPServersStr, @@ -173,14 +173,14 @@ fun SMPServersLayout( Column(horizontalAlignment = Alignment.Start) { Row { Text( - "Cancel", + generalGetString(R.string.cancel_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = cancelEdit) ) Spacer(Modifier.padding(horizontal = 8.dp)) Text( - "Save", + generalGetString(R.string.save_servers_button), color = MaterialTheme.colors.primary, modifier = Modifier.clickable(onClick = { val servers = userSMPServersStr.split("\n") @@ -219,7 +219,7 @@ fun SMPServersLayout( ) { Column(horizontalAlignment = Alignment.Start) { Text( - "Edit", + generalGetString(R.string.edit_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = editOn) @@ -241,9 +241,9 @@ fun howToButton() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } ) { - Text("How to", color = MaterialTheme.colors.primary) + Text(generalGetString(R.string.how_to), color = MaterialTheme.colors.primary) Icon( - Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary, + Icons.Outlined.OpenInNew, generalGetString(R.string.how_to), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index e74aa7042e..a99a49c99b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -24,8 +23,7 @@ import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView -import chat.simplex.app.views.helpers.ProfileImage -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.* @Composable fun SettingsView(chatModel: ChatModel) { @@ -71,7 +69,7 @@ fun SettingsLayout( .padding(top = 16.dp) ) { Text( - "Your settings", + generalGetString(R.string.your_settings), style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 8.dp) ) @@ -93,39 +91,39 @@ fun SettingsLayout( SettingsSectionView(showModal { UserAddressView(it) }) { Icon( Icons.Outlined.QrCode, - contentDescription = "Address", + contentDescription = generalGetString(R.string.icon_descr_address), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Your SimpleX contact address") + Text(generalGetString(R.string.your_simplex_contact_address)) } Spacer(Modifier.height(24.dp)) SettingsSectionView(showModal { HelpView(it) }) { Icon( Icons.Outlined.HelpOutline, - contentDescription = "Chat help", + contentDescription = generalGetString(R.string.icon_descr_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("How to use SimpleX Chat") + Text(generalGetString(R.string.how_to_use_simplex_chat)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, - contentDescription = "Markdown help", + contentDescription = generalGetString(R.string.markdown_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Markdown in messages") + Text(generalGetString(R.string.markdown_in_messages)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) { Icon( Icons.Outlined.Tag, - contentDescription = "SimpleX Team", + contentDescription = generalGetString(R.string.icon_descr_simplex_team), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Chat with the founder", + generalGetString(R.string.chat_with_the_founder), color = MaterialTheme.colors.primary ) } @@ -133,11 +131,11 @@ fun SettingsLayout( SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { Icon( Icons.Outlined.Email, - contentDescription = "Email", + contentDescription = generalGetString(R.string.icon_descr_email), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Send us email", + generalGetString(R.string.send_us_an_email), color = MaterialTheme.colors.primary ) } @@ -146,19 +144,20 @@ fun SettingsLayout( SettingsSectionView(showModal { SMPServersView(it) }) { Icon( Icons.Outlined.Dns, - contentDescription = "SMP servers", + contentDescription = generalGetString(R.string.smp_servers), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("SMP servers") + Text(generalGetString(R.string.smp_servers)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { Icon( Icons.Outlined.Bolt, - contentDescription = "Private notifications", + contentDescription = generalGetString(R.string.private_notifications), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Private notifications", Modifier + Text( + generalGetString(R.string.private_notifications), Modifier .padding(end = 24.dp) .fillMaxWidth() .weight(1F)) @@ -176,10 +175,10 @@ fun SettingsLayout( SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), - contentDescription = "Chat console", + contentDescription = generalGetString(R.string.chat_console), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Chat console") + Text(generalGetString(R.string.chat_console)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { @@ -188,14 +187,7 @@ fun SettingsLayout( contentDescription = "GitHub", ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - buildAnnotatedString { - append("Install ") - withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { - append("SimpleX Chat for terminal") - } - } - ) + Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 486260220a..734fa19a05 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme @@ -32,9 +34,9 @@ fun UserAddressView(chatModel: ChatModel) { share = { userAddress: String -> shareText(cxt, userAddress) }, deleteAddress = { AlertManager.shared.showAlertMsg( - title = "Delete address?", - text = "All your contacts will remain connected.", - confirmText = "Delete", + title = generalGetString(R.string.delete_address__question), + text = generalGetString(R.string.all_your_contacts_will_remain_connected), + confirmText = generalGetString(R.string.delete_verb), onConfirm = { withApi { chatModel.controller.apiDeleteUserAddress() @@ -58,14 +60,14 @@ fun UserAddressLayout( verticalArrangement = Arrangement.Top ) { Text( - "Your chat address", + generalGetString(R.string.your_chat_address), Modifier.padding(bottom = 16.dp), style = MaterialTheme.typography.h1, ) Text( - "You can share your address as a link or as a QR code - anybody will be able to connect to you, " + - "and if you later delete it - you won't lose your contacts.", + generalGetString(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), Modifier.padding(bottom = 12.dp), + lineHeight = 22.sp ) Column( Modifier.fillMaxWidth(), @@ -73,7 +75,12 @@ fun UserAddressLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (userAddress == null) { - SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress) + Text( + generalGetString(R.string.if_you_delete_address_you_wont_lose_contacts), + Modifier.padding(bottom = 12.dp), + lineHeight = 22.sp + ) + SimpleButton(generalGetString(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) } else { QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f)) Row( @@ -82,11 +89,11 @@ fun UserAddressLayout( modifier = Modifier.padding(vertical = 10.dp) ) { SimpleButton( - "Share link", + generalGetString(R.string.share_link), icon = Icons.Outlined.Share, click = { share(userAddress) }) SimpleButton( - "Delete address", + generalGetString(R.string.delete_address), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteAddress diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 46ddaffe89..39af8a3c21 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -19,11 +19,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.ModalView import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.launch @@ -89,16 +90,16 @@ fun UserProfileLayout( horizontalAlignment = Alignment.Start ) { Text( - "Your chat profile", + generalGetString(R.string.your_chat_profile), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) Text( - "Your profile is stored on your device and shared only with your contacts.\n\n" + - "SimpleX servers cannot see your profile.", + generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), Modifier.padding(bottom = 24.dp), - color = MaterialTheme.colors.onBackground + color = MaterialTheme.colors.onBackground, + lineHeight = 22.sp ) if (editProfile.value) { Column( @@ -124,14 +125,14 @@ fun UserProfileLayout( ProfileNameTextField(displayName) ProfileNameTextField(fullName) Row { - TextButton("Cancel") { + TextButton(generalGetString(R.string.cancel_verb)) { displayName.value = profile.displayName fullName.value = profile.fullName profileImage.value = profile.image editProfile.value = false } Spacer(Modifier.padding(horizontal = 8.dp)) - TextButton("Save (and notify contacts)") { + TextButton(generalGetString(R.string.save_and_notify_contacts)) { saveProfile(displayName.value, fullName.value, profileImage.value) } } @@ -154,9 +155,9 @@ fun UserProfileLayout( } } } - ProfileNameRow("Display name:", profile.displayName) - ProfileNameRow("Full name:", profile.fullName) - TextButton("Edit") { editProfile.value = true } + ProfileNameRow(generalGetString(R.string.display_name__field), profile.displayName) + ProfileNameRow(generalGetString(R.string.full_name__field), profile.fullName) + TextButton(generalGetString(R.string.edit_verb)) { editProfile.value = true } } } if (savedKeyboardState != keyboardState) { @@ -223,7 +224,7 @@ fun EditImageButton(click: () -> Unit) { ) { Icon( Icons.Outlined.PhotoCamera, - contentDescription = "Edit image", + contentDescription = generalGetString(R.string.edit_image), tint = MaterialTheme.colors.primary, modifier = Modifier.size(36.dp) ) @@ -235,7 +236,7 @@ fun DeleteImageButton(click: () -> Unit) { IconButton(onClick = click) { Icon( Icons.Outlined.Close, - contentDescription = "Delete image", + contentDescription = generalGetString(R.string.delete_image), tint = MaterialTheme.colors.primary, ) } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..1d54970f46 --- /dev/null +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,218 @@ + + SimpleX + + т + + + Соединиться через ссылку-контакт? + Соединиться через ссылку-приглашение? + Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку. + Соединиться + + + Соединение установлено + Соединение устанавливается… + Установлено соединение с сервером, через который вы получаете сообщения от этого контакта. + Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %1$s). + Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта. + + + удалено + отправка файлов не поддерживается + получение файлов не поддерживается + вы + неизвестный формат сообщения + неверный формат сообщения + + + Ошибка при сохранении SMP серверов + Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + + + Существующий контакт + Вы уже соединены с %1$s! через эту ссылку. + Ошибка в ссылке контакта + Пожалуйста, проверьте, что вы использовали правильную ссылку, или попросите ваш контакт отправить вам новую. + Невозможно удалить контакт! + Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. + Мгновенные уведомления + + + Приватные мгновенные уведомления! + Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает фоновый сервис SimpleX, который потребляет несколько процентов батареи в день. + Он может быть выключен через Настройки – вы продолжите получать уведомления о сообщениях пока приложение запущено. + + + SimpleX Chat сервис + Приём сообщений… + + + Ответить + Поделиться + Скопировать + Редактировать + Удалить + Удалить сообщение? + Сообщение будет удалено – это действие нельзя отменить! + Только для меня + Для всех + + + отредактировано + отправлено + ошибка авторизации при отправке + ошибка при отправке + не прочитано + + + Здравствуйте %1$s! + Здравствуйте! + Этот текст можно найти в Настройках + Ваши чаты + + + Удалить контакт? + Контакт и все сообщения будут удалены - это действие нельзя отменить! + Удалить контакт + Соединение с сервером установлено + Соединение с сервером не установлено + Ошибка соединения с сервером + Ожидается соединение с сервером + + + Отправить сообщение + + + Назад + Отменить + Подтвердить + + нет описания + Добавить контакт + Сканировать QR код + + + (создать QR код или ссылку) + (при встрече или через видео звонок) + Создать группу + (скоро!) + + + Разрешение не получено! + Использовать камеру + Открыть галерею + + + Спасибо что установили SimpleX Chat! + Вы можете соединиться с разработчиками, чтобы задать любые вопросы или получать уведомления о новых версиях. + Чтобы начать новый чат + Нажмите кнопку + сверху, затем: + Добавить новый контакт: чтобы создать одноразовый QR код/ссылку для вашего контакта. + Сканировать QR код: чтобы соединиться с контактом, который показывает вам QR код. + Чтобы соединиться через ссылку + Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть ее в браузере: + 💻 на компьютере: сосканируйте показанный QR код из приложения через Сканировать QR код. + 📱 на мобильном: намжите кнопку Open in mobile app на веб странице, затем нажмите Соединиться в приложении. + + + Принять запрос на соединение? + Отправителю НЕ будет послано уведомление, если вы отклоните запрос на соединение. + Принять + Отклонить + + + хочет соединиться с вами! + + + аватар не установлен + аватар + + + закрыть + изображение превью ссылки + удалить превью ссылки + Настройки + QR код + SimpleX адрес + Помощь + SimpleX команда + SimpleX логотип + Email + + + Неверный QR код + Этот QR код не является ссылкой! + Неверная ссылка! + Эта ссылка не является ссылкой-приглашением! + Запрос на соединение послан! + Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! + Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! + Покажите QR код вашему контакту, чтобы сосканировать его из приложения + Если вы не можете встретиться лично, вы можете показать QR код во время видео звонка или отправить ссылку через любой другой канал связи. + Ваш профиль будет отправлен\nвашему контакту + Если вы не можете встретиться лично, вы можете сосканировать QR код во время видео звонка, или ваш контакт может отправить вам ссылку. + Поделиться ссылкой + + + Настройки + Ваш SimpleX адрес + Как использовать SimpleX Chat + Форматирование сообщений + Форматирование сообщений + Соединиться с разработчиками + Отправить email + Приватные уведомления + Консоль + SMP серверы + SimpleX Chat для терминала + Использовать серверы предосталенные SimpleX Chat? + Сохраненные SMP серверы будут удалены. + Ваши SMP серверы + Настройка SMP серверов + Используются серверы предоставленные SimpleX Chat. + Введите SMP серверы, каждый сервер в отдельной строке: + Информация + Сохранить + + + Создать адрес + Удалить адрес? + Все контакты, которые соединились через этот адрес, сохранятся. + Ваш SimpleX адрес + Вы можете использовать адрес как ссылку или как QR код - через него можно с вами соединиться. + Вы сможете удалить адрес, сохранив контакты, которые через него соединились. + Поделиться\nссылкой + Удалить\nадрес + + + Имя профиля: + "Полное имя: + Ваш профиль + Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\n\nSimpleX серверы не могут получить доступ к вашему профилю. + Поменять аватар + Удалить аватар + Сохранить (и послать обновление контактам) + + + Вы котролируете ваш чат! + Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. + Мы не храним ваши контакты и сообщения (после доставки) на серверах. + Создать профиль + Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам. + Имя профиля не может содержать пробелы. + Имя профиля + Полное имя (не обязательно) + Создать + + + Как форматировать + Вы можете форматировать сообщения: + жирный + курсив + зачеркнуть + a + b + цвет + секрет + + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 228c03d113..dda6fd3439 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1,7 +1,218 @@ - - SimpleX + + SimpleX + + k + + + Connect via contact link? + Connect via invitation link? + Your profile will be sent to the contact that you received this link from. + Connect + + + Server connected + Connecting server… + You are connected to the server used to receive messages from this contact. + Trying to connect to the server used to receive messages from this contact (error: %1$s). + Trying to connect to the server used to receive messages from this contact. + + + deleted + sending files is not supported yet + receiving files is not supported yet + you + unknown message format + invalid message format + + + Error saving SMP servers + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + + + Contact already exists + You are already connected to %1$s! via this link. + Invalid connection link + Please check that you used the correct link or ask your contact to send you another one. + Can\'t delete contact! + Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. + Instant notifications + + + Private instant notifications! + To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. + It can be disabled via settings – notifications will still be shown while the app is running. - SimpleX Chat service - Waiting for incoming messages + SimpleX Chat service + Receiving messages… + + + Reply + Share + Copy + Edit + Delete + Delete message? + Message will be deleted - this cannot be undone! + For me only + For everybody + + + edited + sent + unauthorized send + send failed + unread + + + Welcome %1$s! + Welcome! + This text is available in settings + Your chats + + + Delete contact? + Contact and all messages will be deleted - this cannot be undone! + Delete contact + Connected + Disconnected + Error + Pending + + + Send Message + + + Back + Cancel + Confirm + Ok + no details + Add contact + Scan QR code + + + (create QR code\nor link) + (in person or in video call) + Create Group + (coming soon!) + + + Permission Denied! + Use Camera + From Gallery + + + Thank you for installing SimpleX Chat! + You can connect to SimpleX Chat developers to ask any questions and to receive updates. + To start a new chat + Tap button + above, then: + Add new contact: to create your one-time QR Code for your contact. + Scan QR code: to connect to your contact who shows QR code to you. + To connect via link + If you received SimpleX Chat invitation link, you can open it in your browser: + 💻 desktop: scan displayed QR code from the app, via Scan QR code. + 📱 mobile: tap Open in mobile app, then tap Connect in the app. + + + Accept connection request? + If you choose to reject sender will NOT be notified. + Accept + Reject + + + wants to connect to you! + + + profile image placeholder + profile image + + + Close button + link preview image + cancel link preview + Settings + QR Code + SimpleX Address + help + SimpleX Team + SimpleX Logo + Email + + + Invalid QR code + This QR code is not a link! + Invalid link! + This link is not a valid connection link! + Connection request sent! + You will be connected when your connection request is accepted, please wait or check later! + You will be connected when your contact\'s device is online, please wait or check later! + Show QR code for your contact\nto scan from the app + If you cannot meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. + Your chat profile will be sent\nto your contact + If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. + Share invitation link + + + Your settings + Your SimpleX contact address + How to use SimpleX Chat + Markdown help + Markdown in messages + Connect to the developers + Send us email + Private notifications + Chat console + SMP servers + Install SimpleX Chat for terminal + Use SimpleX Chat servers? + Saved SMP servers will be removed. + Your SMP servers + Configure SMP servers + Using SimpleX Chat servers. + Enter one SMP server per line: + How to + Save + + + Create address + Delete address? + All your contacts will remain connected. + Your chat address + You can share your address as a link or as a QR code - anybody will be able to connect to you. + If you later delete it - you won\'t lose your contacts. + Share link + Delete address + + + Display name: + "Full name: + Your chat profile + Your profile is stored on your device and shared only with your contacts.\n\nSimpleX servers cannot see your profile. + Edit image + Delete image + Save (and notify contacts) + + + You control your chat! + The messaging and application platform protecting your privacy and security. + We don\'t store any of your contacts or messages (once delivered) on the servers. + Create profile + Your profile is stored on your device and shared only with your contacts. + Display name cannot contain whitespace. + Display Name + Full Name (Optional) + Create + + + How to use markdown + You can use markdown to format messages: + bold + italic + strike + a + b + colored + secret + From 87c688a7395151bd4b5b74e1f03f20215363ccaa Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 16 Apr 2022 09:37:01 +0100 Subject: [PATCH 35/37] ios: i18n (#533) * ios: prepare for i18n * commit localizations * update Russian translations * fix notifications and layouts after localizations * localization docs * update translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * fix typo * update translations * fix translations for different link types * update translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update translation Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> * update translations * update translations Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com> --- apps/ios/LOCALIZATION.md | 29 + apps/ios/Shared/ContentView.swift | 4 +- apps/ios/Shared/Model/ChatModel.swift | 12 +- apps/ios/Shared/Model/NtfManager.swift | 16 +- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/SimpleX (macOS)-Bridging-Header.h | 11 - apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- .../Views/Chat/ChatItem/FramedItemView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Chat/ComposeMessage/SendMessageView.swift | 2 +- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 29 +- .../Shared/Views/NewChat/AddContactView.swift | 4 +- .../Views/NewChat/ConnectContactView.swift | 6 +- .../Views/NewChat/CreateGroupView.swift | 2 +- .../Shared/Views/NewChat/NewChatButton.swift | 9 +- .../Views/UserSettings/MarkdownHelp.swift | 4 +- .../Views/UserSettings/SMPServers.swift | 2 +- .../Views/UserSettings/SettingsView.swift | 2 +- .../Views/UserSettings/UserAddress.swift | 2 +- .../AccentColor.colorset/Contents.json | 15 + .../Shared/Assets.xcassets/Contents.json | 6 + .../en.xcloc/Localized Contents/en.xliff | 801 ++++++++++++++++++ .../AccentColor.colorset/Contents.json | 23 + .../Shared/Assets.xcassets/Contents.json | 6 + .../en.lproj/Localizable.strings | Bin 0 -> 1678 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 4 + .../en.xcloc/contents.json | 12 + .../AccentColor.colorset/Contents.json | 15 + .../Shared/Assets.xcassets/Contents.json | 6 + .../ru.xcloc/Localized Contents/ru.xliff | 800 +++++++++++++++++ .../AccentColor.colorset/Contents.json | 23 + .../Shared/Assets.xcassets/Contents.json | 6 + .../en.lproj/Localizable.strings | Bin 0 -> 1678 bytes .../en.lproj/SimpleX--iOS--InfoPlist.strings | 4 + .../ru.xcloc/contents.json | 12 + apps/ios/SimpleX--macOS--Info.plist | 5 - apps/ios/SimpleX.xcodeproj/project.pbxproj | 310 +------ .../xcschemes/SimpleX (iOS).xcscheme | 89 ++ apps/ios/Tests macOS/Tests_macOS.swift | 42 - .../Tests macOS/Tests_macOSLaunchTests.swift | 32 - apps/ios/macOS/macOS.entitlements | 10 - apps/ios/ru.lproj/Localizable.strings | 462 ++++++++++ .../ru.lproj/SimpleX--iOS--InfoPlist.strings | 6 + 45 files changed, 2403 insertions(+), 438 deletions(-) create mode 100644 apps/ios/LOCALIZATION.md delete mode 100644 apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/en.xcloc/contents.json create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings create mode 100644 apps/ios/SimpleX Localizations/ru.xcloc/contents.json delete mode 100644 apps/ios/SimpleX--macOS--Info.plist create mode 100644 apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme delete mode 100644 apps/ios/Tests macOS/Tests_macOS.swift delete mode 100644 apps/ios/Tests macOS/Tests_macOSLaunchTests.swift delete mode 100644 apps/ios/macOS/macOS.entitlements create mode 100644 apps/ios/ru.lproj/Localizable.strings create mode 100644 apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md new file mode 100644 index 0000000000..40fe37c215 --- /dev/null +++ b/apps/ios/LOCALIZATION.md @@ -0,0 +1,29 @@ +# Localization + +## Creating localization keys + +There are three ways XCode generates localization keys from strings: + +1. Automatically, from the texts used in standard components `Text`, `Label`, `Button`, etc. + +2. All strings passed to view variables and function parameters declared as `LocalizedStringKey` type. Only string constants (possibly, with interpolation) or other variables of type `LocalizedStringKey` can be passed to these parameters. See, for example, ContentView.swift. + +3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used: + +```swift +String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body") +``` + +## Adding strings to the existing localizations + +1. Choose `Product -> Export Localizations...` in the menu, choose `ios` folder as the destination and `SimpleX Localizations` as the folder name, confirm to overwrite it (make sure not to save to subfolder). +2. Add `target` keys to the localizations that were added or changed. +3. Choose `Product -> Import Localizations...` for any non-Enlish folders - that would update Localizable files. + +Localizable files values can be edited directly, the changes will be included in the next export. Following the process above though guarantees that all strings are localized. + +## Development + +Make sure to enable the option `Show non-localized strings` in `Product -> Scheme -> Edit scheme...` menu - it will be showing all non-localized strings as all caps. + +Read more about editing XLIFF and string files here: https://developer.apple.com/documentation/xcode/editing-xliff-and-strings-files diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b9e7fb066d..7d911d4c82 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -36,7 +36,7 @@ struct ContentView: View { func notificationAlert() -> Alert { Alert( - title: Text("Notification are disabled!"), + title: Text("Notifications are disabled!"), message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."), primaryButton: .default(Text("Open Settings")) { DispatchQueue.main.async { @@ -61,7 +61,7 @@ final class AlertManager: ObservableObject { } } - func showAlertMsg(title: String, message: String? = nil) { + func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) { if let message = message { showAlert(Alert(title: Text(title), message: Text(message))) } else { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index b70aecf771..e05806458e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -384,7 +384,7 @@ final class Chat: ObservableObject, Identifiable { case disconnected case error(String) - var statusString: String { + var statusString: LocalizedStringKey { get { switch self { case .connected: return "Server connected" @@ -394,7 +394,7 @@ final class Chat: ObservableObject, Identifiable { } } - var statusExplanation: String { + var statusExplanation: LocalizedStringKey { get { switch self { case .connected: return "You are connected to the server used to receive messages from this contact." @@ -719,10 +719,10 @@ enum CIContent: Decodable, ItemContent { switch self { case let .sndMsgContent(mc): return mc.text case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return "deleted" - case .rcvDeleted: return "deleted" - case .sndFileInvitation: return "sending files is not supported yet" - case .rcvFileInvitation: return "receiving files is not supported yet" + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .sndFileInvitation: return NSLocalizedString("sending files is not supported yet", comment: "to be removed") + case .rcvFileInvitation: return NSLocalizedString("receiving files is not supported yet", comment: "to be removed") } } } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 5d53cb31a9..8861968b2c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -92,22 +92,22 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfCategoryContactRequest, actions: [UNNotificationAction( identifier: ntfActionAccept, - title: "Accept" + title: NSLocalizedString("Accept", comment: "accept contact request via notification") )], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "New contact request" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") ), UNNotificationCategory( identifier: ntfCategoryContactConnected, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "Contact is connected" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Contact is connected", comment: "notification") ), UNNotificationCategory( identifier: ntfCategoryMessageReceived, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "New message" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications") ) ]) } @@ -139,8 +139,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.notifyContactRequest") addNotification( categoryIdentifier: ntfCategoryContactRequest, - title: "\(contactRequest.displayName) wants to connect!", - body: "Accept contact request from \(contactRequest.chatViewName)?", + title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), targetContentIdentifier: nil, userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] ) @@ -150,8 +150,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.notifyContactConnected") addNotification( categoryIdentifier: ntfCategoryContactConnected, - title: "\(contact.displayName) is connected!", - body: "You can now send messages to \(contact.chatViewName)", + title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), targetContentIdentifier: contact.id // userInfo: ["chatId": contact.id, "contactId": contact.apiId] ) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f87a178dc1..7622ac881d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -467,13 +467,13 @@ func apiConnect(connReq: String) async throws -> Bool { case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))): am.showAlertMsg( title: "Connection timeout", - message: "Please check your network connection and try again" + message: "Please check your network connection and try again." ) return false case .chatCmdError(.errorAgent(.BROKER(.NETWORK))): am.showAlertMsg( title: "Connection error", - message: "Please check your network connection and try again" + message: "Please check your network connection and try again." ) return false default: throw r diff --git a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h deleted file mode 100644 index bc28b42d38..0000000000 --- a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -extern void hs_init(int argc, char **argv[]); - -typedef void* chat_ctrl; - -extern chat_ctrl chat_init(char *path); -extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); -extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec52ac977a..f345f458fb 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -64,7 +64,7 @@ struct ChatInfoView: View { private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted"), + message: Text("Contact and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { do { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 57ac507d8e..0567598c03 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -87,7 +87,7 @@ struct FramedItemView: View { } } - private func msgDeliveryError(_ err: String) { + private func msgDeliveryError(_ err: LocalizedStringKey) { AlertManager.shared.showAlertMsg( title: "Message delivery error", message: err diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index ee638d8bf2..04cb218ede 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -100,7 +100,7 @@ struct ChatView: View { Button { chatModel.chatId = nil } label: { HStack(spacing: 4) { Image(systemName: "chevron.backward") - Text("Chats") + Text("Chats", comment: "back button to return to chats list") } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 86ab936803..afaa4788b3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,7 +11,7 @@ import SwiftUI struct SendMessageView: View { var sendMessage: (String) -> Void var inProgress: Bool = false - @Binding var message: String //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + @Binding var message: String @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @Binding var editing: Bool diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 58a43eb6cf..3a9dc58f64 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -16,9 +16,9 @@ struct ChatHelp: View { VStack(alignment: .leading, spacing: 10) { Text("Thank you for installing SimpleX Chat!") - HStack(spacing: 4) { - Text("You can") - Button("connect to SimpleX Chat founder.") { + VStack(alignment: .leading, spacing: 0) { + Text("To ask any questions and to receive updates:") + Button("connect to SimpleX Chat developers.") { showSettings = false DispatchQueue.main.async { UIApplication.shared.open(simplexTeamURL) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d2ec6c11e2..d25ffafe53 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -116,7 +116,7 @@ struct ChatListNavLink: View { private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted"), + message: Text("Contact and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { do { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index f74e94a957..e220882494 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -20,19 +20,13 @@ struct ChatListView: View { let v = NavigationView { List { if chatModel.chats.isEmpty { - VStack(alignment: .leading) { - ChatHelp(showSettings: $showSettings) - HStack { - Text("This text is available in settings") - SettingsButton() - } - .padding(.leading) + ChatHelp(showSettings: $showSettings) + } else { + ForEach(filteredChats()) { chat in + ChatListNavLink(chat: chat) + .padding(.trailing, -16) } } - ForEach(filteredChats()) { chat in - ChatListNavLink(chat: chat) - .padding(.trailing, -16) - } } .onChange(of: chatModel.chatId) { _ in if chatModel.chatId == nil, let chatId = chatModel.chatToTop { @@ -80,22 +74,23 @@ struct ChatListView: View { logger.debug("ChatListView.connectViaUrlAlert path: \(path)") if (path == "/contact" || path == "/invitation") { path.removeFirst() - let action = path + let action: ConnReqType = path == "contact" ? .contact : .invitation let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + let title: LocalizedStringKey + if case .contact = action { title = "Connect via contact link?" } + else { title = "Connect via invitation link?" } return Alert( - title: Text("Connect via \(action) link?"), + title: Text(title), message: Text("Your profile will be sent to the contact that you received this link from"), primaryButton: .default(Text("Connect")) { DispatchQueue.main.async { Task { do { let ok = try await apiConnect(connReq: link) - if ok { - connectionReqSentAlert(action == "contact" ? .contact : .invitation) - } + if ok { connectionReqSentAlert(action) } } catch { let err = error.localizedDescription - AlertManager.shared.showAlertMsg(title: "Connection error", message: err) + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(err)") logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") } } diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 3c924697ec..9101e893f8 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -22,9 +22,7 @@ struct AddContactView: View { .multilineTextAlignment(.center) QRCode(uri: connReqInvitation) .padding() - (Text("If you cannot meet in person, you can ") + - Text("scan QR code in the video call").bold() + - Text(", or you can share the invitation link via any other channel.")) + Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.") .font(.subheadline) .multilineTextAlignment(.center) .padding(.horizontal) diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift index 2513801fcc..9944dc21fb 100644 --- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift +++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift @@ -26,7 +26,11 @@ struct ConnectContactView: View { .aspectRatio(1, contentMode: .fit) .border(.gray) } - .padding(13.0) + .padding(12) + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) } } diff --git a/apps/ios/Shared/Views/NewChat/CreateGroupView.swift b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift index 89a65f1ecd..54f1d6c206 100644 --- a/apps/ios/Shared/Views/NewChat/CreateGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift @@ -10,7 +10,7 @@ import SwiftUI struct CreateGroupView: View { var body: some View { - Text("CreateGroupView") + EmptyView() } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 0fff56bf3f..83f7fd85fe 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -65,7 +65,7 @@ struct NewChatButton: View { } func connectionErrorAlert(_ error: Error) { - AlertManager.shared.showAlertMsg(title: "Connection error", message: error.localizedDescription) + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") } } @@ -75,12 +75,11 @@ enum ConnReqType: Equatable { } func connectionReqSentAlert(_ type: ConnReqType) { - let whenConnected = type == .contact - ? "your connection request is accepted" - : "your contact's device is online" AlertManager.shared.showAlertMsg( title: "Connection request sent!", - message: "You will be connected when \(whenConnected), please wait or check later!" + message: type == .contact + ? "You will be connected when your connection request is accepted, please wait or check later!" + : "You will be connected when your contact's device is online, please wait or check later!" ) } diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift index 855d0adff1..c5dafc8663 100644 --- a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift @@ -30,9 +30,9 @@ struct MarkdownHelp: View { } } -private func mdFormat(_ format: String, _ example: Text) -> some View { +private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some View { HStack { - Text(format).frame(width: 88, alignment: .leading) + Text(format).frame(width: 120, alignment: .leading) example } } diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift index 861a934980..a687f613c3 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServers.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift @@ -82,7 +82,7 @@ struct SMPServers: View { saveUserSMPServers() } .alert(isPresented: $showBadServersAlert) { - Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated")) + Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated.")) } Spacer() howToButton() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index feb7ec85fc..933f5f95bd 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -100,7 +100,7 @@ struct SettingsView: View { UIApplication.shared.open(simplexTeamURL) } } label: { - Text("Chat with the founder") + Text("Chat with the developers") } } HStack { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index e15bd167c1..c1ff708233 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -14,7 +14,7 @@ struct UserAddress: View { var body: some View { VStack (alignment: .leading) { - Text("You can share your address as a link or as a QR code - anybody will be able to connect to you, and if you later delete it - you won't lose your contacts.") + Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.") .padding(.bottom) if let userAdress = chatModel.userAddress { QRCode(uri: userAdress) diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..d0ffdd59b0 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "en" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff new file mode 100644 index 0000000000..6cea986b34 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -0,0 +1,801 @@ + + + +
+ +
+ + + + + No comment provided by engineer. + + + ( + ( + No comment provided by engineer. + + + (can be copied) + (can be copied) + No comment provided by engineer. + + + !1 colored! + !1 colored! + No comment provided by engineer. + + + #secret# + #secret# + No comment provided by engineer. + + + %@ is connected! + %@ is connected! + notification title + + + %@ wants to connect! + %@ wants to connect! + notification title + + + %lld + %lld + No comment provided by engineer. + + + %lldk + %lldk + No comment provided by engineer. + + + (shared only with your contacts) + (shared only with your contacts) + No comment provided by engineer. + + + ) + ) + No comment provided by engineer. + + + **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code for your contact. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact who shows QR code to you. + **Scan QR code**: to connect to your contact who shows QR code to you. + No comment provided by engineer. + + + *bold* + *bold* + No comment provided by engineer. + + + , + , + No comment provided by engineer. + + + 6 + 6 + No comment provided by engineer. + + + : + : + No comment provided by engineer. + + + : %@ + : %@ + No comment provided by engineer. + + + Accept + Accept + accept contact request via notification + + + Accept contact + Accept contact + No comment provided by engineer. + + + Accept contact request from %@? + Accept contact request from %@? + notification body + + + Add contact + Add contact + No comment provided by engineer. + + + All your contacts will remain connected + All your contacts will remain connected + No comment provided by engineer. + + + Cancel + Cancel + No comment provided by engineer. + + + Chat console + Chat console + No comment provided by engineer. + + + Chat with the developers + Chat with the developers + No comment provided by engineer. + + + Chats + Chats + back button to return to chats list + + + Choose from library + Choose from library + No comment provided by engineer. + + + Configure SMP servers + Configure SMP servers + No comment provided by engineer. + + + Confirm + Confirm + No comment provided by engineer. + + + Connect + Connect + No comment provided by engineer. + + + Connect via contact link? + Connect via contact link? + No comment provided by engineer. + + + Connect via invitation link? + Connect via invitation link? + No comment provided by engineer. + + + Connecting server… + Connecting server… + No comment provided by engineer. + + + Connecting server… (error: %@) + Connecting server… (error: %@) + No comment provided by engineer. + + + Connecting... + Connecting... + No comment provided by engineer. + + + Connection error + Connection error + No comment provided by engineer. + + + Connection request + Connection request + No comment provided by engineer. + + + Connection request sent! + Connection request sent! + No comment provided by engineer. + + + Connection timeout + Connection timeout + No comment provided by engineer. + + + Contact already exists + Contact already exists + No comment provided by engineer. + + + Contact and all messages will be deleted - this cannot be undone! + Contact and all messages will be deleted - this cannot be undone! + No comment provided by engineer. + + + Contact is connected + Contact is connected + notification + + + Copy + Copy + No comment provided by engineer. + + + Create + Create + No comment provided by engineer. + + + Create address + Create address + No comment provided by engineer. + + + Create group + Create group + No comment provided by engineer. + + + Create profile + Create profile + No comment provided by engineer. + + + Delete + Delete + No comment provided by engineer. + + + Delete address + Delete address + No comment provided by engineer. + + + Delete address? + Delete address? + No comment provided by engineer. + + + Delete contact + Delete contact + No comment provided by engineer. + + + Delete contact? + Delete contact? + No comment provided by engineer. + + + Delete for me + Delete for me + No comment provided by engineer. + + + Delete group + Delete group + No comment provided by engineer. + + + Delete message? + Delete message? + No comment provided by engineer. + + + Develop + Develop + No comment provided by engineer. + + + Display name + Display name + No comment provided by engineer. + + + Edit + Edit + No comment provided by engineer. + + + Enter one SMP server per line: + Enter one SMP server per line: + No comment provided by engineer. + + + Error saving SMP servers + Error saving SMP servers + No comment provided by engineer. + + + Error: %@ + Error: %@ + No comment provided by engineer. + + + Error: URL is invalid + Error: URL is invalid + No comment provided by engineer. + + + Full name (optional) + Full name (optional) + No comment provided by engineer. + + + Group deletion is not supported + Group deletion is not supported + No comment provided by engineer. + + + Help + Help + No comment provided by engineer. + + + How to + How to + No comment provided by engineer. + + + How to use SimpleX Chat + How to use SimpleX Chat + No comment provided by engineer. + + + How to use markdown + How to use markdown + No comment provided by engineer. + + + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + No comment provided by engineer. + + + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + No comment provided by engineer. + + + If you received SimpleX Chat invitation link you can open it in your browser: + If you received SimpleX Chat invitation link you can open it in your browser: + No comment provided by engineer. + + + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + No comment provided by engineer. + + + Invalid connection link + Invalid connection link + No comment provided by engineer. + + + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + No comment provided by engineer. + + + Markdown in messages + Markdown in messages + No comment provided by engineer. + + + Message delivery error + Message delivery error + No comment provided by engineer. + + + Most likely this contact has deleted the connection with you. + Most likely this contact has deleted the connection with you. + No comment provided by engineer. + + + New contact request + New contact request + notification + + + New message + New message + notifications + + + Notifications are disabled! + Notifications are disabled! + No comment provided by engineer. + + + Open Settings + Open Settings + No comment provided by engineer. + + + Please check that you used the correct link or ask your contact to send you another one. + Please check that you used the correct link or ask your contact to send you another one. + No comment provided by engineer. + + + Please check your network connection and try again. + Please check your network connection and try again. + No comment provided by engineer. + + + Profile image + Profile image + No comment provided by engineer. + + + Read + Read + No comment provided by engineer. + + + Reject + Reject + No comment provided by engineer. + + + Reject contact (sender NOT notified) + Reject contact (sender NOT notified) + No comment provided by engineer. + + + Reject contact request + Reject contact request + No comment provided by engineer. + + + Reply + Reply + No comment provided by engineer. + + + SMP servers + SMP servers + No comment provided by engineer. + + + Save + Save + No comment provided by engineer. + + + Save (and notify contacts) + Save (and notify contacts) + No comment provided by engineer. + + + Saved SMP servers will be removed + Saved SMP servers will be removed + No comment provided by engineer. + + + Scan QR code + Scan QR code + No comment provided by engineer. + + + Server connected + Server connected + No comment provided by engineer. + + + Settings + Settings + No comment provided by engineer. + + + Share + Share + No comment provided by engineer. + + + Share invitation link + Share invitation link + No comment provided by engineer. + + + Share link + Share link + No comment provided by engineer. + + + Show QR code to your contact +to scan from the app + Show QR code to your contact +to scan from the app + No comment provided by engineer. + + + Start new chat + Start new chat + No comment provided by engineer. + + + Take picture + Take picture + No comment provided by engineer. + + + Tap button + Tap button + No comment provided by engineer. + + + Thank you for installing SimpleX Chat! + Thank you for installing SimpleX Chat! + No comment provided by engineer. + + + The app can notify you when you receive messages or contact requests - please open settings to enable. + The app can notify you when you receive messages or contact requests - please open settings to enable. + No comment provided by engineer. + + + The messaging and application platform 100% private by design! + The messaging and application platform 100% private by design! + No comment provided by engineer. + + + The sender will NOT be notified + The sender will NOT be notified + No comment provided by engineer. + + + To ask any questions and to receive updates: + To ask any questions and to receive updates: + No comment provided by engineer. + + + To connect via link + To connect via link + No comment provided by engineer. + + + To start a new chat + To start a new chat + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact (error: %@). + Trying to connect to the server used to receive messages from this contact (error: %@). + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact. + Trying to connect to the server used to receive messages from this contact. + No comment provided by engineer. + + + Unexpected error: %@ + Unexpected error: %@ + No comment provided by engineer. + + + Use SimpleX Chat servers? + Use SimpleX Chat servers? + No comment provided by engineer. + + + Using SimpleX Chat servers. + Using SimpleX Chat servers. + No comment provided by engineer. + + + Welcome %@! + Welcome %@! + No comment provided by engineer. + + + You + You + No comment provided by engineer. + + + You are already connected to %@ via this link. + You are already connected to %@ via this link. + No comment provided by engineer. + + + You are connected to the server used to receive messages from this contact. + You are connected to the server used to receive messages from this contact. + No comment provided by engineer. + + + You can now send messages to %@ + You can now send messages to %@ + notification body + + + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + No comment provided by engineer. + + + You can use markdown to format messages: + You can use markdown to format messages: + No comment provided by engineer. + + + You control your chat! + You control your chat! + No comment provided by engineer. + + + You will be connected when your connection request is accepted, please wait or check later! + You will be connected when your connection request is accepted, please wait or check later! + No comment provided by engineer. + + + You will be connected when your contact's device is online, please wait or check later! + You will be connected when your contact's device is online, please wait or check later! + No comment provided by engineer. + + + Your SMP servers + Your SMP servers + No comment provided by engineer. + + + Your SimpleX contact address + Your SimpleX contact address + No comment provided by engineer. + + + Your chat address + Your chat address + No comment provided by engineer. + + + Your chat profile + Your chat profile + No comment provided by engineer. + + + Your chat profile will be sent to your contact + Your chat profile will be sent to your contact + No comment provided by engineer. + + + Your chats + Your chats + No comment provided by engineer. + + + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + No comment provided by engineer. + + + Your profile will be sent to the contact that you received this link from + Your profile will be sent to the contact that you received this link from + No comment provided by engineer. + + + Your profile, contacts and messages (once delivered) are only stored locally on your device. + Your profile, contacts and messages (once delivered) are only stored locally on your device. + No comment provided by engineer. + + + Your settings + Your settings + No comment provided by engineer. + + + [Send us email](mailto:chat@simplex.chat) + [Send us email](mailto:chat@simplex.chat) + No comment provided by engineer. + + + _italic_ + _italic_ + No comment provided by engineer. + + + `a + b` + `a + b` + No comment provided by engineer. + + + above, then: + above, then: + No comment provided by engineer. + + + bold + bold + No comment provided by engineer. + + + colored + colored + No comment provided by engineer. + + + connect to SimpleX Chat developers. + connect to SimpleX Chat developers. + No comment provided by engineer. + + + deleted + deleted + deleted chat item + + + italic + italic + No comment provided by engineer. + + + receiving files is not supported yet + receiving files is not supported yet + to be removed + + + secret + secret + No comment provided by engineer. + + + sending files is not supported yet + sending files is not supported yet + to be removed + + + strike + strike + No comment provided by engineer. + + + v%@ (%@) + v%@ (%@) + No comment provided by engineer. + + + wants to connect to you! + wants to connect to you! + No comment provided by engineer. + + + ~strike~ + ~strike~ + No comment provided by engineer. + + + 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. + 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. + No comment provided by engineer. + + + 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. + 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. + No comment provided by engineer. + + +
+ +
+ +
+ + + SimpleX + SimpleX + Bundle name + + + SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX needs camera access to scan QR codes to connect to other app users + Privacy - Camera Usage Description + + +
+
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000000000000000000000000000000000..8a3092dda65330a4b5cb82313b5aa45120322b7e GIT binary patch literal 1678 zcmb`HO-{ow5QS&mQy3&R-Shwm0b8uFM%T;q2pXRQ8&{X6F^UQ|+`ubI6M@)xqX1SgKaWBt_Cl8^0Fh52a3 zYlKWPvZWPm>s9@F?P^|YT4fpu$Ep9;TOP_o{OL?N)Mu=muwuK%d)%)kZ!PwH3=8d) zA&&w+HD4k-c4NE4`_PW` + + +
+ +
+ + + + + No comment provided by engineer. + + + ( + ( + No comment provided by engineer. + + + (can be copied) + (можно скопировать) + No comment provided by engineer. + + + !1 colored! + !1 цвет! + No comment provided by engineer. + + + #secret# + #секрет# + No comment provided by engineer. + + + %@ is connected! + Установлено соединение с %@! + notification title + + + %@ wants to connect! + %@ хочет соединиться! + notification title + + + %lld + %lld + No comment provided by engineer. + + + %lldk + %lldk + No comment provided by engineer. + + + (shared only with your contacts) + (отправляется только вашим контактам) + No comment provided by engineer. + + + ) + ) + No comment provided by engineer. + + + **Add new contact**: to create your one-time QR Code for your contact. + **Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact who shows QR code to you. + **Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код). + No comment provided by engineer. + + + *bold* + \*жирный* + No comment provided by engineer. + + + , + , + No comment provided by engineer. + + + 6 + 6 + No comment provided by engineer. + + + : + : + No comment provided by engineer. + + + : %@ + : %@ + No comment provided by engineer. + + + Accept + Принять + accept contact request via notification + + + Accept contact + Принять запрос + No comment provided by engineer. + + + Accept contact request from %@? + Принять запрос на соединение от %@? + notification body + + + Add contact + Добавить контакт + No comment provided by engineer. + + + All your contacts will remain connected + Все контакты, которые соединились через этот адрес, сохранятся. + No comment provided by engineer. + + + Cancel + Отменить + No comment provided by engineer. + + + Chat console + Консоль + No comment provided by engineer. + + + Chat with the developers + Соединиться с разработчиками + No comment provided by engineer. + + + Chats + Назад + back button to return to chats list + + + Choose from library + Выбрать из библиотеки + No comment provided by engineer. + + + Configure SMP servers + Настройка SMP серверов + No comment provided by engineer. + + + Confirm + Подтвердить + No comment provided by engineer. + + + Connect + Соединиться + No comment provided by engineer. + + + Connect via contact link? + Соединиться через ссылку-контакт? + No comment provided by engineer. + + + Connect via invitation link? + Соединиться через ссылку-приглашение? + No comment provided by engineer. + + + Connecting server… + Устанавливается соединение с сервером… + No comment provided by engineer. + + + Connecting server… (error: %@) + Устанавливается соединение с сервером… (ошибка: %@) + No comment provided by engineer. + + + Connecting... + Устанавливается соединение… + No comment provided by engineer. + + + Connection error + Ошибка соединения + No comment provided by engineer. + + + Connection request + Запрос на соединение + No comment provided by engineer. + + + Connection request sent! + Запрос на соединение отправлен! + No comment provided by engineer. + + + Connection timeout + Превышено время соединения + No comment provided by engineer. + + + Contact already exists + Существующий контакт + No comment provided by engineer. + + + Contact and all messages will be deleted - this cannot be undone! + Контакт и все сообщения будут удалены - это действие нельзя отменить! + No comment provided by engineer. + + + Contact is connected + Соединение с контактом установлено + notification + + + Copy + Скопировать + No comment provided by engineer. + + + Create + Создать + No comment provided by engineer. + + + Create address + Создать адрес + No comment provided by engineer. + + + Create group + Создать группу + No comment provided by engineer. + + + Create profile + Создать профиль + No comment provided by engineer. + + + Delete + Удалить + No comment provided by engineer. + + + Delete address + Удалить адрес + No comment provided by engineer. + + + Delete address? + Удалить адрес? + No comment provided by engineer. + + + Delete contact + Удалить контакт + No comment provided by engineer. + + + Delete contact? + Удалить контакт? + No comment provided by engineer. + + + Delete for me + Удалить для меня + No comment provided by engineer. + + + Delete group + Удалить группу + No comment provided by engineer. + + + Delete message? + Удалить сообщение? + No comment provided by engineer. + + + Develop + Для разработчиков + No comment provided by engineer. + + + Display name + Имя профиля + No comment provided by engineer. + + + Edit + Редактировать + No comment provided by engineer. + + + Enter one SMP server per line: + Введите SMP серверы, каждый на отдельной строке: + No comment provided by engineer. + + + Error saving SMP servers + Ошибка при сохранении SMP серверов + No comment provided by engineer. + + + Error: %@ + Ошибка: %@ + No comment provided by engineer. + + + Error: URL is invalid + Ошибка: неверная ссылка + No comment provided by engineer. + + + Full name (optional) + Полное имя (не обязательно) + No comment provided by engineer. + + + Group deletion is not supported + Удаление групп не поддерживается + No comment provided by engineer. + + + Help + Помощь + No comment provided by engineer. + + + How to + Информация + No comment provided by engineer. + + + How to use SimpleX Chat + Как использовать SimpleX Chat + No comment provided by engineer. + + + How to use markdown + Как форматировать + No comment provided by engineer. + + + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку. + No comment provided by engineer. + + + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи. + No comment provided by engineer. + + + If you received SimpleX Chat invitation link you can open it in your browser: + Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере: + No comment provided by engineer. + + + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + [SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat) + No comment provided by engineer. + + + Invalid connection link + Ошибка в ссылке контакта + No comment provided by engineer. + + + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + No comment provided by engineer. + + + Markdown in messages + Форматирование сообщений + No comment provided by engineer. + + + Message delivery error + Ошибка доставки сообщения + No comment provided by engineer. + + + Most likely this contact has deleted the connection with you. + Скорее всего, этот контакт удалил соединение с вами. + No comment provided by engineer. + + + New contact request + Новый запрос на соединение + notification + + + New message + Новое сообщение + notifications + + + Notifications are disabled! + Уведомления выключены + No comment provided by engineer. + + + Open Settings + Открыть Настройки + No comment provided by engineer. + + + Please check that you used the correct link or ask your contact to send you another one. + Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку. + No comment provided by engineer. + + + Please check your network connection and try again. + Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз. + No comment provided by engineer. + + + Profile image + Аватар + No comment provided by engineer. + + + Read + Прочитано + No comment provided by engineer. + + + Reject + Отклонить + No comment provided by engineer. + + + Reject contact (sender NOT notified) + Отклонить (не уведомляя отправителя) + No comment provided by engineer. + + + Reject contact request + Отклонить запрос + No comment provided by engineer. + + + Reply + Ответить + No comment provided by engineer. + + + SMP servers + SMP серверы + No comment provided by engineer. + + + Save + Сохранить + No comment provided by engineer. + + + Save (and notify contacts) + Сохранить (и уведомить контакты) + No comment provided by engineer. + + + Saved SMP servers will be removed + Сохраненные SMP серверы будут удалены + No comment provided by engineer. + + + Scan QR code + Сканировать QR код + No comment provided by engineer. + + + Server connected + Установлено соединение с сервером + No comment provided by engineer. + + + Settings + Настройки + No comment provided by engineer. + + + Share + Поделиться + No comment provided by engineer. + + + Share invitation link + Поделиться ссылкой + No comment provided by engineer. + + + Share link + Поделиться ссылкой + No comment provided by engineer. + + + Show QR code to your contact +to scan from the app + Покажите QR код вашему контакту для сканирования в приложении + No comment provided by engineer. + + + Start new chat + Начать новый разговор + No comment provided by engineer. + + + Take picture + Сделать фото + No comment provided by engineer. + + + Tap button + Нажмите кнопку + No comment provided by engineer. + + + Thank you for installing SimpleX Chat! + Спасибо, что Вы установили SimpleX Chat! + No comment provided by engineer. + + + The app can notify you when you receive messages or contact requests - please open settings to enable. + Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках. + No comment provided by engineer. + + + The messaging and application platform 100% private by design! + Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность. + No comment provided by engineer. + + + The sender will NOT be notified + Отправитель не будет уведомлён + No comment provided by engineer. + + + To ask any questions and to receive updates: + Задать вопросы и получать уведомления о новых версиях: + No comment provided by engineer. + + + To connect via link + Соединиться через ссылку + No comment provided by engineer. + + + To start a new chat + Начать новый разговор + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact (error: %@). + Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@). + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact. + Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта. + No comment provided by engineer. + + + Unexpected error: %@ + Неожиданная ошибка: %@ + No comment provided by engineer. + + + Use SimpleX Chat servers? + Использовать серверы предосталенные SimpleX Chat? + No comment provided by engineer. + + + Using SimpleX Chat servers. + Используются серверы, предоставленные SimpleX Chat. + No comment provided by engineer. + + + Welcome %@! + Здравствуйте %@! + No comment provided by engineer. + + + You + Вы + No comment provided by engineer. + + + You are already connected to %@ via this link. + Вы уже соединены с %@ через эту ссылку. + No comment provided by engineer. + + + You are connected to the server used to receive messages from this contact. + Установлено соединение с сервером, через который вы получается сообщения от этого контакта. + No comment provided by engineer. + + + You can now send messages to %@ + Вы теперь можете отправлять сообщения %@ + notification body + + + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились. + No comment provided by engineer. + + + You can use markdown to format messages: + Вы можете форматировать сообщения: + No comment provided by engineer. + + + You control your chat! + Вы котролируете Ваш чат! + No comment provided by engineer. + + + You will be connected when your connection request is accepted, please wait or check later! + Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже! + No comment provided by engineer. + + + You will be connected when your contact's device is online, please wait or check later! + Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже! + No comment provided by engineer. + + + Your SMP servers + Ваши SMP серверы + No comment provided by engineer. + + + Your SimpleX contact address + Ваш SimpleX адрес + No comment provided by engineer. + + + Your chat address + Ваш SimpleX адрес + No comment provided by engineer. + + + Your chat profile + Ваш профиль + No comment provided by engineer. + + + Your chat profile will be sent to your contact + Ваш профиль будет отправлен вашему контакту + No comment provided by engineer. + + + Your chats + Ваши чаты + No comment provided by engineer. + + + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам. +SimpleX серверы не могут получить доступ к вашему профилю. + No comment provided by engineer. + + + Your profile will be sent to the contact that you received this link from + Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку. + No comment provided by engineer. + + + Your profile, contacts and messages (once delivered) are only stored locally on your device. + Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве. + No comment provided by engineer. + + + Your settings + Настройки + No comment provided by engineer. + + + [Send us email](mailto:chat@simplex.chat) + [Отправить email](mailto:chat@simplex.chat) + No comment provided by engineer. + + + _italic_ + \_курсив_ + No comment provided by engineer. + + + `a + b` + \`a + b` + No comment provided by engineer. + + + above, then: + наверху, затем: + No comment provided by engineer. + + + bold + жирный + No comment provided by engineer. + + + colored + цвет + No comment provided by engineer. + + + connect to SimpleX Chat developers. + соединиться с разработчиками. + No comment provided by engineer. + + + deleted + удалено + deleted chat item + + + italic + курсив + No comment provided by engineer. + + + receiving files is not supported yet + получение файлов не поддерживается + to be removed + + + secret + секрет + No comment provided by engineer. + + + sending files is not supported yet + отправка файлов не поддерживается + to be removed + + + strike + зачеркнуть + No comment provided by engineer. + + + v%@ (%@) + v%@ (%@) + No comment provided by engineer. + + + wants to connect to you! + хочет соединиться с вами! + No comment provided by engineer. + + + ~strike~ + \~зачеркнуть~ + No comment provided by engineer. + + + 💻 desktop: scan displayed QR code from the app, via **Scan QR code**. + 💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**. + No comment provided by engineer. + + + 📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app. + 📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении. + No comment provided by engineer. + + +
+ +
+ +
+ + + SimpleX + SimpleX + Bundle name + + + SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями + Privacy - Camera Usage Description + + +
+
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000000000000000000000000000000000..8a3092dda65330a4b5cb82313b5aa45120322b7e GIT binary patch literal 1678 zcmb`HO-{ow5QS&mQy3&R-Shwm0b8uFM%T;q2pXRQ8&{X6F^UQ|+`ubI6M@)xqX1SgKaWBt_Cl8^0Fh52a3 zYlKWPvZWPm>s9@F?P^|YT4fpu$Ep9;TOP_o{OL?N)Mu=muwuK%d)%)kZ!PwH3=8d) zA&&w+HD4k-c4NE4`_PW` - - - - diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 21fdbe7d73..230ca7890e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -8,120 +8,65 @@ /* Begin PBXBuildFile section */ 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; - 3CDBCF4327FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; }; - 3CDBCF4927FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; - 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; - 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; - 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; - 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; - 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; - 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; - 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; - 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; - 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; - 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; - 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; 5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; }; - 5C411599280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; }; 5C41159A280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; }; - 5C41159B280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; }; 5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; }; - 5C41159D280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; }; 5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; }; - 5C41159F280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; }; 5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; }; - 5C4115A1280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; - 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; - 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; - 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; - 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; - 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; - 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; - 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; - 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; - 5C764E81279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; - 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; - 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; - 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; - 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; - 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; - 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; }; - 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */; }; - 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */; }; 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; - 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; - 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; - 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; - 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; - 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; - 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; - 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; - 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; - 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; - 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; - 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; + 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; + 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; - 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; - 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; - 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; - 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; - 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; - 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; - 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; - 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; - 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -132,13 +77,6 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; - 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5CA059CF279559F40002BEB4; - remoteInfo = "SimpleX (macOS)"; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -172,7 +110,6 @@ 5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = ""; }; - 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = ""; }; 5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; @@ -183,13 +120,10 @@ 5CA059C4279559F40002BEB4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5CA059C5279559F40002BEB4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5CA059CA279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5CA059D0279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; - 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = ""; }; 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -198,6 +132,8 @@ 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; @@ -226,20 +162,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059CD279559F40002BEB4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 5C41159F280048E90054D6CB /* libgmpxx.a in Frameworks */, - 5C4115A1280048E90054D6CB /* libgmp.a in Frameworks */, - 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5C41159D280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */, - 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, - 5C411599280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */, - 5C41159B280048E90054D6CB /* libffi.a in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5CA059D4279559F40002BEB4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -336,12 +258,12 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, + 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, - 5CA059D1279559F40002BEB4 /* macOS */, 5CA059DA279559F40002BEB4 /* Tests iOS */, - 5CA059E6279559F40002BEB4 /* Tests macOS */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, ); @@ -356,7 +278,6 @@ 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, - 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, ); path = Shared; @@ -366,20 +287,12 @@ isa = PBXGroup; children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, - 5CA059D0279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */, ); name = Products; sourceTree = ""; }; - 5CA059D1279559F40002BEB4 /* macOS */ = { - isa = PBXGroup; - children = ( - ); - path = macOS; - sourceTree = ""; - }; 5CA059DA279559F40002BEB4 /* Tests iOS */ = { isa = PBXGroup; children = ( @@ -389,15 +302,6 @@ path = "Tests iOS"; sourceTree = ""; }; - 5CA059E6279559F40002BEB4 /* Tests macOS */ = { - isa = PBXGroup; - children = ( - 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */, - 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */, - ); - path = "Tests macOS"; - sourceTree = ""; - }; 5CB924DD27A8622200ACCCDD /* NewChat */ = { isa = PBXGroup; children = ( @@ -480,23 +384,6 @@ productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; productType = "com.apple.product-type.application"; }; - 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */; - buildPhases = ( - 5CA059CC279559F40002BEB4 /* Sources */, - 5CA059CD279559F40002BEB4 /* Frameworks */, - 5CA059CE279559F40002BEB4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SimpleX (macOS)"; - productName = "SimpleX (macOS)"; - productReference = 5CA059D0279559F40002BEB4 /* SimpleX.app */; - productType = "com.apple.product-type.application"; - }; 5CA059D6279559F40002BEB4 /* Tests iOS */ = { isa = PBXNativeTarget; buildConfigurationList = 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */; @@ -526,7 +413,6 @@ buildRules = ( ); dependencies = ( - 5CA059E5279559F40002BEB4 /* PBXTargetDependency */, ); name = "Tests macOS"; productName = "Tests macOS"; @@ -548,10 +434,6 @@ CreatedOnToolsVersion = 13.2.1; LastSwiftMigration = 1320; }; - 5CA059CF279559F40002BEB4 = { - CreatedOnToolsVersion = 13.2.1; - LastSwiftMigration = 1320; - }; 5CA059D6279559F40002BEB4 = { CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; @@ -569,6 +451,7 @@ knownRegions = ( en, Base, + ru, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -579,7 +462,6 @@ projectRoot = ""; targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, - 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, 5CA059E2279559F40002BEB4 /* Tests macOS */, ); @@ -592,14 +474,8 @@ buildActionMask = 2147483647; files = ( 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 5CA059CE279559F40002BEB4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */, + 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, + 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -674,60 +550,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059CC279559F40002BEB4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, - 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */, - 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, - 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, - 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, - 3CDBCF4327FAE51000354CDD /* ComposeLinkView.swift in Sources */, - 3CDBCF4927FF621E00354CDD /* ChatItemLinkView.swift in Sources */, - 5C764E81279C7276000C6508 /* dummy.m in Sources */, - 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, - 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */, - 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, - 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */, - 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, - 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, - 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, - 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, - 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, - 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */, - 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, - 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, - 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, - 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */, - 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */, - 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */, - 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */, - 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, - 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, - 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, - 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, - 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, - 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */, - 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, - 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, - 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */, - 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, - 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, - 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, - 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, - 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, - 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */, - 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */, - 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, - 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, - 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5CA059D3279559F40002BEB4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -741,8 +563,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */, - 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -754,13 +574,27 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; - 5CA059E5279559F40002BEB4 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */; - targetProxy = 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ +/* Begin PBXVariantGroup section */ + 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CC2C0FB2809BF11000C35E3 /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CC2C0FE2809BF11000C35E3 /* ru */, + ); + name = "SimpleX--iOS--InfoPlist.strings"; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 5CA059F1279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; @@ -817,6 +651,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -869,6 +704,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; @@ -953,83 +789,6 @@ }; name = Release; }; - 5CA059F7279559F40002BEB4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = "SimpleX (macOS)Debug.entitlements"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5NN7GUYB6T; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; - PRODUCT_NAME = SimpleX; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 5CA059F8279559F40002BEB4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5NN7GUYB6T; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; - PRODUCT_NAME = SimpleX; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; 5CA059FA279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1130,15 +889,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5CA059F7279559F40002BEB4 /* Debug */, - 5CA059F8279559F40002BEB4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme new file mode 100644 index 0000000000..a90949ee3b --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Tests macOS/Tests_macOS.swift b/apps/ios/Tests macOS/Tests_macOS.swift deleted file mode 100644 index ee05450dc0..0000000000 --- a/apps/ios/Tests macOS/Tests_macOS.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Tests_macOS.swift -// Tests macOS -// -// Created by Evgeny Poberezkin on 17/01/2022. -// - -import XCTest - -class Tests_macOS: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift b/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift deleted file mode 100644 index 84d51dadbd..0000000000 --- a/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Tests_macOSLaunchTests.swift -// Tests macOS -// -// Created by Evgeny Poberezkin on 17/01/2022. -// - -import XCTest - -class Tests_macOSLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/apps/ios/macOS/macOS.entitlements b/apps/ios/macOS/macOS.entitlements deleted file mode 100644 index f2ef3ae026..0000000000 --- a/apps/ios/macOS/macOS.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - - diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..2ceedaa689 --- /dev/null +++ b/apps/ios/ru.lproj/Localizable.strings @@ -0,0 +1,462 @@ +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (можно скопировать)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_курсив_"; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +": %@" = ": %@"; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 цвет!"; + +/* No comment provided by engineer. */ +"(shared only with your contacts)" = "(отправляется только вашим контактам)"; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[Отправить email](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Добавить новый контакт**: чтобы создать одноразовый QR код или ссылку для вашего контакта."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact who shows QR code to you." = "**Сканировать QR код**: чтобы соединиться с вашим контактом (который показывает вам QR код)."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*жирный*"; + +/* No comment provided by engineer. */ +"#secret#" = "#секрет#"; + +/* notification title */ +"%@ is connected!" = "Установлено соединение с %@!"; + +/* notification title */ +"%@ wants to connect!" = "%@ хочет соединиться!"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~зачеркнуть~"; + +/* No comment provided by engineer. */ +"💻 desktop: scan displayed QR code from the app, via **Scan QR code**." = "💻 на компьютере: сосканируйте QR код из приложения через **Сканировать QR код**."; + +/* No comment provided by engineer. */ +"📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app." = "📱 на мобильном: намжите кнопку **Open in mobile app** на веб странице, затем нажмите **Соединиться** в приложении."; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"above, then:" = "наверху, затем:"; + +/* accept contact request via notification */ +"Accept" = "Принять"; + +/* No comment provided by engineer. */ +"Accept contact" = "Принять запрос"; + +/* notification body */ +"Accept contact request from %@?" = "Принять запрос на соединение от %@?"; + +/* No comment provided by engineer. */ +"Add contact" = "Добавить контакт"; + +/* No comment provided by engineer. */ +"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся."; + +/* No comment provided by engineer. */ +"bold" = "жирный"; + +/* No comment provided by engineer. */ +"Cancel" = "Отменить"; + +/* No comment provided by engineer. */ +"Chat console" = "Консоль"; + +/* No comment provided by engineer. */ +"Chat with the developers" = "Соединиться с разработчиками"; + +/* back button to return to chats list */ +"Chats" = "Назад"; + +/* No comment provided by engineer. */ +"Choose from library" = "Выбрать из библиотеки"; + +/* No comment provided by engineer. */ +"colored" = "цвет"; + +/* No comment provided by engineer. */ +"Configure SMP servers" = "Настройка SMP серверов"; + +/* No comment provided by engineer. */ +"Confirm" = "Подтвердить"; + +/* No comment provided by engineer. */ +"Connect" = "Соединиться"; + +/* No comment provided by engineer. */ +"connect to SimpleX Chat developers." = "соединиться с разработчиками."; + +/* No comment provided by engineer. */ +"Connect via contact link?" = "Соединиться через ссылку-контакт?"; + +/* No comment provided by engineer. */ +"Connect via invitation link?" = "Соединиться через ссылку-приглашение?"; + +/* No comment provided by engineer. */ +"Connecting server…" = "Устанавливается соединение с сервером…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "Устанавливается соединение с сервером… (ошибка: %@)"; + +/* No comment provided by engineer. */ +"Connecting..." = "Устанавливается соединение…"; + +/* No comment provided by engineer. */ +"Connection error" = "Ошибка соединения"; + +/* No comment provided by engineer. */ +"Connection request" = "Запрос на соединение"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Запрос на соединение отправлен!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "Превышено время соединения"; + +/* No comment provided by engineer. */ +"Contact already exists" = "Существующий контакт"; + +/* No comment provided by engineer. */ +"Contact and all messages will be deleted - this cannot be undone!" = "Контакт и все сообщения будут удалены - это действие нельзя отменить!"; + +/* notification */ +"Contact is connected" = "Соединение с контактом установлено"; + +/* No comment provided by engineer. */ +"Copy" = "Скопировать"; + +/* No comment provided by engineer. */ +"Create" = "Создать"; + +/* No comment provided by engineer. */ +"Create address" = "Создать адрес"; + +/* No comment provided by engineer. */ +"Create group" = "Создать группу"; + +/* No comment provided by engineer. */ +"Create profile" = "Создать профиль"; + +/* No comment provided by engineer. */ +"Delete" = "Удалить"; + +/* No comment provided by engineer. */ +"Delete address" = "Удалить адрес"; + +/* No comment provided by engineer. */ +"Delete address?" = "Удалить адрес?"; + +/* No comment provided by engineer. */ +"Delete contact" = "Удалить контакт"; + +/* No comment provided by engineer. */ +"Delete contact?" = "Удалить контакт?"; + +/* No comment provided by engineer. */ +"Delete for me" = "Удалить для меня"; + +/* No comment provided by engineer. */ +"Delete group" = "Удалить группу"; + +/* No comment provided by engineer. */ +"Delete message?" = "Удалить сообщение?"; + +/* deleted chat item */ +"deleted" = "удалено"; + +/* No comment provided by engineer. */ +"Develop" = "Для разработчиков"; + +/* No comment provided by engineer. */ +"Display name" = "Имя профиля"; + +/* No comment provided by engineer. */ +"Edit" = "Редактировать"; + +/* No comment provided by engineer. */ +"Enter one SMP server per line:" = "Введите SMP серверы, каждый на отдельной строке:"; + +/* No comment provided by engineer. */ +"Error saving SMP servers" = "Ошибка при сохранении SMP серверов"; + +/* No comment provided by engineer. */ +"Error: %@" = "Ошибка: %@"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Ошибка: неверная ссылка"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "Полное имя (не обязательно)"; + +/* No comment provided by engineer. */ +"Group deletion is not supported" = "Удаление групп не поддерживается"; + +/* No comment provided by engineer. */ +"Help" = "Помощь"; + +/* No comment provided by engineer. */ +"How to" = "Информация"; + +/* No comment provided by engineer. */ +"How to use markdown" = "Как форматировать"; + +/* No comment provided by engineer. */ +"How to use SimpleX Chat" = "Как использовать SimpleX Chat"; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если вы не можете встретиться лично, вы можете **сосканировать QR код во время видеозвонка**, или ваш контакт может отправить вам ссылку."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel." = "Если вы не можете встретиться лично, вы можете **показать QR код во время видеозвонка** или отправить ссылку через любой другой канал связи."; + +/* No comment provided by engineer. */ +"If you received SimpleX Chat invitation link you can open it in your browser:" = "Если вы получили ссылку с приглашением из SimpleX Chat, вы можете открыть её в браузере:"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для терминала](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Ошибка в ссылке контакта"; + +/* No comment provided by engineer. */ +"italic" = "курсив"; + +/* No comment provided by engineer. */ +"Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "Пожалуйста, проверьте, что адреса SMP серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется."; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Форматирование сообщений"; + +/* No comment provided by engineer. */ +"Message delivery error" = "Ошибка доставки сообщения"; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Скорее всего, этот контакт удалил соединение с вами."; + +/* notification */ +"New contact request" = "Новый запрос на соединение"; + +/* notifications */ +"New message" = "Новое сообщение"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "Уведомления выключены"; + +/* No comment provided by engineer. */ +"Open Settings" = "Открыть Настройки"; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "Пожалуйста, проверьте, что вы использовали правильную ссылку или попросите, чтобы ваш контакт отправил вам другую ссылку."; + +/* No comment provided by engineer. */ +"Please check your network connection and try again." = "Пожалуйста, проверьте ваше соединение с сетью и попробуйте еще раз."; + +/* No comment provided by engineer. */ +"Profile image" = "Аватар"; + +/* No comment provided by engineer. */ +"Read" = "Прочитано"; + +/* to be removed */ +"receiving files is not supported yet" = "получение файлов не поддерживается"; + +/* No comment provided by engineer. */ +"Reject" = "Отклонить"; + +/* No comment provided by engineer. */ +"Reject contact (sender NOT notified)" = "Отклонить (не уведомляя отправителя)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "Отклонить запрос"; + +/* No comment provided by engineer. */ +"Reply" = "Ответить"; + +/* No comment provided by engineer. */ +"Save" = "Сохранить"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Сохранить (и уведомить контакты)"; + +/* No comment provided by engineer. */ +"Saved SMP servers will be removed" = "Сохраненные SMP серверы будут удалены"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Сканировать QR код"; + +/* No comment provided by engineer. */ +"secret" = "секрет"; + +/* to be removed */ +"sending files is not supported yet" = "отправка файлов не поддерживается"; + +/* No comment provided by engineer. */ +"Server connected" = "Установлено соединение с сервером"; + +/* No comment provided by engineer. */ +"Settings" = "Настройки"; + +/* No comment provided by engineer. */ +"Share" = "Поделиться"; + +/* No comment provided by engineer. */ +"Share invitation link" = "Поделиться ссылкой"; + +/* No comment provided by engineer. */ +"Share link" = "Поделиться ссылкой"; + +/* No comment provided by engineer. */ +"Show QR code to your contact\nto scan from the app" = "Покажите QR код вашему контакту для сканирования в приложении"; + +/* No comment provided by engineer. */ +"SMP servers" = "SMP серверы"; + +/* No comment provided by engineer. */ +"Start new chat" = "Начать новый разговор"; + +/* No comment provided by engineer. */ +"strike" = "зачеркнуть"; + +/* No comment provided by engineer. */ +"Take picture" = "Сделать фото"; + +/* No comment provided by engineer. */ +"Tap button " = "Нажмите кнопку"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Спасибо, что Вы установили SimpleX Chat!"; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках."; + +/* No comment provided by engineer. */ +"The messaging and application platform 100% private by design!" = "Платформа для сообщений и приложений, которая защищает вашу личную информацию и безопасность."; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "Отправитель не будет уведомлён"; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Задать вопросы и получать уведомления о новых версиях:"; + +/* No comment provided by engineer. */ +"To connect via link" = "Соединиться через ссылку"; + +/* No comment provided by engineer. */ +"To start a new chat" = "Начать новый разговор"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта."; + +/* No comment provided by engineer. */ +"Unexpected error: %@" = "Неожиданная ошибка: %@"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Использовать серверы предосталенные SimpleX Chat?"; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Используются серверы, предоставленные SimpleX Chat."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "хочет соединиться с вами!"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "Здравствуйте %@!"; + +/* No comment provided by engineer. */ +"You" = "Вы"; + +/* No comment provided by engineer. */ +"You are already connected to %@ via this link." = "Вы уже соединены с %@ через эту ссылку."; + +/* No comment provided by engineer. */ +"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получается сообщения от этого контакта."; + +/* notification body */ +"You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@"; + +/* No comment provided by engineer. */ +"You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." = "Вы можете использовать ваш адрес как ссылку или как QR код - кто угодно сможет соединиться с вами. Вы сможете удалить адрес, сохранив контакты, которые через него соединились."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Вы можете форматировать сообщения:"; + +/* No comment provided by engineer. */ +"You control your chat!" = "Вы котролируете Ваш чат!"; + +/* No comment provided by engineer. */ +"You will be connected when your connection request is accepted, please wait or check later!" = "Соединение будет установлено, когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Соединение будет установлено, когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!"; + +/* No comment provided by engineer. */ +"Your chat address" = "Ваш SimpleX адрес"; + +/* No comment provided by engineer. */ +"Your chat profile" = "Ваш профиль"; + +/* No comment provided by engineer. */ +"Your chat profile will be sent to your contact" = "Ваш профиль будет отправлен вашему контакту"; + +/* No comment provided by engineer. */ +"Your chats" = "Ваши чаты"; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Ваш профиль хранится на вашем устройстве и отправляется только вашим контактам.\nSimpleX серверы не могут получить доступ к вашему профилю."; + +/* No comment provided by engineer. */ +"Your profile will be sent to the contact that you received this link from" = "Ваш профиль будет отправлен контакту, от которого вы получили эту ссылку."; + +/* No comment provided by engineer. */ +"Your profile, contacts and messages (once delivered) are only stored locally on your device." = "Ваш профиль, контакты и сообщения (после доставки) хранятся только на вашем устройстве."; + +/* No comment provided by engineer. */ +"Your settings" = "Настройки"; + +/* No comment provided by engineer. */ +"Your SimpleX contact address" = "Ваш SimpleX адрес"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "Ваши SMP серверы"; + diff --git a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..a8fa77d9f6 --- /dev/null +++ b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX использует камеру для сканирования QR кодов при соединении с другими пользователями"; + From 757ca74482d340f79d9e8834937133dc3413ba6e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 16 Apr 2022 13:01:07 +0400 Subject: [PATCH 36/37] terminal: version 1.6.0 (#534) --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index f0917c04aa..835bf19a9f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.5.0 +version: 1.6.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d83866cedf..65c7ea92e2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.5.0 +version: 1.6.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From bc9a8bc32c64ea1dc9b9c1c36ee38b9fbccc52e0 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 16 Apr 2022 12:09:10 +0100 Subject: [PATCH 37/37] ios: remove "Tests MacOS" target --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 92 ---------------------- 1 file changed, 92 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 230ca7890e..2f299cf329 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -123,7 +123,6 @@ 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; - 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -169,13 +168,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059E0279559F40002BEB4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -288,7 +280,6 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, - 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */, ); name = Products; sourceTree = ""; @@ -402,23 +393,6 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - 5CA059E2279559F40002BEB4 /* Tests macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */; - buildPhases = ( - 5CA059DF279559F40002BEB4 /* Sources */, - 5CA059E0279559F40002BEB4 /* Frameworks */, - 5CA059E1279559F40002BEB4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Tests macOS"; - productName = "Tests macOS"; - productReference = 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -438,10 +412,6 @@ CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; }; - 5CA059E2279559F40002BEB4 = { - CreatedOnToolsVersion = 13.2.1; - TestTargetID = 5CA059CF279559F40002BEB4; - }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -463,7 +433,6 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, - 5CA059E2279559F40002BEB4 /* Tests macOS */, ); }; /* End PBXProject section */ @@ -486,13 +455,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059E1279559F40002BEB4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -559,13 +521,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059DF279559F40002BEB4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -830,44 +785,6 @@ }; name = Release; }; - 5CA059FD279559F40002BEB4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "SimpleX (macOS)"; - }; - name = Debug; - }; - 5CA059FE279559F40002BEB4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "SimpleX (macOS)"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -898,15 +815,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5CA059FD279559F40002BEB4 /* Debug */, - 5CA059FE279559F40002BEB4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */