diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 0f21beb0c..ddb3f1e79 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -11,7 +11,12 @@ 269E13522B594B2D008D1CA7 /* AccountFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269E13512B594B2D008D1CA7 /* AccountFooterView.swift */; }; 26A975FF2B7E843E0095C367 /* SelectTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A975FE2B7E843E0095C367 /* SelectTextView.swift */; }; 26A976012B7E852E0095C367 /* ChatSelectTextViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A976002B7E852E0095C367 /* ChatSelectTextViewFactory.swift */; }; + 3A075C9E2B98A3B100714E3B /* FilesPickerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A075C9D2B98A3B100714E3B /* FilesPickerKit */; }; 3A20D93B2AE7F316005475A6 /* AdamantTransactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A20D93A2AE7F316005475A6 /* AdamantTransactionDetails.swift */; }; + 3A2478AE2BB42967009D89E9 /* ChatDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2478AD2BB42967009D89E9 /* ChatDropView.swift */; }; + 3A2478B12BB45DF8009D89E9 /* StorageUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2478B02BB45DF8009D89E9 /* StorageUsageView.swift */; }; + 3A2478B32BB461A7009D89E9 /* StorageUsageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2478B22BB461A7009D89E9 /* StorageUsageViewModel.swift */; }; + 3A2478B52BB46617009D89E9 /* StorageUsageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2478B42BB46617009D89E9 /* StorageUsageFactory.swift */; }; 3A26D9352C3C1BE2003AD832 /* KlyWalletService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26D9342C3C1BE2003AD832 /* KlyWalletService.swift */; }; 3A26D9372C3C1C01003AD832 /* KlyWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26D9362C3C1C01003AD832 /* KlyWallet.swift */; }; 3A26D9392C3C1C62003AD832 /* KlyWalletFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26D9382C3C1C62003AD832 /* KlyWalletFactory.swift */; }; @@ -27,6 +32,15 @@ 3A26D94D2C3D387B003AD832 /* KlyTransactionDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26D94C2C3D387B003AD832 /* KlyTransactionDetailsViewController.swift */; }; 3A26D9502C3D3A5A003AD832 /* KlyWalletService+WalletCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A26D94F2C3D3A5A003AD832 /* KlyWalletService+WalletCore.swift */; }; 3A26D9522C3E7F1E003AD832 /* klayr_notificationContent.png in Resources */ = {isa = PBXBuildFile; fileRef = 3A26D9512C3E7F1D003AD832 /* klayr_notificationContent.png */; }; + 3A299C692B838AA600B54C61 /* ChatMediaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C682B838AA600B54C61 /* ChatMediaCell.swift */; }; + 3A299C6B2B838F2300B54C61 /* ChatMediaContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C6A2B838F2300B54C61 /* ChatMediaContainerView.swift */; }; + 3A299C6D2B838F8F00B54C61 /* ChatMediaContainerView+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C6C2B838F8F00B54C61 /* ChatMediaContainerView+Model.swift */; }; + 3A299C712B83975700B54C61 /* ChatMediaContnentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C702B83975700B54C61 /* ChatMediaContnentView.swift */; }; + 3A299C732B83975D00B54C61 /* ChatMediaContnentView+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C722B83975D00B54C61 /* ChatMediaContnentView+Model.swift */; }; + 3A299C762B84CE4100B54C61 /* FilesToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C752B84CE4100B54C61 /* FilesToolbarView.swift */; }; + 3A299C782B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C772B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift */; }; + 3A299C7B2B85EABB00B54C61 /* FileListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7A2B85EABB00B54C61 /* FileListContentView.swift */; }; + 3A299C7D2B85F98700B54C61 /* ChatFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7C2B85F98700B54C61 /* ChatFile.swift */; }; 3A2F55F92AC6F308000A3F26 /* CoinTransaction+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F55F72AC6F308000A3F26 /* CoinTransaction+CoreDataClass.swift */; }; 3A2F55FA2AC6F308000A3F26 /* CoinTransaction+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F55F82AC6F308000A3F26 /* CoinTransaction+CoreDataProperties.swift */; }; 3A2F55FC2AC6F885000A3F26 /* CoinStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A2F55FB2AC6F885000A3F26 /* CoinStorage.swift */; }; @@ -36,10 +50,14 @@ 3A41938F2A580C57006A6B22 /* AdamantRichTransactionReactService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A41938E2A580C57006A6B22 /* AdamantRichTransactionReactService.swift */; }; 3A4193912A580C85006A6B22 /* RichTransactionReactService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4193902A580C85006A6B22 /* RichTransactionReactService.swift */; }; 3A41939A2A5D554A006A6B22 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4193992A5D554A006A6B22 /* Reaction.swift */; }; + 3A53BD462C6B7AF100BB1EE6 /* DownloadPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A53BD452C6B7AF100BB1EE6 /* DownloadPolicy.swift */; }; + 3A5DF1792C4698EC0005369D /* EdgeInsetLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A5DF1782C4698EC0005369D /* EdgeInsetLabel.swift */; }; 3A770E4C2AE14F130008D98F /* SimpleTransactionDetails+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A770E4B2AE14F130008D98F /* SimpleTransactionDetails+Hashable.swift */; }; 3A7BD00E2AA9BCE80045AAB0 /* VibroService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7BD00D2AA9BCE80045AAB0 /* VibroService.swift */; }; 3A7BD0102AA9BD030045AAB0 /* AdamantVibroService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7BD00F2AA9BD030045AAB0 /* AdamantVibroService.swift */; }; 3A7BD0122AA9BD5A0045AAB0 /* AdamantVibroType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7BD0112AA9BD5A0045AAB0 /* AdamantVibroType.swift */; }; + 3A7FD6F52C076D86002AF7D9 /* FileMessageStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7FD6F42C076D85002AF7D9 /* FileMessageStatus.swift */; }; + 3A833C402B99CDA000238F6A /* FilesStorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A833C3F2B99CDA000238F6A /* FilesStorageKit */; }; 3A8875EF27BBF38D00436195 /* Parchment in Frameworks */ = {isa = PBXBuildFile; productRef = 3A8875EE27BBF38D00436195 /* Parchment */; }; 3A9015A52A614A18002A2464 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9015A42A614A18002A2464 /* EmojiService.swift */; }; 3A9015A72A614A62002A2464 /* AdamantEmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9015A62A614A62002A2464 /* AdamantEmojiService.swift */; }; @@ -58,11 +76,29 @@ 3AA50DEF2AEBE65D00C58FC8 /* PartnerQRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA50DEE2AEBE65D00C58FC8 /* PartnerQRView.swift */; }; 3AA50DF12AEBE66A00C58FC8 /* PartnerQRViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA50DF02AEBE66A00C58FC8 /* PartnerQRViewModel.swift */; }; 3AA50DF32AEBE67C00C58FC8 /* PartnerQRFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA50DF22AEBE67C00C58FC8 /* PartnerQRFactory.swift */; }; + 3AA6DF402BA9941E00EA2E16 /* MediaContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */; }; + 3AA6DF442BA997C000EA2E16 /* FileListContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF432BA997C000EA2E16 /* FileListContainerView.swift */; }; + 3AA6DF462BA9BEB700EA2E16 /* MediaContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */; }; + 3AB87CD62BF6237100AE8743 /* MultipartFormDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB87CD52BF6237100AE8743 /* MultipartFormDataModel.swift */; }; 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */; }; + 3ACD307E2BBD86B700ABF671 /* FilesStorageProprietiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACD307D2BBD86B700ABF671 /* FilesStorageProprietiesService.swift */; }; + 3ACD30802BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */; }; + 3AE0A42A2BC6A64900BF7125 /* FilesNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4272BC6A64900BF7125 /* FilesNetworkManager.swift */; }; + 3AE0A42B2BC6A64900BF7125 /* IPFSApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4282BC6A64900BF7125 /* IPFSApiService.swift */; }; + 3AE0A42C2BC6A64900BF7125 /* IPFSApiCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4292BC6A64900BF7125 /* IPFSApiCore.swift */; }; + 3AE0A42E2BC6A96B00BF7125 /* IPFS+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A42D2BC6A96A00BF7125 /* IPFS+Constants.swift */; }; + 3AE0A4312BC6A9C900BF7125 /* IPFSDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4302BC6A9C900BF7125 /* IPFSDTO.swift */; }; + 3AE0A4332BC6A9EB00BF7125 /* FileApiServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */; }; + 3AE0A4352BC6AA1B00BF7125 /* FileManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4342BC6AA1B00BF7125 /* FileManagerError.swift */; }; + 3AE0A4372BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */; }; 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF08D5E2B4EB3A200EB82B1 /* LanguageService.swift */; }; 3AF08D612B4EB3C400EB82B1 /* LanguageStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */; }; + 3AF0A6CA2BBAF5850019FF47 /* ChatFileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF0A6C92BBAF5850019FF47 /* ChatFileService.swift */; }; 3AF53F8D2B3DCFA300B30312 /* NodeGroup+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF53F8C2B3DCFA300B30312 /* NodeGroup+Constants.swift */; }; 3AF53F8F2B3EE0DA00B30312 /* DogeNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF53F8E2B3EE0DA00B30312 /* DogeNodeInfo.swift */; }; + 3AF8D9E92C73ADFA007A7CBC /* IPFSNodeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF8D9E82C73ADFA007A7CBC /* IPFSNodeStatus.swift */; }; + 3AF9DF0B2BFE306C009A43A8 /* ChatFileProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.swift */; }; + 3AF9DF0D2C049161009A43A8 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF9DF0C2C049161009A43A8 /* CircularProgressView.swift */; }; 3AFE7E412B18D88B00718739 /* WalletService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFE7E402B18D88B00718739 /* WalletService.swift */; }; 3AFE7E432B19E4D900718739 /* WalletServiceCompose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFE7E422B19E4D900718739 /* WalletServiceCompose.swift */; }; 3AFE7E522B1F6B3400718739 /* WalletServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFE7E512B1F6B3400718739 /* WalletServiceProtocol.swift */; }; @@ -100,7 +136,6 @@ 4186B338294200E8006594A3 /* DogeWalletService+DynamicConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4186B337294200E8006594A3 /* DogeWalletService+DynamicConstants.swift */; }; 4186B33A294200F4006594A3 /* DashWalletService+DynamicConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4186B339294200F4006594A3 /* DashWalletService+DynamicConstants.swift */; }; 418FDE502A25CA340055E3CD /* ChatMenuManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 418FDE4F2A25CA340055E3CD /* ChatMenuManager.swift */; }; - 41935848287841E20083363B /* MacOSDeterminer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41935847287841E20083363B /* MacOSDeterminer.swift */; }; 4193AE1629FBEFBF002F21BE /* NSAttributedText+Adamant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4193AE1529FBEFBF002F21BE /* NSAttributedText+Adamant.swift */; }; 4197B9C92952FAFF004CAF64 /* VisibleWalletsCheckmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4197B9C82952FAFF004CAF64 /* VisibleWalletsCheckmarkView.swift */; }; 4198D57B28C8B7DA009337F2 /* so-proud-notification.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 4198D57A28C8B7DA009337F2 /* so-proud-notification.mp3 */; }; @@ -663,6 +698,10 @@ 33975C0D891698AA7E74EBCC /* Pods_Adamant.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Adamant.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 36AB8CE9537B3B873972548B /* Pods_AdmCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AdmCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3A20D93A2AE7F316005475A6 /* AdamantTransactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdamantTransactionDetails.swift; sourceTree = ""; }; + 3A2478AD2BB42967009D89E9 /* ChatDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDropView.swift; sourceTree = ""; }; + 3A2478B02BB45DF8009D89E9 /* StorageUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageUsageView.swift; sourceTree = ""; }; + 3A2478B22BB461A7009D89E9 /* StorageUsageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageUsageViewModel.swift; sourceTree = ""; }; + 3A2478B42BB46617009D89E9 /* StorageUsageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageUsageFactory.swift; sourceTree = ""; }; 3A26D9342C3C1BE2003AD832 /* KlyWalletService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlyWalletService.swift; sourceTree = ""; }; 3A26D9362C3C1C01003AD832 /* KlyWallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlyWallet.swift; sourceTree = ""; }; 3A26D9382C3C1C62003AD832 /* KlyWalletFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlyWalletFactory.swift; sourceTree = ""; }; @@ -678,6 +717,15 @@ 3A26D94C2C3D387B003AD832 /* KlyTransactionDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlyTransactionDetailsViewController.swift; sourceTree = ""; }; 3A26D94F2C3D3A5A003AD832 /* KlyWalletService+WalletCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KlyWalletService+WalletCore.swift"; sourceTree = ""; }; 3A26D9512C3E7F1D003AD832 /* klayr_notificationContent.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = klayr_notificationContent.png; sourceTree = ""; }; + 3A299C682B838AA600B54C61 /* ChatMediaCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaCell.swift; sourceTree = ""; }; + 3A299C6A2B838F2300B54C61 /* ChatMediaContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaContainerView.swift; sourceTree = ""; }; + 3A299C6C2B838F8F00B54C61 /* ChatMediaContainerView+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatMediaContainerView+Model.swift"; sourceTree = ""; }; + 3A299C702B83975700B54C61 /* ChatMediaContnentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaContnentView.swift; sourceTree = ""; }; + 3A299C722B83975D00B54C61 /* ChatMediaContnentView+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatMediaContnentView+Model.swift"; sourceTree = ""; }; + 3A299C752B84CE4100B54C61 /* FilesToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesToolbarView.swift; sourceTree = ""; }; + 3A299C772B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesToolbarCollectionViewCell.swift; sourceTree = ""; }; + 3A299C7A2B85EABB00B54C61 /* FileListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileListContentView.swift; sourceTree = ""; }; + 3A299C7C2B85F98700B54C61 /* ChatFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFile.swift; sourceTree = ""; }; 3A2F55F72AC6F308000A3F26 /* CoinTransaction+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoinTransaction+CoreDataClass.swift"; sourceTree = ""; }; 3A2F55F82AC6F308000A3F26 /* CoinTransaction+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoinTransaction+CoreDataProperties.swift"; sourceTree = ""; }; 3A2F55FB2AC6F885000A3F26 /* CoinStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinStorage.swift; sourceTree = ""; }; @@ -687,10 +735,13 @@ 3A41938E2A580C57006A6B22 /* AdamantRichTransactionReactService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdamantRichTransactionReactService.swift; sourceTree = ""; }; 3A4193902A580C85006A6B22 /* RichTransactionReactService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichTransactionReactService.swift; sourceTree = ""; }; 3A4193992A5D554A006A6B22 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; + 3A53BD452C6B7AF100BB1EE6 /* DownloadPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadPolicy.swift; sourceTree = ""; }; + 3A5DF1782C4698EC0005369D /* EdgeInsetLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsetLabel.swift; sourceTree = ""; }; 3A770E4B2AE14F130008D98F /* SimpleTransactionDetails+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SimpleTransactionDetails+Hashable.swift"; sourceTree = ""; }; 3A7BD00D2AA9BCE80045AAB0 /* VibroService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibroService.swift; sourceTree = ""; }; 3A7BD00F2AA9BD030045AAB0 /* AdamantVibroService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdamantVibroService.swift; sourceTree = ""; }; 3A7BD0112AA9BD5A0045AAB0 /* AdamantVibroType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdamantVibroType.swift; sourceTree = ""; }; + 3A7FD6F42C076D85002AF7D9 /* FileMessageStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileMessageStatus.swift; sourceTree = ""; }; 3A9015A42A614A18002A2464 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; 3A9015A62A614A62002A2464 /* AdamantEmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdamantEmojiService.swift; sourceTree = ""; }; 3A9015A82A615893002A2464 /* ChatMessagesListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessagesListViewModel.swift; sourceTree = ""; }; @@ -708,13 +759,31 @@ 3AA50DEE2AEBE65D00C58FC8 /* PartnerQRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerQRView.swift; sourceTree = ""; }; 3AA50DF02AEBE66A00C58FC8 /* PartnerQRViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerQRViewModel.swift; sourceTree = ""; }; 3AA50DF22AEBE67C00C58FC8 /* PartnerQRFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerQRFactory.swift; sourceTree = ""; }; + 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainerView.swift; sourceTree = ""; }; + 3AA6DF432BA997C000EA2E16 /* FileListContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileListContainerView.swift; sourceTree = ""; }; + 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContentView.swift; sourceTree = ""; }; + 3AB87CD52BF6237100AE8743 /* MultipartFormDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartFormDataModel.swift; sourceTree = ""; }; + 3ACD307D2BBD86B700ABF671 /* FilesStorageProprietiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesStorageProprietiesService.swift; sourceTree = ""; }; + 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesStorageProprietiesProtocol.swift; sourceTree = ""; }; + 3AE0A4272BC6A64900BF7125 /* FilesNetworkManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FilesNetworkManager.swift; sourceTree = ""; }; + 3AE0A4282BC6A64900BF7125 /* IPFSApiService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPFSApiService.swift; sourceTree = ""; }; + 3AE0A4292BC6A64900BF7125 /* IPFSApiCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IPFSApiCore.swift; sourceTree = ""; }; + 3AE0A42D2BC6A96A00BF7125 /* IPFS+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IPFS+Constants.swift"; sourceTree = ""; }; + 3AE0A4302BC6A9C900BF7125 /* IPFSDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPFSDTO.swift; sourceTree = ""; }; + 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileApiServiceProtocol.swift; sourceTree = ""; }; + 3AE0A4342BC6AA1B00BF7125 /* FileManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerError.swift; sourceTree = ""; }; + 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesNetworkManagerProtocol.swift; sourceTree = ""; }; 3AF08D5B2B4E7FFC00EB82B1 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/InfoPlist.strings; sourceTree = ""; }; 3AF08D5C2B4E7FFC00EB82B1 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = zh; path = zh.lproj/Localizable.stringsdict; sourceTree = ""; }; 3AF08D5D2B4E7FFC00EB82B1 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/Localizable.strings; sourceTree = ""; }; 3AF08D5E2B4EB3A200EB82B1 /* LanguageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageService.swift; sourceTree = ""; }; 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageStorageProtocol.swift; sourceTree = ""; }; + 3AF0A6C92BBAF5850019FF47 /* ChatFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileService.swift; sourceTree = ""; }; 3AF53F8C2B3DCFA300B30312 /* NodeGroup+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NodeGroup+Constants.swift"; sourceTree = ""; }; 3AF53F8E2B3EE0DA00B30312 /* DogeNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DogeNodeInfo.swift; sourceTree = ""; }; + 3AF8D9E82C73ADFA007A7CBC /* IPFSNodeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPFSNodeStatus.swift; sourceTree = ""; }; + 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileProtocol.swift; sourceTree = ""; }; + 3AF9DF0C2C049161009A43A8 /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 3AFE7E402B18D88B00718739 /* WalletService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletService.swift; sourceTree = ""; }; 3AFE7E422B19E4D900718739 /* WalletServiceCompose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletServiceCompose.swift; sourceTree = ""; }; 3AFE7E512B1F6B3400718739 /* WalletServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletServiceProtocol.swift; sourceTree = ""; }; @@ -747,7 +816,6 @@ 4186B337294200E8006594A3 /* DogeWalletService+DynamicConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DogeWalletService+DynamicConstants.swift"; sourceTree = ""; }; 4186B339294200F4006594A3 /* DashWalletService+DynamicConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashWalletService+DynamicConstants.swift"; sourceTree = ""; }; 418FDE4F2A25CA340055E3CD /* ChatMenuManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMenuManager.swift; sourceTree = ""; }; - 41935847287841E20083363B /* MacOSDeterminer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacOSDeterminer.swift; sourceTree = ""; }; 4193AE1529FBEFBF002F21BE /* NSAttributedText+Adamant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedText+Adamant.swift"; sourceTree = ""; }; 4197B9C82952FAFF004CAF64 /* VisibleWalletsCheckmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleWalletsCheckmarkView.swift; sourceTree = ""; }; 4198D57A28C8B7DA009337F2 /* so-proud-notification.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "so-proud-notification.mp3"; sourceTree = ""; }; @@ -1243,6 +1311,7 @@ A50AEB0C262C81E300B37C22 /* QRCodeReader in Frameworks */, 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, A5F92994262C855B00C3E60A /* MarkdownKit in Frameworks */, + 3A833C402B99CDA000238F6A /* FilesStorageKit in Frameworks */, A50AEB04262C815200B37C22 /* EFQRCode in Frameworks */, A544F0D4262C9878001F1A6D /* Eureka in Frameworks */, 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */, @@ -1263,6 +1332,7 @@ 4184F1712A33044E00D7B8B9 /* FirebaseCrashlytics in Frameworks */, A5DBBAEE262C72EF004AC028 /* BitcoinKit in Frameworks */, A57282CA262C94CD00C96FA8 /* DateToolsSwift in Frameworks */, + 3A075C9E2B98A3B100714E3B /* FilesPickerKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1323,6 +1393,16 @@ path = Models; sourceTree = ""; }; + 3A2478AF2BB45DE2009D89E9 /* StorageUsage */ = { + isa = PBXGroup; + children = ( + 3A2478B42BB46617009D89E9 /* StorageUsageFactory.swift */, + 3A2478B02BB45DF8009D89E9 /* StorageUsageView.swift */, + 3A2478B22BB461A7009D89E9 /* StorageUsageViewModel.swift */, + ); + path = StorageUsage; + sourceTree = ""; + }; 3A26D9312C3C1B55003AD832 /* Klayr */ = { isa = PBXGroup; children = ( @@ -1353,6 +1433,53 @@ path = WalletService; sourceTree = ""; }; + 3A299C672B838A7800B54C61 /* ChatMedia */ = { + isa = PBXGroup; + children = ( + 3A299C682B838AA600B54C61 /* ChatMediaCell.swift */, + 3A299C6F2B83901600B54C61 /* Container */, + 3A299C6E2B83901000B54C61 /* Content */, + ); + path = ChatMedia; + sourceTree = ""; + }; + 3A299C6E2B83901000B54C61 /* Content */ = { + isa = PBXGroup; + children = ( + 3A299C792B85EAA900B54C61 /* Views */, + 3A299C702B83975700B54C61 /* ChatMediaContnentView.swift */, + 3A299C722B83975D00B54C61 /* ChatMediaContnentView+Model.swift */, + ); + path = Content; + sourceTree = ""; + }; + 3A299C6F2B83901600B54C61 /* Container */ = { + isa = PBXGroup; + children = ( + 3A299C6A2B838F2300B54C61 /* ChatMediaContainerView.swift */, + 3A299C6C2B838F8F00B54C61 /* ChatMediaContainerView+Model.swift */, + ); + path = Container; + sourceTree = ""; + }; + 3A299C742B84CE1400B54C61 /* FilesToolBarView */ = { + isa = PBXGroup; + children = ( + 3A299C752B84CE4100B54C61 /* FilesToolbarView.swift */, + 3A299C772B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift */, + ); + path = FilesToolBarView; + sourceTree = ""; + }; + 3A299C792B85EAA900B54C61 /* Views */ = { + isa = PBXGroup; + children = ( + 3AA6DF422BA9943500EA2E16 /* MediaContainerView */, + 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */, + ); + path = Views; + sourceTree = ""; + }; 3A41938D2A580C3B006A6B22 /* RichTransactionReactService */ = { isa = PBXGroup; children = ( @@ -1387,6 +1514,45 @@ path = PartnerQR; sourceTree = ""; }; + 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */ = { + isa = PBXGroup; + children = ( + 3AA6DF432BA997C000EA2E16 /* FileListContainerView.swift */, + 3A299C7A2B85EABB00B54C61 /* FileListContentView.swift */, + ); + path = ChatFileContainerView; + sourceTree = ""; + }; + 3AA6DF422BA9943500EA2E16 /* MediaContainerView */ = { + isa = PBXGroup; + children = ( + 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */, + 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */, + ); + path = MediaContainerView; + sourceTree = ""; + }; + 3AE0A4262BC6A64900BF7125 /* FilesNetworkManager */ = { + isa = PBXGroup; + children = ( + 3AE0A42F2BC6A9BC00BF7125 /* Models */, + 3AE0A4272BC6A64900BF7125 /* FilesNetworkManager.swift */, + 3AE0A4282BC6A64900BF7125 /* IPFSApiService.swift */, + 3AE0A4292BC6A64900BF7125 /* IPFSApiCore.swift */, + 3AE0A42D2BC6A96A00BF7125 /* IPFS+Constants.swift */, + ); + path = FilesNetworkManager; + sourceTree = ""; + }; + 3AE0A42F2BC6A9BC00BF7125 /* Models */ = { + isa = PBXGroup; + children = ( + 3AE0A4302BC6A9C900BF7125 /* IPFSDTO.swift */, + 3AE0A4342BC6AA1B00BF7125 /* FileManagerError.swift */, + ); + path = Models; + sourceTree = ""; + }; 3AFE7E502B1F6AFE00718739 /* WalletsService */ = { isa = PBXGroup; children = ( @@ -1423,6 +1589,10 @@ children = ( 41330F7529F1509400CB587C /* AdamantCellAnimation.swift */, 416380E02A51765F00F90E6D /* ChatReactionsView.swift */, + 3AF9DF0C2C049161009A43A8 /* CircularProgressView.swift */, + 3A2478AD2BB42967009D89E9 /* ChatDropView.swift */, + 3A299C7C2B85F98700B54C61 /* ChatFile.swift */, + 3A7FD6F42C076D85002AF7D9 /* FileMessageStatus.swift */, ); path = Helpers; sourceTree = ""; @@ -1729,6 +1899,7 @@ 93A118522993241D00E144CC /* ChatMessagesListFactory.swift */, 9322E87A2970431200B8357C /* ChatMessageFactory.swift */, 9399F5EC29A85A48006C3E30 /* ChatCacheService.swift */, + 3AF0A6C92BBAF5850019FF47 /* ChatFileService.swift */, 3A9015A82A615893002A2464 /* ChatMessagesListViewModel.swift */, ); path = ViewModel; @@ -1750,6 +1921,8 @@ 93996A9829682690008D080B /* Subviews */ = { isa = PBXGroup; children = ( + 3A299C742B84CE1400B54C61 /* FilesToolBarView */, + 3A299C672B838A7800B54C61 /* ChatMedia */, 41A1995029D42C160031AD75 /* ChatBaseMessage */, 413AD21A29CDDD750025F255 /* ChatReply */, 9377FBE0296C2AB700C9211B /* ChatTransaction */, @@ -2001,6 +2174,10 @@ 3A96E37B2AED27F8001F5A52 /* PartnerQRService.swift */, 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */, 265AA1612B74E6B900CF98B0 /* ChatPreservation.swift */, + 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */, + 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */, + 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */, + 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.swift */, ); path = ServiceProtocols; sourceTree = ""; @@ -2008,6 +2185,7 @@ E913C9061FFFA92E001A83F7 /* Services */ = { isa = PBXGroup; children = ( + 3AE0A4262BC6A64900BF7125 /* FilesNetworkManager */, 3A41938D2A580C3B006A6B22 /* RichTransactionReactService */, 41C1698A29E7F2EE00FEB3CB /* RichTransactionReplyService */, 935F53D429BE8F4800779492 /* RichTransactionStatusService */, @@ -2038,6 +2216,7 @@ 3A2F55FD2AC6F90E000A3F26 /* AdamantCoinStorageService.swift */, 3A96E3792AED27D7001F5A52 /* AdamantPartnerQRService.swift */, 3AF08D5E2B4EB3A200EB82B1 /* LanguageService.swift */, + 3ACD307D2BBD86B700ABF671 /* FilesStorageProprietiesService.swift */, ); path = Services; sourceTree = ""; @@ -2061,6 +2240,7 @@ E95F85682006AB9D0070534A /* NormalizedTransaction.swift */, 93E5D4DA293000BE00439298 /* UnregisteredTransaction.swift */, 5558A437282AB9390024DDD6 /* NodeStatus.swift */, + 3AF8D9E82C73ADFA007A7CBC /* IPFSNodeStatus.swift */, E9FCA1E5218334C00005E83D /* SimpleTransactionDetails.swift */, E971591921681D6900A5F904 /* TransactionStatus.swift */, 648C696E22915A12006645F5 /* DashTransaction.swift */, @@ -2079,6 +2259,8 @@ 3AA388022B67F47600125684 /* RPCResponseModel.swift */, 3AA3880D2B6A356900125684 /* RpcRequestModel.swift */, 936658902B0AB9DC00BDB2D3 /* NodeWithGroup.swift */, + 3AB87CD52BF6237100AE8743 /* MultipartFormDataModel.swift */, + 3A53BD452C6B7AF100BB1EE6 /* DownloadPolicy.swift */, ); path = Models; sourceTree = ""; @@ -2102,7 +2284,6 @@ E9256F5E2034C21100DE86E9 /* String+localized.swift */, E98FC34320F920BD00032D65 /* UIFont+adamant.swift */, 645AE06521E67D3300AD3623 /* UITextField+adamant.swift */, - 41935847287841E20083363B /* MacOSDeterminer.swift */, 4E9EE86E28CE793D008359F7 /* SafeDecimalRow.swift */, 9345769428FD0C34004E6C7A /* UIViewController+email.swift */, 41CE1539297FF98200CC9254 /* Web3Swift+Adamant.swift */, @@ -2117,6 +2298,7 @@ 936658942B0AC15300BDB2D3 /* Node+UI.swift */, 3AF53F8C2B3DCFA300B30312 /* NodeGroup+Constants.swift */, 3AA3880B2B69201B00125684 /* ADM+JsonDecode.swift */, + 3A5DF1782C4698EC0005369D /* EdgeInsetLabel.swift */, ); path = Helpers; sourceTree = ""; @@ -2141,6 +2323,7 @@ E919479920000FFD001362F8 /* Modules */ = { isa = PBXGroup; children = ( + 3A2478AF2BB45DE2009D89E9 /* StorageUsage */, 9366588B2B0AB68300BDB2D3 /* CoinsNodesList */, 3AA50DED2AEBE61C00C58FC8 /* PartnerQR */, 93ADE06D2ACA66AF008ED641 /* TestVibration */, @@ -2622,6 +2805,8 @@ 4177E5E02A52DA7100C089FE /* AdvancedContextMenuKit */, 9342F6C12A6A35E300A9B39F /* CommonKit */, 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, + 3A075C9D2B98A3B100714E3B /* FilesPickerKit */, + 3A833C3F2B99CDA000238F6A /* FilesStorageKit */, ); productName = Adamant; productReference = E913C8EE1FFFA51D001A83F7 /* Adamant.app */; @@ -3027,6 +3212,7 @@ 3A26D93B2C3C1C97003AD832 /* KlyApiCore.swift in Sources */, 936658A32B0ADE4400BDB2D3 /* CoinsNodesListView+Row.swift in Sources */, 648CE3A6229AD1CD0070A2CC /* DashWalletService+Send.swift in Sources */, + 3AF9DF0B2BFE306C009A43A8 /* ChatFileProtocol.swift in Sources */, E987024920C2B1F700E393F4 /* AdamantChatsProvider+fakeMessages.swift in Sources */, 6403F5E222723F7500D58779 /* DashWallet.swift in Sources */, 26A975FF2B7E843E0095C367 /* SelectTextView.swift in Sources */, @@ -3053,11 +3239,14 @@ E91947B020002393001362F8 /* AdamantApiService.swift in Sources */, E921597B206503000000CA5C /* ButtonsStripeView.swift in Sources */, 93FC16A12B01DE120062B507 /* ERC20ApiService.swift in Sources */, + 3A299C692B838AA600B54C61 /* ChatMediaCell.swift in Sources */, 93294B9A2AAD624100911109 /* WalletFactoryCompose.swift in Sources */, 41C1698E29E7F36900FEB3CB /* AdamantRichTransactionReplyService.swift in Sources */, 649D6BF221C27D5C009E727B /* SearchResultsViewController.swift in Sources */, E9E7CD8D20026B6600DFC4DB /* DialogService.swift in Sources */, + 3ACD30802BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift in Sources */, E9E7CDB72003994E00DFC4DB /* AdamantUtilities+extended.swift in Sources */, + 3A2478B32BB461A7009D89E9 /* StorageUsageViewModel.swift in Sources */, E9147B6320505C7500145913 /* QRCodeReader+adamant.swift in Sources */, E90055F720EC200900D0CB2D /* SecurityViewController.swift in Sources */, 939FA3422B0D6F0000710EC6 /* SelfRemovableHostingController.swift in Sources */, @@ -3066,21 +3255,25 @@ 4184F1772A33173100D7B8B9 /* ContributeView.swift in Sources */, 3A7BD0122AA9BD5A0045AAB0 /* AdamantVibroType.swift in Sources */, E921597520611A6A0000CA5C /* AdamantReachability.swift in Sources */, + 3A2478AE2BB42967009D89E9 /* ChatDropView.swift in Sources */, E9960B3321F5154300C840A8 /* BaseAccount+CoreDataClass.swift in Sources */, E9FCA1E6218334C00005E83D /* SimpleTransactionDetails.swift in Sources */, 41A1995829D5733D0031AD75 /* ChatMessageCell+Model.swift in Sources */, + 3A299C7D2B85F98700B54C61 /* ChatFile.swift in Sources */, 932F77592989F999006D8801 /* ChatCellManager.swift in Sources */, 9377FBE2296C2ACA00C9211B /* ChatTransactionContentView+Model.swift in Sources */, E933475B225539390083839E /* DogeGetTransactionsResponse.swift in Sources */, 9340078029AC341100A20622 /* ChatAction.swift in Sources */, 648DD7A02236A59200B811FD /* DogeTransactionDetailsViewController.swift in Sources */, 93ADC17D2B083C3B00F2DF77 /* NodesAdditionalParamsStorageProtocol.swift in Sources */, + 3AA6DF402BA9941E00EA2E16 /* MediaContainerView.swift in Sources */, 938A46A42AE6103E00FC03DB /* HealthCheckWrapper.swift in Sources */, 557AC308287B1365004699D7 /* CheckmarkRowView.swift in Sources */, 9390C5052976B53000270CDF /* ChatDialog.swift in Sources */, 6455E9F321075D8000B2E94C /* AdamantAddressBookService.swift in Sources */, 3A26D9472C3D37B5003AD832 /* KlyWalletViewController.swift in Sources */, 9324C75E297170600022D7EA /* TransactionStatusService.swift in Sources */, + 3A53BD462C6B7AF100BB1EE6 /* DownloadPolicy.swift in Sources */, 9304F8BE292F88F900173F18 /* ANSPayload.swift in Sources */, 41CA598C29A0D84F002BFDE4 /* TaskManager.swift in Sources */, E9E7CD9120026FA100DFC4DB /* AppAssembly.swift in Sources */, @@ -3102,6 +3295,7 @@ 3AA388052B67F4DD00125684 /* BtcBlockchainInfoDTO.swift in Sources */, 93547BCA29E2262D00B0914B /* WelcomeViewController.swift in Sources */, 41047B74294C61D10039E956 /* VisibleWalletsService.swift in Sources */, + 3AE0A42A2BC6A64900BF7125 /* FilesNetworkManager.swift in Sources */, 648CE3AC229AD2190070A2CC /* DashTransferViewController.swift in Sources */, 3A7BD0102AA9BD030045AAB0 /* AdamantVibroService.swift in Sources */, A5E04227282A8BDC0076CD13 /* BtcBalanceResponse.swift in Sources */, @@ -3126,6 +3320,8 @@ 93C794482B0778C700408826 /* DashGetBlockDTO.swift in Sources */, 6449BA6D235CA0930033B936 /* ERC20TransactionsViewController.swift in Sources */, E983AE2A20E65F3200497E1A /* AccountViewController.swift in Sources */, + 3A299C762B84CE4100B54C61 /* FilesToolbarView.swift in Sources */, + 3A299C712B83975700B54C61 /* ChatMediaContnentView.swift in Sources */, 6449BA70235CA0930033B936 /* ERC20WalletFactory.swift in Sources */, 4184F1752A33106200D7B8B9 /* CrashlysticsService.swift in Sources */, 4197B9C92952FAFF004CAF64 /* VisibleWalletsCheckmarkView.swift in Sources */, @@ -3142,6 +3338,7 @@ 93294B872AAD0E0A00911109 /* AdmWallet.swift in Sources */, 6449BA6B235CA0930033B936 /* ERC20TransactionDetailsViewController.swift in Sources */, E907350E2256779C00BF02CC /* DogeMainnet.swift in Sources */, + 3A299C732B83975D00B54C61 /* ChatMediaContnentView+Model.swift in Sources */, 41CE153A297FF98200CC9254 /* Web3Swift+Adamant.swift in Sources */, 93CC8DC9296F01DE003772BF /* ChatTransactionContainerView+Model.swift in Sources */, E9147B6F205088DE00145913 /* LoginViewController+Pinpad.swift in Sources */, @@ -3199,6 +3396,7 @@ 938F7D5D2955C8F9001915CA /* ChatLayoutManager.swift in Sources */, 6403F5DE22723C6800D58779 /* DashMainnet.swift in Sources */, E940088B2114F63000CD2D67 /* NSRegularExpression+adamant.swift in Sources */, + 3AE0A4372BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift in Sources */, 932B34E92974AA4A002A75BA /* ChatPreservationProtocol.swift in Sources */, 3AA388032B67F47600125684 /* RPCResponseModel.swift in Sources */, E9484B7F2285C016008E10F0 /* PKGeneratorViewController.swift in Sources */, @@ -3212,10 +3410,12 @@ E9E7CDB32002B9FB00DFC4DB /* LoginFactory.swift in Sources */, E941CCDE20E7B70200C96220 /* WalletCollectionViewCell.swift in Sources */, 4186B33A294200F4006594A3 /* DashWalletService+DynamicConstants.swift in Sources */, + 3A299C7B2B85EABB00B54C61 /* FileListContentView.swift in Sources */, 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */, E9AA8BFA212C166600F9249F /* EthWalletService+Send.swift in Sources */, 411743042A39B257008CD98A /* ContributeViewModel.swift in Sources */, 93E5D4DB293000BE00439298 /* UnregisteredTransaction.swift in Sources */, + 3AB87CD62BF6237100AE8743 /* MultipartFormDataModel.swift in Sources */, 411DB8332A14D01F006AB158 /* ChatKeyboardManager.swift in Sources */, 6449BA68235CA0930033B936 /* ERC20WalletService.swift in Sources */, 3A9365A92C41332F0073D9A7 /* KLYWalletService+DynamicConstants.swift in Sources */, @@ -3231,23 +3431,29 @@ E95F85802008C8D70070534A /* ChatListFactory.swift in Sources */, 41A1994429D2D3CF0031AD75 /* MessageModel.swift in Sources */, 93775E462A674FA9009061AC /* Markdown+Adamant.swift in Sources */, + 3AE0A42C2BC6A64900BF7125 /* IPFSApiCore.swift in Sources */, 3AA3880E2B6A356900125684 /* RpcRequestModel.swift in Sources */, 93E8EDCD2AF1BD65003E163C /* AdamantApiCore.swift in Sources */, E9942B87203D9E5100C163AF /* EurekaQRRow.swift in Sources */, + 3ACD307E2BBD86B700ABF671 /* FilesStorageProprietiesService.swift in Sources */, 3AA50DF32AEBE67C00C58FC8 /* PartnerQRFactory.swift in Sources */, E9AA8C02212C5BF500F9249F /* AdmWalletService+Send.swift in Sources */, E90847332196FEA80095825D /* TransferTransaction+CoreDataProperties.swift in Sources */, 9366588D2B0AB6BD00BDB2D3 /* CoinsNodesListState.swift in Sources */, E99818942120892F0018C84C /* WalletViewControllerBase.swift in Sources */, + 3AA6DF462BA9BEB700EA2E16 /* MediaContentView.swift in Sources */, E9B3D39E201F99F40019EB36 /* DataProvider.swift in Sources */, 93BF4A6C29E4B4BF00505CD0 /* DelegatesBottomPanel+Model.swift in Sources */, 93294B882AAD0E0A00911109 /* AdmWalletService.swift in Sources */, 648DD7A82239147800B811FD /* DogeWalletService+RichMessageProvider.swift in Sources */, + 3A2478B52BB46617009D89E9 /* StorageUsageFactory.swift in Sources */, + 3AE0A4352BC6AA1B00BF7125 /* FileManagerError.swift in Sources */, E9E7CDC02003AF6D00DFC4DB /* AdamantCellFactory.swift in Sources */, E9DFB71C21624C9200CF8C7C /* AdmTransactionDetailsViewController.swift in Sources */, 6403F5E022723F6400D58779 /* DashWalletFactory.swift in Sources */, E94008722114EACF00CD2D67 /* WalletAccount.swift in Sources */, 3A7BD00E2AA9BCE80045AAB0 /* VibroService.swift in Sources */, + 3AF9DF0D2C049161009A43A8 /* CircularProgressView.swift in Sources */, E93B0D742028B21400126346 /* ChatsProvider.swift in Sources */, E9CAE8D42018AC1800345E76 /* AdamantApi+Keys.swift in Sources */, E9C51EEF20139DC600385EB7 /* TransactionIdResponse.swift in Sources */, @@ -3260,6 +3466,7 @@ 648CE3A222999CE70070A2CC /* BTCRawTransaction.swift in Sources */, 648DD79E2236A0B500B811FD /* DogeTransactionsViewController.swift in Sources */, 3A26D9392C3C1C62003AD832 /* KlyWalletFactory.swift in Sources */, + 3A299C6B2B838F2300B54C61 /* ChatMediaContainerView.swift in Sources */, 64B5736F2209B892005DC968 /* BtcTransactionDetailsViewController.swift in Sources */, 938F7D612955C92B001915CA /* ChatDataSourceManager.swift in Sources */, E96D64C82295C44400CA5587 /* Data+utilites.swift in Sources */, @@ -3272,8 +3479,10 @@ 93E5D4E02930029300439298 /* AdamantCore+Extensions.swift in Sources */, 41047B72294B5F210039E956 /* VisibleWalletsTableViewCell.swift in Sources */, E90847392196FEF50095825D /* BaseTransaction+TransactionDetails.swift in Sources */, + 3AE0A4312BC6A9C900BF7125 /* IPFSDTO.swift in Sources */, 649D6BF021BFF481009E727B /* AdamantChatsProvider+search.swift in Sources */, E908473B219707200095825D /* AccountViewController+StayIn.swift in Sources */, + 3AF0A6CA2BBAF5850019FF47 /* ChatFileService.swift in Sources */, 93C7944C2B077B2700408826 /* DashGetAddressTransactionIds.swift in Sources */, 3A26D9502C3D3A5A003AD832 /* KlyWalletService+WalletCore.swift in Sources */, E9204B5220C9762400F3B9AB /* MessageStatus.swift in Sources */, @@ -3285,6 +3494,7 @@ E9CAE8D82018ACA700345E76 /* AdamantApi+Transfers.swift in Sources */, 4186B3302941E642006594A3 /* AdmWalletService+DynamicConstants.swift in Sources */, E95F85852008CB3A0070534A /* ChatListViewController.swift in Sources */, + 3AE0A42E2BC6A96B00BF7125 /* IPFS+Constants.swift in Sources */, E9FEECA62143C300007DD7C8 /* EthWalletService+RichMessageProvider.swift in Sources */, 6403F5E622723FDA00D58779 /* DashWalletViewController.swift in Sources */, 93C7944E2B077C1F00408826 /* DashSendRawTransactionDTO.swift in Sources */, @@ -3311,6 +3521,7 @@ E9B1AA5B21283E0F00080A2A /* AdmTransferViewController.swift in Sources */, 3A26D94D2C3D387B003AD832 /* KlyTransactionDetailsViewController.swift in Sources */, 648C697322916192006645F5 /* DashTransactionsViewController.swift in Sources */, + 3AA6DF442BA997C000EA2E16 /* FileListContainerView.swift in Sources */, 93E8EDCF2AF1CD9F003E163C /* NodeStatusInfo.swift in Sources */, 55E69E172868D7920025D82E /* CheckmarkView.swift in Sources */, 93B28EC02B076667007F268B /* APIResponseModel.swift in Sources */, @@ -3333,14 +3544,18 @@ E9240BF5215D686500187B09 /* AdmWalletService+RichMessageProvider.swift in Sources */, 648C697122915CB8006645F5 /* BTCRPCServerResponce.swift in Sources */, E9A174B32057EC47003667CD /* BackgroundFetchService.swift in Sources */, + 3AE0A4332BC6A9EB00BF7125 /* FileApiServiceProtocol.swift in Sources */, E9E7CDBE2003AEFB00DFC4DB /* CellFactory.swift in Sources */, + 3A299C782B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift in Sources */, 9366589D2B0ADBAF00BDB2D3 /* CoinsNodesListView.swift in Sources */, 411743022A39B208008CD98A /* ContributeState.swift in Sources */, 9366588F2B0AB97500BDB2D3 /* CoinsNodesListMapper.swift in Sources */, 93F391502962F5D400BFD6AE /* SpinnerView.swift in Sources */, 93A18C892AAEAE7700D0AB98 /* WalletFactory.swift in Sources */, E923222621135F9000A7E5AF /* EthAccount.swift in Sources */, + 3A7FD6F52C076D86002AF7D9 /* FileMessageStatus.swift in Sources */, E9061B97207501E40011F104 /* AdamantUserInfoKey.swift in Sources */, + 3A299C6D2B838F8F00B54C61 /* ChatMediaContainerView+Model.swift in Sources */, E9CAE8D62018AC5300345E76 /* AdamantApi+Transactions.swift in Sources */, E93D7ABE2052CEE1005D19DC /* NotificationsService.swift in Sources */, E940087D2114EDEE00CD2D67 /* EthWallet.swift in Sources */, @@ -3384,6 +3599,8 @@ E93B0D762028B28E00126346 /* AdamantChatsProvider.swift in Sources */, 3A33F9FA2A7A53DA002B8003 /* EmojiUpdateType.swift in Sources */, 936658932B0AC03700BDB2D3 /* CoinsNodesListStrings.swift in Sources */, + 3A5DF1792C4698EC0005369D /* EdgeInsetLabel.swift in Sources */, + 3AF8D9E92C73ADFA007A7CBC /* IPFSNodeStatus.swift in Sources */, E993302021354B1800CD5200 /* AdmWalletFactory.swift in Sources */, E9332B8921F1FA4400D56E72 /* OnboardFactory.swift in Sources */, 938F7D722955CE72001915CA /* ChatFactory.swift in Sources */, @@ -3402,10 +3619,10 @@ 3A26D9432C3C2E19003AD832 /* KlyWalletService+StatusCheck.swift in Sources */, 265AA1622B74E6B900CF98B0 /* ChatPreservation.swift in Sources */, 4164A9D728F17D4000EEF16D /* ChatTransactionService.swift in Sources */, + 3A2478B12BB45DF8009D89E9 /* StorageUsageView.swift in Sources */, E90A4943204C5ED6009F6A65 /* EurekaPassphraseRow.swift in Sources */, E90847302196FEA80095825D /* ChatTransaction+CoreDataClass.swift in Sources */, E913C9081FFFA943001A83F7 /* AdamantCore.swift in Sources */, - 41935848287841E20083363B /* MacOSDeterminer.swift in Sources */, 3A96E37A2AED27D7001F5A52 /* AdamantPartnerQRService.swift in Sources */, E9EC342120052ABB00C0E546 /* TransferViewControllerBase.swift in Sources */, 9304F8C4292F8A3100173F18 /* AdamantPushNotificationsTokenService.swift in Sources */, @@ -3415,6 +3632,7 @@ 938A46A62AE6106300FC03DB /* BlockchainHealthCheckWrapper.swift in Sources */, E921534E20EE1E8700C0843F /* EurekaAlertLabelRow.swift in Sources */, 9338AE8D2AEF7E9C001D32DF /* BodyStringEncoding.swift in Sources */, + 3AE0A42B2BC6A64900BF7125 /* IPFSApiService.swift in Sources */, 64C65F4523893C7600DC0425 /* OnboardOverlay.swift in Sources */, 93A18C862AAEACC100D0AB98 /* AdamantWalletFactoryCompose.swift in Sources */, 3A26D9452C3D336A003AD832 /* KlyWalletService+RichMessageProvider.swift in Sources */, @@ -3774,7 +3992,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = "im.adamant.adamant-messenger-dev"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3805,7 +4023,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.7.0; + MARKETING_VERSION = 3.8.0; PRODUCT_BUNDLE_IDENTIFIER = "im.adamant.adamant-messenger"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -4200,6 +4418,14 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 3A075C9D2B98A3B100714E3B /* FilesPickerKit */ = { + isa = XCSwiftPackageProductDependency; + productName = FilesPickerKit; + }; + 3A833C3F2B99CDA000238F6A /* FilesStorageKit */ = { + isa = XCSwiftPackageProductDependency; + productName = FilesStorageKit; + }; 3A8875EE27BBF38D00436195 /* Parchment */ = { isa = XCSwiftPackageProductDependency; package = 3A8875ED27BBF38D00436195 /* XCRemoteSwiftPackageReference "Parchment" */; diff --git a/Adamant.xcworkspace/contents.xcworkspacedata b/Adamant.xcworkspace/contents.xcworkspacedata index 038e94fb9..563a0441b 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,12 @@ + + + + diff --git a/Adamant/App/AppDelegate.swift b/Adamant/App/AppDelegate.swift index 64b21f67a..ad9b80d34 100644 --- a/Adamant/App/AppDelegate.swift +++ b/Adamant/App/AppDelegate.swift @@ -11,6 +11,7 @@ import Swinject import CryptoSwift import CoreData import CommonKit +import FilesStorageKit // MARK: - Constants extension String.adamant { diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index b116c8375..9b05680e1 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -9,6 +9,8 @@ import Swinject import BitcoinKit import CommonKit +import FilesStorageKit +import FilesPickerKit struct AppAssembly: Assembly { func assemble(container: Container) { @@ -16,6 +18,13 @@ struct AppAssembly: Assembly { // MARK: AdamantCore container.register(AdamantCore.self) { _ in NativeAdamantCore() }.inObjectScope(.container) + // MARK: FilesStorageProtocol + container.register(FilesStorageProtocol.self) { _ in FilesStorageKit() }.inObjectScope(.container) + + container.register(FilesPickerProtocol.self) { r in + FilesPickerKit(storageKit: r.resolve(FilesStorageProtocol.self)!) + } + // MARK: CellFactory container.register(CellFactory.self) { _ in AdamantCellFactory() }.inObjectScope(.container) @@ -120,6 +129,23 @@ struct AppAssembly: Assembly { ) }.inObjectScope(.container) + // MARK: IPFSApiService + container.register(IPFSApiService.self) { r in + IPFSApiService( + healthCheckWrapper: .init( + service: .init(apiCore: r.resolve(APICoreProtocol.self)!), + nodesStorage: r.resolve(NodesStorageProtocol.self)!, + nodesAdditionalParamsStorage: r.resolve(NodesAdditionalParamsStorageProtocol.self)!, + nodeGroup: .ipfs + ) + ) + }.inObjectScope(.container) + + // MARK: FilesNetworkManagerProtocol + container.register(FilesNetworkManagerProtocol.self) { r in + FilesNetworkManager(ipfsService: r.resolve(IPFSApiService.self)!) + }.inObjectScope(.container) + // MARK: BtcApiService container.register(BtcApiService.self) { r in BtcApiService(api: .init( @@ -264,6 +290,24 @@ struct AppAssembly: Assembly { ) }.inObjectScope(.container) + // MARK: ChatFileService + container.register(ChatFileProtocol.self) { r in + ChatFileService( + accountService: r.resolve(AccountService.self)!, + filesStorage: r.resolve(FilesStorageProtocol.self)!, + chatsProvider: r.resolve(ChatsProvider.self)!, + filesNetworkManager: r.resolve(FilesNetworkManagerProtocol.self)!, + adamantCore: r.resolve(AdamantCore.self)! + ) + }.inObjectScope(.container) + + // MARK: FilesStorageProprietiesService + container.register(FilesStorageProprietiesProtocol.self) { r in + FilesStorageProprietiesService( + securedStore: r.resolve(SecuredStore.self)! + ) + }.inObjectScope(.container) + // MARK: Chats container.register(ChatsProvider.self) { r in AdamantChatsProvider( diff --git a/Adamant/Debug.entitlements b/Adamant/Debug.entitlements index 96fd56e77..7713a96e4 100644 --- a/Adamant/Debug.entitlements +++ b/Adamant/Debug.entitlements @@ -8,6 +8,16 @@ applinks:msg.adamant.im + com.apple.developer.icloud-container-identifiers + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox com.apple.security.device.camera diff --git a/Adamant/Helpers/EdgeInsetLabel.swift b/Adamant/Helpers/EdgeInsetLabel.swift new file mode 100644 index 000000000..1192a30ca --- /dev/null +++ b/Adamant/Helpers/EdgeInsetLabel.swift @@ -0,0 +1,34 @@ +// +// EdgeInsetLabel.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 16.07.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import UIKit + +final class EdgeInsetLabel: UILabel { + var textInsets = UIEdgeInsets.zero { + didSet { invalidateIntrinsicContentSize() } + } + + override func textRect( + forBounds bounds: CGRect, + limitedToNumberOfLines numberOfLines: Int + ) -> CGRect { + let textRect = super.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) + let invertedInsets = UIEdgeInsets( + top: -textInsets.top, + left: -textInsets.left, + bottom: -textInsets.bottom, + right: -textInsets.right + ) + return textRect.inset(by: invertedInsets) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: textInsets)) + } +} diff --git a/Adamant/Helpers/Markdown+Adamant.swift b/Adamant/Helpers/Markdown+Adamant.swift index 4ab3410a8..d827a2244 100644 --- a/Adamant/Helpers/Markdown+Adamant.swift +++ b/Adamant/Helpers/Markdown+Adamant.swift @@ -226,3 +226,51 @@ final class MarkdownCodeAdamant: MarkdownCommonElement { addAttributes(attributedString, range: match.range) } } + +// MARK: Detect file emoji & count +// - ex: 📄2 +final class MarkdownFileRaw: MarkdownElement { + private let emoji: String + private let matchFont: UIFont + + init( + emoji: String, + font: UIFont + ) { + self.emoji = emoji + self.matchFont = font + } + + var regex: String { + return "\(emoji)\\d{0,2}" + } + + func regularExpression() throws -> NSRegularExpression { + try NSRegularExpression(pattern: regex, options: .dotMatchesLineSeparators) + } + + func match(_ match: NSTextCheckingResult, attributedString: NSMutableAttributedString) { + let attributesColor: [NSAttributedString.Key : Any] = [ + .foregroundColor: UIColor.lightGray, + .font: matchFont, + .baselineOffset: -3.0 + ] + + let nsString = (attributedString.string as NSString) + let matchText = nsString.substring(with: match.range) + + let textWithoutEmoji = matchText.replacingOccurrences(of: emoji, with: "") + let countRange = (matchText as NSString).range(of: textWithoutEmoji) + let emojiRange = (matchText as NSString).range(of: emoji) + + let range = NSRange( + location: match.range.location + emojiRange.length, + length: countRange.length + ) + + attributedString.addAttributes( + attributesColor, + range: range + ) + } +} diff --git a/Adamant/Helpers/NodeGroup+Constants.swift b/Adamant/Helpers/NodeGroup+Constants.swift index bcac6516b..47203f9fb 100644 --- a/Adamant/Helpers/NodeGroup+Constants.swift +++ b/Adamant/Helpers/NodeGroup+Constants.swift @@ -25,6 +25,8 @@ public extension NodeGroup { return DogeWalletService.healthCheckParameters.onScreenUpdateInterval case .dash: return DashWalletService.healthCheckParameters.onScreenUpdateInterval + case .ipfs: + return IPFSApiService.healthCheckParameters.onScreenUpdateInterval } } @@ -44,6 +46,8 @@ public extension NodeGroup { return DogeWalletService.healthCheckParameters.crucialUpdateInterval case .dash: return DashWalletService.healthCheckParameters.crucialUpdateInterval + case .ipfs: + return IPFSApiService.healthCheckParameters.crucialUpdateInterval } } @@ -63,6 +67,8 @@ public extension NodeGroup { return DogeWalletService.healthCheckParameters.threshold case .dash: return DashWalletService.healthCheckParameters.threshold + case .ipfs: + return IPFSApiService.healthCheckParameters.threshold } } @@ -82,6 +88,8 @@ public extension NodeGroup { return DogeWalletService.healthCheckParameters.normalUpdateInterval case .dash: return DashWalletService.healthCheckParameters.normalUpdateInterval + case .ipfs: + return IPFSApiService.healthCheckParameters.normalUpdateInterval } } @@ -102,6 +110,8 @@ public extension NodeGroup { minNodeVersion = DogeWalletService.minNodeVersion case .dash: minNodeVersion = DashWalletService.minNodeVersion + case .ipfs: + minNodeVersion = nil } guard let versionNumber = Node.stringToDouble(minNodeVersion) else { diff --git a/Adamant/Helpers/UIFont+adamant.swift b/Adamant/Helpers/UIFont+adamant.swift index c2b368338..92f601ddb 100644 --- a/Adamant/Helpers/UIFont+adamant.swift +++ b/Adamant/Helpers/UIFont+adamant.swift @@ -40,6 +40,7 @@ extension UIFont { return UIFont(name: name, size: size) ?? .systemFont(ofSize: size, weight: weight) } + static var adamantChatFileRawDefault = UIFont.systemFont(ofSize: 8) static var adamantChatDefault = UIFont.systemFont(ofSize: 17) static var adamantCodeDefault = UIFont.adamantMono(ofSize: 15, weight: .regular) static var adamantChatReplyDefault = UIFont.systemFont(ofSize: 14) diff --git a/Adamant/Helpers/UITextField+adamant.swift b/Adamant/Helpers/UITextField+adamant.swift index ec4846f7a..8a5abd6b0 100644 --- a/Adamant/Helpers/UITextField+adamant.swift +++ b/Adamant/Helpers/UITextField+adamant.swift @@ -7,6 +7,7 @@ // import UIKit +import CommonKit extension UITextField { diff --git a/Adamant/Models/ApiServiceError.swift b/Adamant/Models/ApiServiceError.swift index 8abc11831..156dc1a04 100644 --- a/Adamant/Models/ApiServiceError.swift +++ b/Adamant/Models/ApiServiceError.swift @@ -34,8 +34,8 @@ enum ApiServiceError: LocalizedError, Error { let message = error?.localizedDescription ?? msg return String.adamant.sharedErrors.internalError(message: message) - case .networkError(error: _): - return String.adamant.sharedErrors.networkError + case let .networkError(error): + return error.localizedDescription case .requestCancelled: return String.adamant.sharedErrors.requestCancelled diff --git a/Adamant/Models/ApiServiceResult.swift b/Adamant/Models/ApiServiceResult.swift index 71c88ccd3..d1c88ddb7 100644 --- a/Adamant/Models/ApiServiceResult.swift +++ b/Adamant/Models/ApiServiceResult.swift @@ -7,3 +7,4 @@ // typealias ApiServiceResult = Result +typealias FileApiServiceResult = Result diff --git a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift index e2225d0c4..f1a5a78af 100644 --- a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift +++ b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift @@ -47,6 +47,14 @@ public class Chatroom: NSManagedObject { return result?.checkAndReplaceSystemWallets() } + @MainActor func hasPartnerName(addressBookService: AddressBookService) -> Bool { + guard let partner = partner else { return false } + + return partner.address.flatMap { addressBookService.getName(for: $0) } != nil + || title != nil + || partner.name != nil + } + private let semaphore = DispatchSemaphore(value: 1) func updateLastTransaction() { diff --git a/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift index 1b84cc8b5..bcecc8ffb 100644 --- a/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift +++ b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift @@ -28,13 +28,36 @@ extension RichMessageTransaction { return richContent?[RichContentKeys.reply.replyMessage] is [String: String] } + func isFileReply() -> Bool { + let replyMessage = richContent?[RichContentKeys.reply.replyMessage] as? [String: Any] + return replyMessage?[RichContentKeys.file.files] is [[String: Any]] + } + func getRichValue(for key: String) -> String? { if let value = richContent?[key] as? String { return value } - if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: String], - let value = content[key] { + if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: Any], + let value = content[key] as? String { + return value + } + + return nil + } + + func getRichValue(for key: String) -> T? { + if let value = richContent?[key] as? T { + return value + } + + if let content = richContent?[RichContentKeys.file.files] as? [String: Any], + let value = content[key] as? T { + return value + } + + if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: Any], + let value = content[key] as? T { return value } diff --git a/Adamant/Models/DownloadPolicy.swift b/Adamant/Models/DownloadPolicy.swift new file mode 100644 index 000000000..0348f0eda --- /dev/null +++ b/Adamant/Models/DownloadPolicy.swift @@ -0,0 +1,34 @@ +// +// DownloadPolicy.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 13.08.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +extension Notification.Name { + struct Storage { + public static let storageClear = Notification.Name("adamant.storage.clear") + public static let storageProprietiesUpdated = Notification.Name("adamant.storage.ProprietiesUpdated") + } + } + + enum DownloadPolicy: String { + case everybody + case nobody + case contacts + + var title: String { + switch self { + case .everybody: + return .localized("Storage.DownloadPolicy.Everybody.Title") + case .nobody: + return .localized("Storage.DownloadPolicy.Nobody.Title") + case .contacts: + return .localized("Storage.DownloadPolicy.Contacts.Title") + } + } + } diff --git a/Adamant/Models/IPFSNodeStatus.swift b/Adamant/Models/IPFSNodeStatus.swift new file mode 100644 index 000000000..2a1ed9813 --- /dev/null +++ b/Adamant/Models/IPFSNodeStatus.swift @@ -0,0 +1,31 @@ +// +// IPFSNodeStatus.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.08.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +struct IPFSNodeStatus: Codable { + let version: String +} + +/* JSON + + { + "version":"0.0.1", + "timestamp":1724085764840, + "heliaStatus":"started", + "peerId":"12D3KooWGMp6SaKon2UKwJsDEf3chLAGRzsjdAfDGN9zcwt6ydqJ", + "multiAddresses":[ + "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWGMp6SaKon2UKwJsDEf3chLAGRzsjdAfDGN9zcwt6ydqJ", + "/ip4/95.216.45.88/tcp/4001/p2p/12D3KooWGMp6SaKon2UKwJsDEf3chLAGRzsjdAfDGN9zcwt6ydqJ" + ], + "blockstoreSizeMb":5053.823321342468, + "datastoreSizeMb":0.135406494140625, + "availableSizeInMb":943942 + } + +*/ diff --git a/Adamant/Models/MultipartFormDataModel.swift b/Adamant/Models/MultipartFormDataModel.swift new file mode 100644 index 000000000..b77120115 --- /dev/null +++ b/Adamant/Models/MultipartFormDataModel.swift @@ -0,0 +1,15 @@ +// +// MultipartFormDataModel.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 16.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +struct MultipartFormDataModel { + let keyName: String + let fileName: String + let data: Data +} diff --git a/Adamant/Models/NodeWithGroup.swift b/Adamant/Models/NodeWithGroup.swift index 7574cb556..ed4f4189f 100644 --- a/Adamant/Models/NodeWithGroup.swift +++ b/Adamant/Models/NodeWithGroup.swift @@ -31,6 +31,8 @@ extension NodeGroup { return DashWalletService.tokenNetworkSymbol case .adm: return AdmWalletService.tokenNetworkSymbol + case .ipfs: + return IPFSApiService.symbol } } @@ -38,7 +40,7 @@ extension NodeGroup { switch self { case .btc, .klyNode, .klyService, .doge, .adm: return true - case .eth, .dash: + case .eth, .dash, .ipfs: return false } } diff --git a/Adamant/Modules/Account/AccountViewController.swift b/Adamant/Modules/Account/AccountViewController.swift index 394f066c5..0ff1df2f8 100644 --- a/Adamant/Modules/Account/AccountViewController.swift +++ b/Adamant/Modules/Account/AccountViewController.swift @@ -62,7 +62,7 @@ final class AccountViewController: FormViewController { enum Rows { case balance, sendTokens // Wallet - case security, nodes, coinsNodes, theme, currency, language, about, visibleWallets, contribute // Application + case security, nodes, coinsNodes, theme, currency, language, about, visibleWallets, contribute, storage // Application case voteForDelegates, generateQr, generatePk, logout // Actions case stayIn, biometry, notifications // Security @@ -86,6 +86,7 @@ final class AccountViewController: FormViewController { case .contribute: return "contribute" case .coinsNodes: return "coinsNodes" case .language: return "language" + case .storage: return "storage" } } @@ -109,6 +110,7 @@ final class AccountViewController: FormViewController { case .contribute: return .localized("AccountTab.Row.Contribute", comment: "Account tab: 'Contribute' row") case .coinsNodes: return .adamant.coinsNodesList.title case .language: return .localized("AccountTab.Row.Language", comment: "Account tab: 'Language' row") + case .storage: return .localized("StorageUsage.Title", comment: "Storage Usage: Title") } } @@ -133,6 +135,7 @@ final class AccountViewController: FormViewController { case .visibleWallets: image = .asset(named: "row_balance") case .contribute: image = .asset(named: "row_contribute") case .language: image = .asset(named: "row_language") + case .storage: image = .asset(named: "row_storage") } return image? @@ -479,6 +482,34 @@ final class AccountViewController: FormViewController { appSection.append(contributeRow) + // Storage Usage + let storageRow = LabelRow { + $0.title = Rows.storage.localized + $0.tag = Rows.storage.tag + $0.cell.imageView?.image = Rows.storage.image + $0.cell.selectionStyle = .gray + }.cellUpdate { (cell, row) in + cell.accessoryType = .disclosureIndicator + row.title = Rows.storage.localized + }.onCellSelection { [weak self] (_, _) in + guard let self = self else { return } + let vc = screensFactory.makeStorageUsage() + + if let split = splitViewController { + let details = UINavigationController(rootViewController: vc) + split.showDetailViewController(details, sender: self) + } else if let nav = navigationController { + nav.pushViewController(vc, animated: true) + } else { + vc.modalPresentationStyle = .overFullScreen + present(vc, animated: true, completion: nil) + } + + deselectWalletViewControllers() + } + + appSection.append(storageRow) + // About let aboutRow = LabelRow { $0.title = Rows.about.localized diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index c81f4fbb3..0ab315a81 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -11,6 +11,8 @@ import MessageKit import InputBarAccessoryView import Combine import Swinject +import FilesStorageKit +import FilesPickerKit @MainActor struct ChatFactory { @@ -27,7 +29,13 @@ struct ChatFactory { let emojiService: EmojiService let walletServiceCompose: WalletServiceCompose let chatPreservation: ChatPreservationProtocol - + let filesStorage: FilesStorageProtocol + let chatFileService: ChatFileProtocol + let filesStorageProprieties: FilesStorageProprietiesProtocol + let nodesStorage: NodesStorageProtocol + let reachabilityMonitor: ReachabilityMonitor + let filesPickerKit: FilesPickerProtocol + nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! dialogService = assembler.resolve(DialogService.self)! @@ -41,6 +49,12 @@ struct ChatFactory { emojiService = assembler.resolve(EmojiService.self)! walletServiceCompose = assembler.resolve(WalletServiceCompose.self)! chatPreservation = assembler.resolve(ChatPreservationProtocol.self)! + filesStorage = assembler.resolve(FilesStorageProtocol.self)! + chatFileService = assembler.resolve(ChatFileProtocol.self)! + filesStorageProprieties = assembler.resolve(FilesStorageProprietiesProtocol.self)! + nodesStorage = assembler.resolve(NodesStorageProtocol.self)! + reachabilityMonitor = assembler.resolve(ReachabilityMonitor.self)! + filesPickerKit = assembler.resolve(FilesPickerProtocol.self)! } func makeViewController(screensFactory: ScreensFactory) -> ChatViewController { @@ -98,7 +112,7 @@ private extension ChatFactory { markdownParser: .init(font: UIFont.systemFont(ofSize: UIFont.systemFontSize)), transfersProvider: transferProvider, chatMessagesListFactory: .init(chatMessageFactory: .init( - walletServiceCompose: walletServiceCompose + walletServiceCompose: walletServiceCompose )), addressBookService: addressBookService, visibleWalletService: visibleWalletService, @@ -113,7 +127,13 @@ private extension ChatFactory { emojiService: emojiService ), emojiService: emojiService, - chatPreservation: chatPreservation + chatPreservation: chatPreservation, + filesStorage: filesStorage, + chatFileService: chatFileService, + filesStorageProprieties: filesStorageProprieties, + nodesStorage: nodesStorage, + reachabilityMonitor: reachabilityMonitor, + filesPicker: filesPickerKit ) } diff --git a/Adamant/Modules/Chat/ChatLocalization.swift b/Adamant/Modules/Chat/ChatLocalization.swift index 8c37f01fb..22f7c8281 100644 --- a/Adamant/Modules/Chat/ChatLocalization.swift +++ b/Adamant/Modules/Chat/ChatLocalization.swift @@ -87,5 +87,8 @@ extension String.adamant { static var messageIsTooBig: String { String.localized("ChatScene.Error.messageIsTooBig", comment: "Chat: Error message is too big") } + static var unknownTitle: String { + String.localized("Chat.unknown.title", comment: "Chat unknown") + } } } diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index def74b149..dc38bad0a 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -12,6 +12,10 @@ import Combine import UIKit import SnapKit import CommonKit +import FilesStorageKit +import PhotosUI +import FilesPickerKit +import QuickLook @MainActor final class ChatViewController: MessagesViewController { @@ -44,6 +48,10 @@ final class ChatViewController: MessagesViewController { private lazy var scrollDownButton = makeScrollDownButton() private lazy var chatMessagesCollectionView = makeChatMessagesCollectionView() private lazy var replyView = ReplyView() + private lazy var filesToolbarView = FilesToolbarView() + private lazy var chatDropView = ChatDropView() + + private var sendTransaction: SendTransaction // swiftlint:disable unused_setter_value override var messageInputBar: InputBarAccessoryView { @@ -84,10 +92,13 @@ final class ChatViewController: MessagesViewController { self.walletServiceCompose = walletServiceCompose self.admWalletService = admWalletService self.screensFactory = screensFactory + self.sendTransaction = sendTransaction super.init(nibName: nil, bundle: nil) inputBar.onAttachmentButtonTap = { [weak self] in - self.map { sendTransaction($0, viewModel.replyMessage?.id) } - self?.viewModel.clearReplyMessage() + self?.viewModel.presentActionMenu() + } + inputBar.onImagePasted = { [weak self] image in + self?.viewModel.handlePastedImage(image) } } @@ -105,7 +116,9 @@ final class ChatViewController: MessagesViewController { configureHeader() configureLayout() configureReplyView() + configureFilesToolbarView() configureGestures() + configureDropFiles() setupObservers() viewModel.loadFirstMessagesIfNeeded() } @@ -150,6 +163,7 @@ final class ChatViewController: MessagesViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + viewModel.preserveFiles() viewModel.preserveMessage(inputBar.text) viewModel.preserveReplayMessage() viewModel.saveChatOffset( @@ -261,6 +275,15 @@ private extension ChatViewController { .sink { [weak self] _ in self?.inputTextUpdated() } .store(in: &subscriptions) + NotificationCenter.default + .publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + guard let self = self else { return } + let indexes = self.messagesCollectionView.indexPathsForVisibleItems + self.viewModel.updatePreviewFor(indexes: indexes) + } + .store(in: &subscriptions) + viewModel.$messages .removeDuplicates() .sink { [weak self] _ in self?.updateMessages() } @@ -343,6 +366,10 @@ private extension ChatViewController { .sink { [weak self] in self?.processSwipeMessage($0) } .store(in: &subscriptions) + viewModel.$filesPicked + .sink { [weak self] in self?.processFileToolbarView($0) } + .store(in: &subscriptions) + viewModel.$scrollToMessage .sink { [weak self] in guard let toId = $0, @@ -380,6 +407,38 @@ private extension ChatViewController { .sink { [weak self] in self?.didTapPartenerQR(partner: $0) } .store(in: &subscriptions) + viewModel.presentSendTokensVC + .sink { [weak self] in + guard let self = self else { return } + + sendTransaction(self, self.viewModel.replyMessage?.id) + self.viewModel.clearReplyMessage() + self.viewModel.clearPickedFiles() + } + .store(in: &subscriptions) + + viewModel.presentMediaPickerVC + .sink { [weak self] in + self?.presentMediaPicker() + } + .store(in: &subscriptions) + + viewModel.presentDocumentPickerVC + .sink { [weak self] in + self?.presentDocumentPicker() + } + .store(in: &subscriptions) + + viewModel.presentDocumentViewerVC + .sink { [weak self] (files, index) in + self?.presentDocumentViewer(files: files, selectedIndex: index) + } + .store(in: &subscriptions) + + viewModel.presentDropView + .sink { [weak self] in self?.presentDropView($0) } + .store(in: &subscriptions) + viewModel.didTapSelectText .sink { [weak self] text in self?.didTapSelectText(text: text) @@ -391,6 +450,16 @@ private extension ChatViewController { // MARK: Configuration private extension ChatViewController { + func configureDropFiles() { + chatDropView.alpha = .zero + view.addSubview(chatDropView) + chatDropView.snp.makeConstraints { + $0.directionalEdges.equalTo(view.safeAreaLayoutGuide).inset(5) + } + + view.addInteraction(UIDropInteraction(delegate: viewModel.dropInteractionService)) + } + func configureLayout() { view.addSubview(scrollDownButton) scrollDownButton.snp.makeConstraints { [unowned inputBar] in @@ -444,6 +513,24 @@ private extension ChatViewController { } } + func configureFilesToolbarView() { + filesToolbarView.snp.makeConstraints { make in + make.height.equalTo(filesToolbarViewHeight) + } + + filesToolbarView.closeAction = { [weak self] in + self?.viewModel.updateFiles(nil) + } + + filesToolbarView.updatedDataAction = { [weak self] data in + self?.viewModel.updateFiles(data) + } + + filesToolbarView.openFileAction = { [weak self] data in + self?.presentDocumentViewer(url: data.url) + } + } + func configureGestures() { /// Replaces the delegate of the pan gesture recognizer used in the input bar control of MessageKit. /// This gesture controls the position of the input bar when the keyboard is open and the user swipes it to dismiss. @@ -462,6 +549,75 @@ private extension ChatViewController { messagesCollectionView.addGestureRecognizer(panGesture) messagesCollectionView.clipsToBounds = false } + + func presentMediaPicker() { + messageInputBar.inputTextView.resignFirstResponder() + + viewModel.mediaPickerDelegate.preSelectedFiles = viewModel.filesPicked ?? [] + + let assetIds = viewModel.filesPicked?.compactMap { $0.assetId } ?? [] + + var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) + phPickerConfig.selectionLimit = FilesConstants.maxFilesCount + phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos, .livePhotos]) + phPickerConfig.preselectedAssetIdentifiers = assetIds + phPickerConfig.selection = .ordered + + let phPickerVC = PHPickerViewController(configuration: phPickerConfig) + phPickerVC.delegate = viewModel.mediaPickerDelegate + present(phPickerVC, animated: true) + } + + func presentDocumentPicker() { + messageInputBar.inputTextView.resignFirstResponder() + + let documentPicker = UIDocumentPickerViewController( + forOpeningContentTypes: [.data, .content], + asCopy: false + ) + documentPicker.allowsMultipleSelection = true + documentPicker.delegate = viewModel.documentPickerDelegate + present(documentPicker, animated: true) + } + + func presentDocumentViewer(files: [FileResult], selectedIndex: Int) { + viewModel.documentViewerService.openFile( + files: files + ) + + let quickVC = QLPreviewController() + quickVC.delegate = viewModel.documentViewerService + quickVC.dataSource = viewModel.documentViewerService + quickVC.modalPresentationStyle = .fullScreen + quickVC.currentPreviewItemIndex = selectedIndex + + if let splitViewController = splitViewController { + splitViewController.present(quickVC, animated: true) + } else { + present(quickVC, animated: true) + } + } + + func presentDocumentViewer(url: URL) { + viewModel.documentViewerService.openFile(url: url) + + let quickVC = QLPreviewController() + quickVC.delegate = viewModel.documentViewerService + quickVC.dataSource = viewModel.documentViewerService + quickVC.modalPresentationStyle = .fullScreen + + if let splitViewController = splitViewController { + splitViewController.present(quickVC, animated: true) + } else { + present(quickVC, animated: true) + } + } + + func presentDropView(_ value: Bool) { + UIView.animate(withDuration: 0.25) { + self.chatDropView.alpha = value ? 1.0 : .zero + } + } } // MARK: Tap on title view @@ -543,6 +699,7 @@ private extension ChatViewController { collection.register(ChatTransactionCell.self) collection.register(ChatMessageCell.self) collection.register(ChatMessageReplyCell.self) + collection.register(ChatMediaCell.self) collection.register( SpinnerCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader @@ -652,25 +809,68 @@ private extension ChatViewController { } if !messageInputBar.topStackView.subviews.contains(replyView) { + if messageInputBar.topStackView.arrangedSubviews.isEmpty { + UIView.transition( + with: messageInputBar.topStackView, + duration: 0.25, + options: [.transitionCrossDissolve], + animations: { + self.messageInputBar.topStackView.insertArrangedSubview( + self.replyView, + at: .zero + ) + }) + } else { + messageInputBar.topStackView.insertArrangedSubview( + replyView, + at: .zero + ) + } + + if viewAppeared { + messageInputBar.inputTextView.becomeFirstResponder() + } + } + + replyView.update(with: message) + } + + func closeReplyView() { + replyView.removeFromSuperview() + messageInputBar.invalidateIntrinsicContentSize() + } + + func processFileToolbarView(_ data: [FileResult]?) { + guard let data = data, !data.isEmpty else { + inputBar.isForcedSendEnabled = false + closeFileToolbarView() + return + } + + inputBar.isForcedSendEnabled = true + + if !messageInputBar.topStackView.subviews.contains(filesToolbarView) { UIView.transition( with: messageInputBar.topStackView, duration: 0.25, options: [.transitionCrossDissolve], animations: { - self.messageInputBar.topStackView.addArrangedSubview(self.replyView) + self.messageInputBar.topStackView.insertArrangedSubview( + self.filesToolbarView, + at: self.messageInputBar.topStackView.arrangedSubviews.count + ) }) if viewAppeared { messageInputBar.inputTextView.becomeFirstResponder() } } - replyView.update(with: message) + filesToolbarView.update(data) } - func closeReplyView() { - replyView.removeFromSuperview() + func closeFileToolbarView() { + filesToolbarView.removeFromSuperview() messageInputBar.invalidateIntrinsicContentSize() - messageInputBar.layoutContainerViewIfNeeded() } func didTapTransfer(id: String) { @@ -831,3 +1031,4 @@ private let messagePadding: CGFloat = 12 private var replyAction: Bool = false private var canReplyVibrate: Bool = true private var oldContentOffset: CGPoint? +private let filesToolbarViewHeight: CGFloat = 140 diff --git a/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift b/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift index be85bfc11..c8669afa7 100644 --- a/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift +++ b/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift @@ -18,4 +18,20 @@ extension UIView { self.backgroundColor = originalColor } } + + func addShadow( + shadowColor: UIColor = UIColor.black, + shadowOffset: CGSize = .zero, + shadowOpacity: Float = 0.55, + shadowRadius: CGFloat = 3.0, + masksToBounds: Bool = false, + cornerRadius: CGFloat = 4.0 + ) { + layer.shadowColor = shadowColor.cgColor + layer.shadowOffset = shadowOffset + layer.shadowOpacity = shadowOpacity + layer.shadowRadius = shadowRadius + layer.masksToBounds = masksToBounds + layer.cornerRadius = cornerRadius + } } diff --git a/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift new file mode 100644 index 000000000..eb67a11df --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift @@ -0,0 +1,54 @@ +// +// ChatDropView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit + +final class ChatDropView: UIView { + private lazy var imageView = UIImageView(image: .asset(named: "uploadIcon")) + private lazy var titleLabel = UILabel(font: titleFont, textColor: .lightGray) + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +private extension ChatDropView { + func configure() { + layer.cornerRadius = 5 + layer.borderWidth = 2.0 + layer.borderColor = UIColor.adamant.active.cgColor + backgroundColor = .systemBackground + + titleLabel.text = dropTitle + imageView.tintColor = .lightGray + + addSubview(imageView) + addSubview(titleLabel) + + imageView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-15) + make.size.equalTo(60) + } + + titleLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.top.equalTo(imageView.snp.bottom).offset(10) + } + } +} + +private let titleFont = UIFont.systemFont(ofSize: 20) +private var dropTitle: String { .localized("Chat.Drop.Title") } diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift new file mode 100644 index 000000000..7f55caaa1 --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -0,0 +1,60 @@ +// +// ChatFile.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import CommonKit +import UIKit + +struct DownloadStatus: Hashable { + var isPreviewDownloading: Bool + var isOriginalDownloading: Bool + + static let `default` = Self( + isPreviewDownloading: false, + isOriginalDownloading: false + ) +} + +struct ChatFile: Equatable, Hashable { + var file: RichMessageFile.File + var previewImage: UIImage? + var downloadStatus: DownloadStatus + var isUploading: Bool + var isCached: Bool + var storage: String + var nonce: String + var isFromCurrentSender: Bool + var fileType: FileType + var progress: Int? + var isPreviewDownloadAllowed: Bool + var isFullMediaDownloadAllowed: Bool + + var isBusy: Bool { + isDownloading + || isUploading + } + + var isDownloading: Bool { + downloadStatus.isOriginalDownloading + || downloadStatus.isPreviewDownloading + } + + static let `default` = Self( + file: .init([:]), + previewImage: nil, + downloadStatus: .default, + isUploading: false, + isCached: false, + storage: .empty, + nonce: .empty, + isFromCurrentSender: false, + fileType: .other, + progress: .zero, + isPreviewDownloadAllowed: false, + isFullMediaDownloadAllowed: false + ) +} diff --git a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift new file mode 100644 index 000000000..f9859965f --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift @@ -0,0 +1,62 @@ +// +// CircularProgressView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SwiftUI +import UIKit + +final class CircularProgressState: ObservableObject { + let lineWidth: CGFloat + let backgroundColor: UIColor + let progressColor: UIColor + @Published var progress: Double = 0 + @Published var hidden: Bool = false + + init( + lineWidth: CGFloat = 6, + backgroundColor: UIColor = .lightGray, + progressColor: UIColor = .blue, + progress: Double, + hidden: Bool + ) { + self.lineWidth = lineWidth + self.backgroundColor = backgroundColor + self.progressColor = progressColor + self.progress = progress + self.hidden = hidden + } +} + +struct CircularProgressView: View { + @StateObject private var state: CircularProgressState + + init(state: @escaping () -> CircularProgressState) { + _state = .init(wrappedValue: state()) + } + + var body: some View { + ZStack { + Circle() + .stroke( + Color(uiColor: state.backgroundColor), + lineWidth: state.lineWidth + ) + Circle() + .trim(from: 0, to: state.progress) + .stroke( + Color(uiColor: state.progressColor), + style: StrokeStyle( + lineWidth: state.lineWidth, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeOut, value: state.progress) + } + .opacity(state.hidden ? .zero : 1.0) + } +} diff --git a/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift new file mode 100644 index 000000000..f2055198e --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift @@ -0,0 +1,37 @@ +// +// FileMessageStatus.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 29.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import CommonKit + +enum FileMessageStatus: Equatable { + case busy + case needToDownload(failed: Bool) + case failed + case success + + var image: UIImage { + switch self { + case .busy: return .asset(named: "status_pending") ?? .init() + case .success: return .asset(named: "status_success") ?? .init() + case .failed: return .asset(named: "status_failed") ?? .init() + case let .needToDownload(failed): + guard !failed else { + return .asset(named: "download-circular-error") ?? .init() + } + return .asset(named: "download-circular") ?? .init() + } + } + + var imageTintColor: UIColor { + switch self { + case .busy, .needToDownload, .success: return .adamant.primary + case .failed: return .adamant.alert + } + } +} diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index f440a7bf0..0990fe43f 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -21,4 +21,7 @@ enum ChatAction { case remove(id: String) case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) + case openFile(messageId: String, file: ChatFile) + case downloadContentIfNeeded(messageId: String, files: [ChatFile]) + case forceDownloadAllFiles(messageId: String, files: [ChatFile]) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 1b6c1944f..22829e0c6 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -121,28 +121,51 @@ final class ChatDataSourceManager: MessagesDataSource { at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView ) -> UICollectionViewCell { - guard case let .transaction(model) = message.fullModel.content - else { return UICollectionViewCell() } - - let cell = messagesCollectionView.dequeueReusableCell( - ChatTransactionCell.self, - for: indexPath - ) + if case let .transaction(model) = message.fullModel.content { + let cell = messagesCollectionView.dequeueReusableCell( + ChatTransactionCell.self, + for: indexPath + ) + + let publisher: any Observable = viewModel.$messages.compactMap { + let message = $0[safe: indexPath.section] + guard case let .transaction(model) = message?.fullModel.content + else { return nil } + + return model.value + } + + cell.transactionView.actionHandler = { [weak self] in self?.handleAction($0) } + cell.transactionView.chatMessagesListViewModel = viewModel.chatMessagesListViewModel + cell.transactionView.model = model.value + cell.transactionView.setSubscription(publisher: publisher, collection: messagesCollectionView) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell + } - let publisher: any Observable = viewModel.$messages.compactMap { - let message = $0[safe: indexPath.section] - guard case let .transaction(model) = message?.fullModel.content - else { return nil } + if case let .file(model) = message.fullModel.content { + let cell = messagesCollectionView.dequeueReusableCell( + ChatMediaCell.self, + for: indexPath + ) - return model.value + let publisher: any Observable = viewModel.$messages.compactMap { + let message = $0[safe: indexPath.section] + guard case let .file(model) = message?.fullModel.content + else { return nil } + + return model.value + } + + cell.containerMediaView.actionHandler = { [weak self] in self?.handleAction($0) } + cell.containerMediaView.chatMessagesListViewModel = viewModel.chatMessagesListViewModel + cell.containerMediaView.model = model.value + cell.containerMediaView.setSubscription(publisher: publisher, collection: messagesCollectionView) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell } - - cell.transactionView.actionHandler = { [weak self] in self?.handleAction($0) } - cell.transactionView.chatMessagesListViewModel = viewModel.chatMessagesListViewModel - cell.transactionView.model = model.value - cell.transactionView.setSubscription(publisher: publisher, collection: messagesCollectionView) - cell.configure(with: message, at: indexPath, and: messagesCollectionView) - return cell + + return UICollectionViewCell() } } @@ -171,6 +194,15 @@ private extension ChatDataSourceManager { viewModel.presentMenu(arg: arg) case .copyInPart(text: let text): viewModel.copyTextInPartAction(text) + case let .openFile(messageId, file): + viewModel.openFile(messageId: messageId, file: file) + case let .downloadContentIfNeeded(messageId, files): + viewModel.downloadContentIfNeeded( + messageId: messageId, + files: files + ) + case let .forceDownloadAllFiles(messageId, files): + viewModel.forceDownloadAllFiles(messageId: messageId, files: files) } } } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift index cc4126ef6..3dbb6e878 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift @@ -106,6 +106,8 @@ private extension ChatDialogManager { dismissMenu() case .renameAlert: showRenameAlert() + case .actionMenu: + showActionMenu() } } @@ -137,6 +139,26 @@ private extension ChatDialogManager { ) } + func showActionMenu() { + let didSelect: ((ShareType) -> Void)? = { [weak self] type in + self?.viewModel.didSelectMenuAction(type) + } + + dialogService.presentShareAlertFor( + string: .empty, + types: [ + .sendTokens, + .uploadFile, + .uploadMedia + ], + excludedActivityTypes: ShareContentType.address.excludedActivityTypes, + animated: true, + from: nil, + completion: nil, + didSelect: didSelect + ) + } + func showSystemPartnerMenu(sender: UIBarButtonItem) { guard let address = address else { return } @@ -214,7 +236,7 @@ private extension ChatDialogManager { ) } - func showFailedMessageAlert(id: String, sender: UIAlertController.SourceView) { + func showFailedMessageAlert(id: String, sender: UIAlertController.SourceView?) { dialogService.showAlert( title: .adamant.alert.retryOrDeleteTitle, message: .adamant.alert.retryOrDeleteBody, diff --git a/Adamant/Modules/Chat/View/Managers/ChatDisplayManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDisplayManager.swift index a716568cc..241aefdf8 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDisplayManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDisplayManager.swift @@ -90,6 +90,11 @@ final class ChatDisplayManager: MessagesDisplayDelegate { switch message.fullModel.status { case .failed: guard accessoryView.subviews.isEmpty else { break } + + if case .file = message.fullModel.content { + break + } + let icon = UIImageView(frame: CGRect(x: -28, y: -10, width: 20, height: 20)) icon.contentMode = .scaleAspectFit icon.tintColor = .adamant.secondary diff --git a/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift b/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift index 22740011e..1962c1b47 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift @@ -26,26 +26,14 @@ protocol ChatMenuManagerDelegate: AnyObject { final class ChatMenuManager: NSObject { weak var delegate: ChatMenuManagerDelegate? - var isiOSAppOnMac: Bool = { -#if targetEnvironment(macCatalyst) - return true -#else - if #available(iOS 14.0, *) { - return ProcessInfo.processInfo.isiOSAppOnMac - } else { - return false - } -#endif - }() - // MARK: Init init(delegate: ChatMenuManagerDelegate?) { self.delegate = delegate } - func setup(for contentView: UIView) { - guard !isiOSAppOnMac else { + func setup(for contentView: UIView ) { + guard !isMacOS else { let interaction = UIContextMenuInteraction(delegate: self) contentView.addInteraction(interaction) return @@ -83,7 +71,7 @@ final class ChatMenuManager: NSObject { } @objc func handleLongPress(_ gesture: UILongPressGestureRecognizer) { - guard !isiOSAppOnMac else { return } + guard !isMacOS else { return } guard gesture.state == .began, let contentView = gesture.view diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index 251f45749..b79e334fe 100644 --- a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift +++ b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift @@ -67,7 +67,8 @@ ) messageContainerSize = size - messageContainerSize.width += messageInsets.horizontal + messageContainerSize.width += messageInsets.horizontal + + additionalWidth messageContainerSize.height = contentViewHeight } @@ -79,6 +80,12 @@ + additionalHeight } + if case let .file(model) = getMessages()[indexPath.section].fullModel.content { + let contentViewHeight: CGFloat = model.value.height() + messageContainerSize.width = maxWidth + messageContainerSize.height = contentViewHeight + } + return messageContainerSize } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift index 16e7413b9..8d24b2168 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift @@ -13,7 +13,8 @@ import CommonKit final class ChatInputBar: InputBarAccessoryView { var onAttachmentButtonTap: (() -> Void)? - + var onImagePasted: ((UIImage) -> Void)? + var fee = "" { didSet { updateFeeLabel() } } @@ -22,6 +23,10 @@ final class ChatInputBar: InputBarAccessoryView { didSet { updateIsEnabled() } } + var isForcedSendEnabled = false { + didSet { updateSendIsEnabled() } + } + var isAttachmentButtonEnabled = true { didSet { updateIsAttachmentButtonEnabled() } } @@ -51,7 +56,35 @@ final class ChatInputBar: InputBarAccessoryView { override func didMoveToWindow() { super.didMoveToWindow() - sendButton.isEnabled = !inputTextView.text.isEmpty + sendButton.isEnabled = (isEnabled && !inputTextView.text.isEmpty) || isForcedSendEnabled + } + + override func calculateIntrinsicContentSize() -> CGSize { + let superSize = super.calculateIntrinsicContentSize() + + // Calculate the required height + let superTopStackViewHeight = topStackView.arrangedSubviews.count > .zero + ? topStackView.bounds.height + : .zero + + let validTopStackViewHeight = topStackView.arrangedSubviews.map { + $0.frame.height + }.reduce(0, +) + + return .init( + width: superSize.width, + height: superSize.height + - superTopStackViewHeight + + validTopStackViewHeight + ) + } + + override func inputTextViewDidChange() { + super.inputTextViewDidChange() + + sendButton.isEnabled = isForcedSendEnabled + ? true + : sendButton.isEnabled } } @@ -78,6 +111,10 @@ private extension ChatInputBar { updateIsAttachmentButtonEnabled() } + func updateSendIsEnabled() { + sendButton.isEnabled = (isEnabled && !inputTextView.text.isEmpty) || isForcedSendEnabled + } + func updateIsAttachmentButtonEnabled() { let isEnabled = isEnabled && isAttachmentButtonEnabled @@ -189,6 +226,28 @@ private extension ChatInputBar { } } +extension InputTextView { + open override func canPerformAction( + _ action: Selector, + withSender sender: Any? + ) -> Bool { + if action == #selector(paste(_:)) && UIPasteboard.general.image != nil { + return true + } + return super.canPerformAction(action, withSender: sender) + } + + open override func paste(_ sender: Any?) { + super.paste(sender) + + guard let view = inputBarAccessoryView as? ChatInputBar, + let image = UIPasteboard.general.image + else { return } + + view.onImagePasted?(image) + } +} + private let attachmentButtonSize: CGFloat = 36 private let baseInsetSize: CGFloat = 6 private let buttonHeight: CGFloat = 36 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift new file mode 100644 index 000000000..36c570a1e --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -0,0 +1,66 @@ +// +// ChatMediaCell.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import MessageKit + +final class ChatMediaCell: MessageContentCell { + let containerMediaView = ChatMediaContainerView() + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override func prepareForReuse() { + containerMediaView.prepareForReuse() + } + + override var isSelected: Bool { + didSet { + containerMediaView.isSelected = isSelected + } + } + + override func configure( + with message: MessageType, + at indexPath: IndexPath, + and messagesCollectionView: MessagesCollectionView + ) { + super.configure(with: message, at: indexPath, and: messagesCollectionView) + messageContainerView.style = .none + messageContainerView.backgroundColor = .clear + } + + override func layoutMessageContainerView( + with attributes: MessagesCollectionViewLayoutAttributes + ) { + super.layoutMessageContainerView(with: attributes) + + containerMediaView.snp.remakeConstraints { make in + make.horizontalEdges.equalToSuperview() + make.top.equalTo(messageContainerView.frame.origin.y) + make.height.equalTo(messageContainerView.frame.height) + } + } +} + +private extension ChatMediaCell { + func configure() { + contentView.addSubview(containerMediaView) + containerMediaView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift new file mode 100644 index 000000000..5abb23a62 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -0,0 +1,52 @@ +// +// ChatMediaContainerView+Model.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +extension ChatMediaContainerView { + struct Model: ChatReusableViewModelProtocol, MessageModel { + let id: String + let isFromCurrentSender: Bool + let reactions: Set? + var content: ChatMediaContentView.Model + let address: String + let opponentAddress: String + let txStatus: MessageStatus + var status: FileMessageStatus + + static let `default` = Self( + id: "", + isFromCurrentSender: true, + reactions: nil, + content: .default, + address: "", + opponentAddress: "", + txStatus: .failed, + status: .failed + ) + + func makeReplyContent() -> NSAttributedString { + let mediaFilesCount = content.fileModel.files.filter { file in + return file.fileType == .image || file.fileType == .video + }.count + + let otherFilesCount = content.fileModel.files.count - mediaFilesCount + + let comment = content.comment.string + + let text = FilePresentationHelper.getFilePresentationText( + mediaFilesCount: mediaFilesCount, + otherFilesCount: otherFilesCount, + comment: comment + ) + + return ChatMessageFactory.markdownParser.parse(text) + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift new file mode 100644 index 000000000..28c09df9b --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -0,0 +1,354 @@ +// +// ChatMediaContainerView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 14.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import Combine +import CommonKit + +final class ChatMediaContainerView: UIView, ChatModelView { + private lazy var swipeView: SwipeableView = { + let view = SwipeableView(frame: .zero, view: self) + return view + }() + + private let spacingView: UIView = { + let view = UIView() + view.setContentCompressionResistancePriority(.dragThatCanResizeScene, for: .horizontal) + return view + }() + + private lazy var horizontalStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .center + stack.axis = .horizontal + stack.spacing = horizontalStackSpace + return stack + }() + + private lazy var ownReactionLabel: UILabel = { + let label = UILabel() + label.text = getReaction(for: model.address) + label.backgroundColor = .adamant.pickedReactionBackground + label.layer.cornerRadius = ownReactionSize.height / 2 + label.textAlignment = .center + label.layer.masksToBounds = true + + label.snp.makeConstraints { make in + make.width.equalTo(ownReactionSize.width) + make.height.equalTo(ownReactionSize.height) + } + + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(tapReactionAction) + ) + + label.addGestureRecognizer(tapGesture) + label.isUserInteractionEnabled = true + return label + }() + + private lazy var opponentReactionLabel: UILabel = { + let label = UILabel() + label.text = getReaction(for: model.opponentAddress) + label.textAlignment = .center + label.layer.masksToBounds = true + label.backgroundColor = .adamant.pickedReactionBackground + label.layer.cornerRadius = opponentReactionSize.height / 2 + + label.snp.makeConstraints { make in + make.width.equalTo(opponentReactionSize.width) + make.height.equalTo(opponentReactionSize.height) + } + + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(tapReactionAction) + ) + + label.addGestureRecognizer(tapGesture) + label.isUserInteractionEnabled = true + return label + }() + + private lazy var reactionsStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .center + stack.axis = .vertical + stack.spacing = reactionsStackSpace + + stack.addArrangedSubview(statusButton) + stack.addArrangedSubview(ownReactionLabel) + stack.addArrangedSubview(opponentReactionLabel) + return stack + }() + + private lazy var statusButton: UIButton = { + let button = UIButton() + button.addTarget(self, action: #selector(onStatusButtonTap), for: .touchUpInside) + return button + }() + + private lazy var contentView = ChatMediaContentView() + private lazy var chatMenuManager = ChatMenuManager(delegate: self) + + // MARK: Dependencies + + var chatMessagesListViewModel: ChatMessagesListViewModel? + + // MARK: Proprieties + + var subscription: AnyCancellable? + + var model: Model = .default { + didSet { update() } + } + + var actionHandler: (ChatAction) -> Void = { _ in } { + didSet { contentView.actionHandler = actionHandler } + } + + var isSelected: Bool = false { + didSet { + contentView.isSelected = isSelected + } + } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + @objc func onStatusButtonTap() { + if model.status == .failed, + let file = model.content.fileModel.files.first { + actionHandler(.openFile(messageId: model.id, file: file)) + return + } + + guard case .needToDownload = model.status else { + return + } + + let fileModel = model.content.fileModel + let fileList = Array(fileModel.files.prefix(FilesConstants.maxFilesCount)) + + actionHandler(.forceDownloadAllFiles( + messageId: fileModel.messageId, + files: fileList + )) + } +} + +extension ChatMediaContainerView { + func configure() { + addSubview(swipeView) + swipeView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + addSubview(horizontalStack) + horizontalStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(4) + } + + swipeView.swipeStateAction = { [actionHandler] state in + actionHandler(.swipeState(state: state)) + } + + reactionsStack.snp.makeConstraints { $0.width.equalTo(reactionsWidth) } + chatMenuManager.setup(for: contentView) + } + + func update() { + contentView.model = model.content + + swipeView.didSwipeAction = { [actionHandler, model] in + actionHandler(.reply(message: model)) + } + + updateLayout() + + ownReactionLabel.isHidden = getReaction(for: model.address) == nil + opponentReactionLabel.isHidden = getReaction(for: model.opponentAddress) == nil + updateOwnReaction() + updateOpponentReaction() + updateStatus(model.status) + } + + func updateStatus(_ status: FileMessageStatus) { + statusButton.setImage(status.image, for: .normal) + statusButton.tintColor = status.imageTintColor + statusButton.isHidden = status == .success + } + + func updateLayout() { + var viewsList = [spacingView, reactionsStack, contentView] + + viewsList = model.isFromCurrentSender + ? viewsList + : viewsList.reversed() + + guard horizontalStack.arrangedSubviews != viewsList else { return } + horizontalStack.arrangedSubviews.forEach(horizontalStack.removeArrangedSubview) + viewsList.forEach(horizontalStack.addArrangedSubview) + } + + func updateOwnReaction() { + ownReactionLabel.text = getReaction(for: model.address) + ownReactionLabel.backgroundColor = model.content.backgroundColor.uiColor.mixin( + infusion: .lightGray, + alpha: 0.15 + ) + } + + func updateOpponentReaction() { + guard let reaction = getReaction(for: model.opponentAddress), + let senderPublicKey = getSenderPublicKeyInReaction(for: model.opponentAddress) + else { + opponentReactionLabel.attributedText = nil + opponentReactionLabel.text = nil + return + } + + let fullString = NSMutableAttributedString(string: reaction) + + if let image = chatMessagesListViewModel?.avatarService.avatar( + for: senderPublicKey, + size: opponentReactionImageSize.width + ) { + let replyImageAttachment = NSTextAttachment() + replyImageAttachment.image = image + replyImageAttachment.bounds = .init( + origin: .init(x: .zero, y: -3), + size: opponentReactionImageSize + ) + + let imageString = NSAttributedString(attachment: replyImageAttachment) + fullString.append(NSAttributedString(string: " ")) + fullString.append(imageString) + } + + opponentReactionLabel.attributedText = fullString + opponentReactionLabel.backgroundColor = model.content.backgroundColor.uiColor.mixin( + infusion: .lightGray, + alpha: 0.15 + ) + } + + func getSenderPublicKeyInReaction(for senderAddress: String) -> String? { + model.reactions?.first( + where: { $0.sender == senderAddress } + )?.senderPublicKey + } + + func getReaction(for address: String) -> String? { + model.reactions?.first( + where: { $0.sender == address } + )?.reaction + } + + @objc func tapReactionAction() { + chatMenuManager.presentMenuProgrammatically(for: contentView) + } +} + +extension ChatMediaContainerView: ChatMenuManagerDelegate { + func getCopyView() -> UIView? { + copy(with: model)?.contentView + } + + func presentMenu( + copyView: UIView, + size: CGSize, + location: CGPoint, + tapLocation: CGPoint, + getPositionOnScreen: @escaping () -> CGPoint + ) { + let arguments = ChatContextMenuArguments.init( + copyView: copyView, + size: size, + location: location, + tapLocation: tapLocation, + messageId: model.id, + menu: makeContextMenu(), + selectedEmoji: getReaction(for: model.address), + getPositionOnScreen: getPositionOnScreen + ) + actionHandler(.presentMenu(arg: arguments)) + } +} + +extension ChatMediaContainerView { + func makeContextMenu() -> AMenuSection { + let remove = AMenuItem.action( + title: .adamant.chat.remove, + systemImageName: "trash", + style: .destructive + ) { [actionHandler, model] in + actionHandler(.remove(id: model.id)) + } + + let report = AMenuItem.action( + title: .adamant.chat.report, + systemImageName: "exclamationmark.bubble" + ) { [actionHandler, model] in + actionHandler(.report(id: model.id)) + } + + let reply = AMenuItem.action( + title: .adamant.chat.reply, + systemImageName: "arrowshape.turn.up.left" + ) { [actionHandler, model] in + actionHandler(.reply(message: model)) + } + + let copy = AMenuItem.action( + title: .adamant.chat.copy, + systemImageName: "doc.on.doc" + ) { [actionHandler, model] in + actionHandler(.copy(text: model.content.comment.string)) + } + + let actions: [AMenuItem] = model.content.comment.string.isEmpty + ? [reply, report, remove] + : [reply, copy, report, remove] + + return AMenuSection(actions) + } +} + +extension ChatMediaContainerView { + func copy(with model: Model) -> ChatMediaContainerView? { + let view = ChatMediaContainerView(frame: frame) + view.contentView.model = model.content + return view + } +} + +extension ChatMediaContainerView.Model { + func height() -> CGFloat { + content.height() + } +} + +private let contentWidth: CGFloat = 260 +private let reactionsWidth: CGFloat = 50 +private let horizontalStackSpace: CGFloat = 5 +private let reactionsStackSpace: CGFloat = 12 +private let ownReactionSize = CGSize(width: 40, height: 27) +private let opponentReactionSize = CGSize(width: 55, height: 27) +private let opponentReactionImageSize = CGSize(width: 12, height: 12) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift new file mode 100644 index 000000000..e6e2ae264 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -0,0 +1,62 @@ +// +// ChatMediaContnentView+Model.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +extension ChatMediaContentView { + struct Model: Equatable { + let id: String + var fileModel: FileModel + var isHidden: Bool + let isFromCurrentSender: Bool + let isReply: Bool + let replyMessage: NSAttributedString + let replyId: String + let comment: NSAttributedString + let backgroundColor: ChatMessageBackgroundColor + + static let `default` = Self( + id: "", + fileModel: .default, + isHidden: false, + isFromCurrentSender: false, + isReply: false, + replyMessage: NSAttributedString(string: .empty), + replyId: .empty, + comment: NSAttributedString(string: .empty), + backgroundColor: .failed + ) + } + + struct FileModel: Equatable { + let messageId: String + var files: [ChatFile] + var isMediaFilesOnly: Bool + let isFromCurrentSender: Bool + let txStatus: MessageStatus + + static let `default` = Self( + messageId: .empty, + files: [], + isMediaFilesOnly: false, + isFromCurrentSender: false, + txStatus: .failed + ) + } + + struct FileContentModel { + let chatFile: ChatFile + let txStatus: MessageStatus + + static let `default` = Self( + chatFile: .default, + txStatus: .failed + ) + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift new file mode 100644 index 000000000..8cb0ffcaf --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -0,0 +1,298 @@ +// +// ChatMediaContnentView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 14.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SnapKit +import UIKit +import CommonKit +import FilesPickerKit +import MessageKit + +final class ChatMediaContentView: UIView { + private let commentLabel = MessageLabel() + + private let spacingView: UIView = { + let view = UIView() + view.snp.makeConstraints { $0.height.equalTo(verticalInsets) } + return view + }() + + private var replyMessageLabel = UILabel() + + private lazy var colorView: UIView = { + let view = UIView() + view.clipsToBounds = true + view.backgroundColor = .adamant.active + return view + }() + + private lazy var replyView: UIView = { + let view = UIView() + view.backgroundColor = .lightGray.withAlphaComponent(0.15) + view.layer.cornerRadius = 5 + view.clipsToBounds = true + + view.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(didTap) + )) + + view.addSubview(colorView) + view.addSubview(replyMessageLabel) + + replyMessageLabel.numberOfLines = 1 + + colorView.snp.makeConstraints { + $0.top.leading.bottom.equalToSuperview() + $0.width.equalTo(2) + } + replyMessageLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview().offset(-5) + $0.leading.equalTo(colorView.snp.trailing).offset(6) + } + view.snp.makeConstraints { make in + make.height.equalTo(replyViewDynamicHeight) + } + return view + }() + + private lazy var replyContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + view.addSubview(replyView) + + replyView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(verticalInsets) + make.horizontalEdges.equalToSuperview().inset(horizontalInsets) + make.bottom.equalToSuperview() + } + + view.snp.makeConstraints { make in + make.height.equalTo(replyContainerViewDynamicHeight) + } + return view + }() + + private lazy var listFileContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + view.addSubview(fileContainerView) + + fileContainerView.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(verticalInsets) + make.horizontalEdges.equalToSuperview().inset(horizontalInsets) + } + + return view + }() + + private lazy var commentContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + view.addSubview(commentLabel) + + commentLabel.snp.makeConstraints { make in + make.verticalEdges.equalToSuperview().inset(verticalInsets) + make.horizontalEdges.equalToSuperview().inset(horizontalInsets) + } + + return view + }() + + private lazy var verticalStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [replyContainerView, spacingView, mediaContainerView, listFileContainerView, commentContainerView]) + stack.axis = .vertical + stack.spacing = .zero + stack.layer.masksToBounds = true + return stack + }() + + private lazy var uploadImageView = UIImageView(image: .asset(named: "downloadIcon")) + + private lazy var mediaContainerView = MediaContainerView() + private lazy var fileContainerView = FileListContainerView() + + var replyViewDynamicHeight: CGFloat { + model.isReply ? replyViewHeight : .zero + } + + var replyContainerViewDynamicHeight: CGFloat { + model.isReply + ? replyViewHeight + verticalInsets + : .zero + } + + var model: Model = .default { + didSet { + guard oldValue != model else { return } + update() + } + } + + var isSelected: Bool = false { + didSet { + animateIsSelected( + isSelected, + originalColor: model.backgroundColor.uiColor + ) + } + } + + var actionHandler: (ChatAction) -> Void = { _ in } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + layer.borderColor = model.backgroundColor.uiColor.cgColor + } +} + +private extension ChatMediaContentView { + func configure() { + layer.masksToBounds = true + layer.cornerRadius = 16 + layer.borderWidth = 2.5 + + addSubview(verticalStack) + verticalStack.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + addSubview(uploadImageView) + uploadImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(imageSize) + } + + uploadImageView.transform = CGAffineTransform(rotationAngle: .pi) + + commentLabel.enabledDetectors = [.url] + commentLabel.setAttributes([.foregroundColor: UIColor.adamant.active], detector: .url) + } + + func update() { + alpha = model.isHidden ? .zero : 1.0 + backgroundColor = model.backgroundColor.uiColor + layer.borderColor = model.backgroundColor.uiColor.cgColor + + uploadImageView.isHidden = model.fileModel.txStatus != .failed + + commentLabel.attributedText = model.comment + commentLabel.isHidden = model.comment.string.isEmpty + commentContainerView.isHidden = model.comment.string.isEmpty + replyContainerView.isHidden = !model.isReply + spacingView.isHidden = !model.fileModel.isMediaFilesOnly + + replyMessageLabel.attributedText = model.isReply + ? model.replyMessage + : nil + + replyContainerView.snp.updateConstraints { make in + make.height.equalTo(replyContainerViewDynamicHeight) + } + + let spaceHeight = model.fileModel.isMediaFilesOnly && model.isReply + ? verticalInsets + : .zero + spacingView.snp.remakeConstraints { $0.height.equalTo(spaceHeight) } + + updateStackLayout() + } + + func updateStackLayout() { + spacingView.isHidden = !model.fileModel.isMediaFilesOnly + mediaContainerView.isHidden = !model.fileModel.isMediaFilesOnly + listFileContainerView.isHidden = model.fileModel.isMediaFilesOnly + + if model.fileModel.isMediaFilesOnly { + mediaContainerView.actionHandler = actionHandler + mediaContainerView.model = model.fileModel + } else { + fileContainerView.actionHandler = actionHandler + fileContainerView.model = model.fileModel + } + } + + @objc func didTap() { + actionHandler(.scrollTo(message: .init( + id: model.id, + replyId: model.replyId, + message: NSAttributedString(string: .empty), + messageReply: NSAttributedString(string: .empty), + backgroundColor: .failed, + isFromCurrentSender: true, + reactions: nil, + address: .empty, + opponentAddress: .empty, + isHidden: false + ))) + } +} + +extension ChatMediaContentView.Model { + func height() -> CGFloat { + let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : .zero + + var spaceCount: CGFloat = fileModel.isMediaFilesOnly ? .zero : 1 + + if isReply { + spaceCount += 2 + } + + if !comment.string.isEmpty { + spaceCount += 2 + } + + let stackWidth = MediaContainerView.stackWidth + + return fileModel.height() + + spaceCount * verticalInsets + + labelSize(for: comment, considering: stackWidth - horizontalInsets * 2).height + + replyViewDynamicHeight + } + + func labelSize( + for attributedText: NSAttributedString, + considering maxWidth: CGFloat + ) -> CGSize { + guard !attributedText.string.isEmpty else { return .zero } + + let textContainer = NSTextContainer( + size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude) + ) + let layoutManager = NSLayoutManager() + + layoutManager.addTextContainer(textContainer) + + let textStorage = NSTextStorage(attributedString: attributedText) + textStorage.addLayoutManager(layoutManager) + + let rect = layoutManager.usedRect(for: textContainer) + + return .init(width: rect.width, height: rect.height + additionalHeight) + } +} + +private let verticalInsets: CGFloat = 8 +private let horizontalInsets: CGFloat = 12 +private let replyViewHeight: CGFloat = 25 +private let additionalHeight: CGFloat = 2 +private let imageSize: CGFloat = 70 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift new file mode 100644 index 000000000..78d475e10 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift @@ -0,0 +1,100 @@ +// +// FileListContainerView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SnapKit +import UIKit +import CommonKit +import FilesPickerKit +import Combine + +final class FileListContainerView: UIView { + private lazy var filesStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = Self.stackSpacing + + for _ in 0.. Void = { _ in } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +private extension FileListContainerView { + func configure() { + addSubview(filesStack) + filesStack.snp.makeConstraints { $0.directionalEdges.equalToSuperview() } + } + + func update() { + let fileList = model.files.prefix(FilesConstants.maxFilesCount) + + let filesToDownload = fileList.filter { + $0.fileType.isMedia + && ( + (!$0.isCached && $0.isFullMediaDownloadAllowed) + || ( + $0.previewImage == nil + && $0.file.preview != nil + && $0.isPreviewDownloadAllowed + ) + ) + } + + if !filesToDownload.isEmpty { + actionHandler(.downloadContentIfNeeded( + messageId: model.messageId, + files: filesToDownload + )) + } + + filesStack.arrangedSubviews.forEach { $0.isHidden = true } + + for (index, file) in fileList.enumerated() { + let view = filesStack.arrangedSubviews[index] as? FileListContentView + view?.isHidden = false + view?.model = .init( + chatFile: file, + txStatus: model.txStatus + ) + view?.buttonActionHandler = { [weak self, file, model] in + self?.actionHandler( + .openFile( + messageId: model.messageId, + file: file + ) + ) + } + } + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift new file mode 100644 index 000000000..9a27186e2 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift @@ -0,0 +1,263 @@ +// +// FileListContentView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import CommonKit +import SwiftUI + +final class FileListContentView: UIView { + private lazy var iconImageView: UIImageView = UIImageView() + private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) + private lazy var videoIconIV = UIImageView(image: .asset(named: "playVideoIcon")) + + private lazy var spinner: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.isHidden = true + view.color = .white + view.backgroundColor = .darkGray.withAlphaComponent(0.45) + return view + }() + + private lazy var horizontalStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .center + stack.axis = .horizontal + stack.spacing = stackSpacing + + stack.addArrangedSubview(iconImageView) + stack.addArrangedSubview(vStack) + return stack + }() + + private let nameLabel = UILabel(font: nameFont, textColor: .adamant.textColor) + private let sizeLabel = UILabel(font: sizeFont, textColor: .lightGray) + private let additionalLabel = UILabel(font: additionalFont, textColor: .adamant.cellColor) + + private lazy var vStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .leading + stack.axis = .vertical + stack.spacing = verticalStackSpacing + stack.backgroundColor = .clear + + stack.addArrangedSubview(nameLabel) + stack.addArrangedSubview(additionalDataStack) + return stack + }() + + private lazy var additionalDataStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .center + stack.axis = .horizontal + stack.spacing = stackSpacing + + let progressBar = CircularProgressView { [weak self] in + guard let self = self else { return .init(progress: .zero, hidden: true) } + return self.progressState + } + let controller = UIHostingController(rootView: progressBar) + controller.view.backgroundColor = .clear + + stack.addArrangedSubview(sizeLabel) + stack.addArrangedSubview(controller.view) + return stack + }() + + private lazy var tapBtn: UIButton = { + let btn = UIButton() + btn.addTarget(self, action: #selector(tapBtnAction), for: .touchUpInside) + return btn + }() + + private lazy var progressState: CircularProgressState = { + .init( + lineWidth: 2.0, + backgroundColor: .lightGray, + progressColor: .white, + progress: .zero, + hidden: true + ) + }() + + var model: ChatMediaContentView.FileContentModel = .default { + didSet { + update() + } + } + + var buttonActionHandler: (() -> Void)? + + init(model: ChatMediaContentView.FileContentModel) { + super.init(frame: .zero) + backgroundColor = .clear + configure() + self.model = model + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override func layoutSubviews() { + super.layoutSubviews() + + iconImageView.layer.cornerRadius = 5 + } + + @objc func tapBtnAction() { + buttonActionHandler?() + } +} + +private extension FileListContentView { + func configure() { + addSubview(horizontalStack) + horizontalStack.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + iconImageView.snp.makeConstraints { make in + make.size.equalTo(imageSize) + } + + addSubview(additionalLabel) + additionalLabel.snp.makeConstraints { make in + make.center.equalTo(iconImageView.snp.center) + } + + addSubview(spinner) + spinner.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + make.size.equalTo(imageSize / 2) + } + + addSubview(downloadImageView) + downloadImageView.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + make.size.equalTo(imageSize / 1.3) + } + + addSubview(videoIconIV) + videoIconIV.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + make.size.equalTo(imageSize / 2) + } + + addSubview(tapBtn) + tapBtn.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + nameLabel.lineBreakMode = .byTruncatingMiddle + nameLabel.textAlignment = .left + sizeLabel.textAlignment = .left + iconImageView.layer.cornerRadius = 5 + iconImageView.layer.masksToBounds = true + iconImageView.contentMode = .scaleAspectFill + additionalLabel.textAlignment = .center + videoIconIV.tintColor = .adamant.active + + videoIconIV.addShadow() + downloadImageView.addShadow() + spinner.addShadow(shadowColor: .white) + } + + func update() { + let chatFile = model.chatFile + + let image: UIImage? + if let previewImage = chatFile.previewImage { + image = previewImage + additionalLabel.isHidden = true + } else { + image = chatFile.fileType.isMedia + ? defaultMediaImage + : defaultImage + + additionalLabel.isHidden = chatFile.fileType.isMedia + } + + if iconImageView.image != image { + iconImageView.image = image + } + + downloadImageView.isHidden = chatFile.isCached + || chatFile.isBusy + || model.txStatus == .failed + || (chatFile.fileType.isMedia && chatFile.previewImage == nil) + + if chatFile.isDownloading { + if chatFile.previewImage == nil, + chatFile.file.preview != nil, + chatFile.downloadStatus.isPreviewDownloading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } else { + spinner.stopAnimating() + } + + if chatFile.isBusy { + if chatFile.isUploading { + progressState.hidden = false + } else { + progressState.hidden = !chatFile.downloadStatus.isOriginalDownloading + } + } else { + progressState.hidden = chatFile.progress == 100 + || chatFile.progress == nil + } + + let progress = chatFile.progress ?? .zero + progressState.progress = Double(progress) / 100 + + let fileType = chatFile.file.extension.map { ".\($0)" } ?? .empty + let fileName = chatFile.file.name ?? .adamant.chat.unknownTitle.uppercased() + + nameLabel.text = fileName.contains(fileType) + ? fileName + : "\(fileName.uppercased())\(fileType.uppercased())" + + sizeLabel.text = formatSize(chatFile.file.size) + additionalLabel.text = fileType.uppercased() + + videoIconIV.isHidden = !( + chatFile.isCached + && !chatFile.isBusy + && chatFile.fileType == .video + ) + } + + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useKB, .useBytes] + formatter.countStyle = .file + + return formatter.string(fromByteCount: bytes) + } +} + +private let additionalFont = UIFont.boldSystemFont(ofSize: 15) +private let nameFont = UIFont.systemFont(ofSize: 15) +private let sizeFont = UIFont.systemFont(ofSize: 13) +private let imageSize: CGFloat = 70 +private let stackSpacing: CGFloat = 12 +private let verticalStackSpacing: CGFloat = 3 +private let defaultImage: UIImage? = .asset(named: "defaultFileIcon") +private let defaultMediaImage: UIImage? = .asset(named: "defaultMediaBlur") +private let previewDownloadNotAllowedFont = UIFont.systemFont(ofSize: 6) +private var previewDownloadNotAllowedText: String { .localized("Chats.AutoDownloadPreview.Disabled") } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift new file mode 100644 index 000000000..acdeb9cbf --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -0,0 +1,286 @@ +// +// MediaContainerView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SnapKit +import UIKit +import CommonKit +import FilesPickerKit + +final class MediaContainerView: UIView { + private lazy var filesStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = stackSpacing + stack.alignment = .fill + stack.distribution = .fill + stack.layer.masksToBounds = true + + for chunk in 0..<(FilesConstants.maxFilesCount / 2) { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = stackSpacing + stackView.alignment = .fill + stackView.distribution = .fill + + for file in 0..<2 { + let view = MediaContentView() + view.layer.masksToBounds = true + view.snp.makeConstraints { + $0.height.equalTo(rowVerticalHeight) + } + stackView.addArrangedSubview(view) + } + + stack.addArrangedSubview(stackView) + } + + return stack + }() + + private lazy var previewDownloadNotAllowedLabel = EdgeInsetLabel( + font: previewDownloadNotAllowedFont, + textColor: .adamant.textColor.withAlphaComponent(0.4) + ) + + // MARK: Proprieties + + var model: ChatMediaContentView.FileModel = .default { + didSet { update() } + } + + var actionHandler: (ChatAction) -> Void = { _ in } + + static var stackWidth: CGFloat { + guard !isMacOS else { return defaultStackWidth } + return UIScreen.main.bounds.width - screenSpace + } + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +private extension MediaContainerView { + func configure() { + layer.masksToBounds = true + + addSubview(filesStack) + filesStack.snp.makeConstraints { + $0.directionalEdges.equalToSuperview() + $0.width.equalTo(Self.stackWidth) + } + + addSubview(previewDownloadNotAllowedLabel) + previewDownloadNotAllowedLabel.snp.makeConstraints { make in + make.center.equalTo(filesStack.snp.center) + } + + previewDownloadNotAllowedLabel.textInsets = previewTextInsets + previewDownloadNotAllowedLabel.text = previewDownloadNotAllowedText + previewDownloadNotAllowedLabel.numberOfLines = .zero + previewDownloadNotAllowedLabel.textAlignment = .center + previewDownloadNotAllowedLabel.backgroundColor = .adamant.moreReactionsBackground.withAlphaComponent(0.2) + previewDownloadNotAllowedLabel.layer.cornerRadius = 6 + previewDownloadNotAllowedLabel.addShadow(shadowColor: .adamant.primary) + previewDownloadNotAllowedLabel.clipsToBounds = true + previewDownloadNotAllowedLabel.sizeToFit() + } + + func update() { + let fileList = model.files.prefix(FilesConstants.maxFilesCount) + + let filesToDownload = fileList.filter { + $0.fileType.isMedia + && ( + (!$0.isCached && $0.isFullMediaDownloadAllowed) + || ( + $0.previewImage == nil + && $0.file.preview != nil + && $0.isPreviewDownloadAllowed + ) + ) + } + + if !filesToDownload.isEmpty { + actionHandler(.downloadContentIfNeeded( + messageId: model.messageId, + files: filesToDownload + )) + } + + updatePreviewDownloadLabel(files: Array(fileList)) + + for (index, stackView) in filesStack.arrangedSubviews.enumerated() { + guard let horizontalStackView = stackView as? UIStackView else { continue } + + var isHorizontal = false + + for (fileIndex, fileView) in horizontalStackView.arrangedSubviews.enumerated() { + guard let mediaView = fileView as? MediaContentView else { continue } + + let fileOverallIndex = index * horizontalStackView.arrangedSubviews.count + fileIndex + + if fileOverallIndex < fileList.count { + let file = fileList[fileOverallIndex] + mediaView.isHidden = false + mediaView.model = .init( + chatFile: file, + txStatus: model.txStatus + ) + mediaView.buttonActionHandler = { [weak self, file, model] in + self?.actionHandler( + .openFile( + messageId: model.messageId, + file: file + ) + ) + } + + if let resolution = file.file.resolution, + resolution.width > resolution.height { + isHorizontal = true + } + } else { + mediaView.isHidden = true + } + } + + updateCellsSize( + in: horizontalStackView, + isHorizontal: isHorizontal, + fileList: Array(fileList) + ) + } + } + + func updateCellsSize( + in horizontalStackView: UIStackView, + isHorizontal: Bool, + fileList: [ChatFile] + ) { + let filesStackWidth = Self.stackWidth + + let minimumWidth = calculateMinimumWidth(availableWidth: filesStackWidth) + let maximumWidth = calculateMaximumWidth(availableWidth: filesStackWidth) + + let height: CGFloat = isHorizontal + ? rowHorizontalHeight + : fileList.count == 1 ? rowVerticalHeight * 2 : rowVerticalHeight + + var totalWidthForEqualAspectRatio: CGFloat = 0.0 + + for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { + if let resolution = mediaView.model.chatFile.file.resolution { + let aspectRatio = resolution.width / resolution.height + let widthForEqualAspectRatio = height * aspectRatio + totalWidthForEqualAspectRatio += widthForEqualAspectRatio + } else { + totalWidthForEqualAspectRatio += height + } + } + + let scaleFactor = filesStackWidth / totalWidthForEqualAspectRatio + + for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { + if let resolution = mediaView.model.chatFile.file.resolution { + let aspectRatio = resolution.width / resolution.height + let widthForEqualAspectRatio = height * aspectRatio + var width = max(widthForEqualAspectRatio * scaleFactor, minimumWidth) + width = min(width, maximumWidth) + + mediaView.snp.remakeConstraints { + $0.width.equalTo(width - stackSpacing) + $0.height.equalTo(height) + } + } else { + mediaView.snp.remakeConstraints { + $0.height.equalTo(height) + $0.width.equalTo((filesStackWidth - stackSpacing) / 2) + } + } + } + } + + func calculateMinimumWidth(availableWidth: CGFloat) -> CGFloat { + (availableWidth - stackSpacing) * 0.3 + } + + func calculateMaximumWidth(availableWidth: CGFloat) -> CGFloat { + (availableWidth - stackSpacing) * 0.7 + } + + func updatePreviewDownloadLabel(files: [ChatFile]) { + guard let firstFile = files.first else { + previewDownloadNotAllowedLabel.isHidden = true + return + } + + let isPreviewDownloadAllowed = firstFile.isPreviewDownloadAllowed + let haveNoPreview = files.contains { + $0.fileType.isMedia + && $0.previewImage == nil + && $0.file.preview != nil + } + + if !isPreviewDownloadAllowed && haveNoPreview { + previewDownloadNotAllowedLabel.isHidden = false + } else { + previewDownloadNotAllowedLabel.isHidden = true + } + } +} + +extension ChatMediaContentView.FileModel { + func height() -> CGFloat { + let fileList = Array(files.prefix(FilesConstants.maxFilesCount)) + + guard isMediaFilesOnly else { + return FileListContainerView.cellSize * CGFloat(fileList.count) + + FileListContainerView.stackSpacing * CGFloat(fileList.count) + } + + let rows = fileList.chunked(into: 2) + var totalHeight: CGFloat = .zero + + for row in rows { + var isHorizontal = false + for row in row { + if let resolution = row.file.resolution, + resolution.width > resolution.height { + isHorizontal = true + } + } + + let height: CGFloat = isHorizontal + ? rowHorizontalHeight + : fileList.count == 1 ? rowVerticalHeight * 2 : rowVerticalHeight + + totalHeight += height + } + + return totalHeight + + stackSpacing * CGFloat(rows.count) + } +} + +private let stackSpacing: CGFloat = 1 +private let rowVerticalHeight: CGFloat = 200 +private let rowHorizontalHeight: CGFloat = 150 +private let defaultStackWidth: CGFloat = 280 +private let screenSpace: CGFloat = 110 +private let previewDownloadNotAllowedFont = UIFont.systemFont(ofSize: 12) +private let previewTextInsets: UIEdgeInsets = .init(top: 5, left: 5, bottom: 5, right: 5) +private var previewDownloadNotAllowedText: String { .localized("Chats.AutoDownloadPreview.Disabled") } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift new file mode 100644 index 000000000..baaba4c8c --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -0,0 +1,221 @@ +// +// MediaContentView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import SwiftUI + +final class MediaContentView: UIView { + private lazy var imageView: UIImageView = UIImageView() + private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) + private lazy var videoIconIV = UIImageView(image: .asset(named: "playVideoIcon")) + + private lazy var spinner: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.isHidden = true + view.color = .white + view.backgroundColor = .darkGray.withAlphaComponent(0.45) + return view + }() + + private lazy var tapBtn: UIButton = { + let btn = UIButton() + btn.addTarget(self, action: #selector(tapBtnAction), for: .touchUpInside) + return btn + }() + + private lazy var progressState: CircularProgressState = { + .init( + lineWidth: 2.0, + backgroundColor: .lightGray, + progressColor: .white, + progress: .zero, + hidden: true + ) + }() + + private lazy var durationLabel = EdgeInsetLabel( + font: durationFont, + textColor: .white.withAlphaComponent(0.8) + ) + + var model: ChatMediaContentView.FileContentModel = .default { + didSet { + update() + } + } + + var buttonActionHandler: (() -> Void)? + + init(model: ChatMediaContentView.FileContentModel) { + super.init(frame: .zero) + backgroundColor = .clear + configure() + + self.model = model + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + @objc func tapBtnAction() { + buttonActionHandler?() + } +} + +private extension MediaContentView { + func configure() { + addSubview(imageView) + imageView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + addSubview(spinner) + spinner.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.size.equalTo(imageSize / 2) + } + + addSubview(downloadImageView) + downloadImageView.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.size.equalTo(imageSize / 1.3) + } + + addSubview(durationLabel) + durationLabel.snp.makeConstraints { make in + make.bottom.trailing.equalToSuperview().offset(-10) + } + + addSubview(tapBtn) + tapBtn.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + addSubview(videoIconIV) + videoIconIV.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.size.equalTo(imageSize / 1.6) + } + + let progressBar = CircularProgressView { [weak self] in + guard let self = self else { return .init(progress: .zero, hidden: true) } + return self.progressState + } + let controller = UIHostingController(rootView: progressBar) + + controller.view.backgroundColor = .clear + addSubview(controller.view) + controller.view.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(15) + make.size.equalTo(progressSize) + } + + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + videoIconIV.tintColor = .adamant.active + + videoIconIV.addShadow() + downloadImageView.addShadow() + spinner.addShadow(shadowColor: .white) + controller.view.addShadow() + + durationLabel.textInsets = durationTextInsets + durationLabel.numberOfLines = .zero + durationLabel.textAlignment = .center + durationLabel.backgroundColor = .black.withAlphaComponent(0.1) + durationLabel.layer.cornerRadius = 6 + durationLabel.addShadow() + durationLabel.clipsToBounds = true + } + + func update() { + let chatFile = model.chatFile + + let image = chatFile.previewImage ?? defaultMediaImage + + if imageView.image != image { + imageView.image = image + } + + downloadImageView.isHidden = chatFile.isCached + || chatFile.isBusy + || model.txStatus == .failed + || chatFile.previewImage == nil + + videoIconIV.isHidden = !( + chatFile.isCached + && !chatFile.isBusy + && chatFile.fileType == .video + ) + + if chatFile.isDownloading { + if chatFile.previewImage == nil, + chatFile.file.preview != nil, + chatFile.downloadStatus.isPreviewDownloading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } else { + spinner.stopAnimating() + } + + if chatFile.isBusy { + if chatFile.isUploading { + progressState.hidden = false + } else { + progressState.hidden = !chatFile.downloadStatus.isOriginalDownloading + } + } else { + progressState.hidden = chatFile.progress == 100 + || chatFile.progress == nil + } + + let progress = chatFile.progress ?? .zero + progressState.progress = Double(progress) / 100 + + durationLabel.isHidden = chatFile.fileType != .video + + if let duration = chatFile.file.duration { + durationLabel.text = formatTime(seconds: Int(duration)) + } else { + durationLabel.text = "-:-" + } + } + + func formatTime(seconds: Int) -> String { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let seconds = seconds % 60 + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } +} + +private let imageSize: CGFloat = 70 +private let progressSize: CGFloat = 15 +private let stackSpacing: CGFloat = 12 +private let verticalStackSpacing: CGFloat = 3 +private let defaultImage: UIImage? = .asset(named: "defaultFileIcon") +private let defaultMediaImage: UIImage? = .asset(named: "defaultMediaBlur") +private let durationFont = UIFont.systemFont(ofSize: 10) +private let durationTextInsets: UIEdgeInsets = .init(top: 3, left: 3, bottom: 3, right: 3) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift new file mode 100644 index 000000000..a7b0306b3 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -0,0 +1,142 @@ +// +// FilesToolbarCollectionViewCell.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 20.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import FilesStorageKit +import CommonKit + +final class FilesToolbarCollectionViewCell: UICollectionViewCell { + private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) + private lazy var videoIconIV = UIImageView(image: .asset(named: "playVideoIcon")) + private lazy var nameLabel = UILabel(font: nameFont, textColor: .adamant.textColor) + private let additionalLabel = UILabel(font: additionalFont, textColor: .adamant.cellColor) + + private lazy var containerView: UIView = { + let view = UIView() + view.backgroundColor = .secondarySystemBackground + view.layer.masksToBounds = true + view.layer.cornerRadius = 5 + + view.addSubview(imageView) + view.addSubview(nameLabel) + view.addSubview(additionalLabel) + view.addSubview(videoIconIV) + + imageView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(nameLabel.snp.top).offset(-7) + } + + nameLabel.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(5) + make.bottom.equalToSuperview().offset(-7) + make.height.equalTo(17) + } + + additionalLabel.snp.makeConstraints { make in + make.center.equalTo(imageView.snp.center) + } + + videoIconIV.snp.makeConstraints { make in + make.center.equalTo(imageView.snp.center) + make.size.equalTo(30) + } + + return view + }() + + private lazy var removeBtn: UIButton = { + let btn = UIButton() + btn.setImage(.asset(named: "checkMarkIcon"), for: .normal) + btn.tintColor = .adamant.active + btn.addTarget(self, action: #selector(didTapRemoveBtn), for: .touchUpInside) + return btn + }() + + var buttonActionHandler: ((Int) -> Void)? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didTapRemoveBtn() { + buttonActionHandler?(removeBtn.tag) + } + + func update(_ file: FileResult, tag: Int) { + imageView.image = file.preview ?? defaultImage + removeBtn.tag = tag + + let fileType = file.extenstion ?? "" + let fileName = file.name ?? "UNKNWON" + + nameLabel.text = fileName.contains(fileType) + ? fileName + : "\(fileName.uppercased()).\(fileType.uppercased())" + + additionalLabel.text = fileType.uppercased() + additionalLabel.isHidden = file.preview != nil + + videoIconIV.isHidden = file.type != .video + layoutConstraints(file) + } +} + +private extension FilesToolbarCollectionViewCell { + func configure() { + addSubview(containerView) + containerView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview().inset(5) + } + + addSubview(removeBtn) + removeBtn.snp.makeConstraints { make in + make.top.equalTo(containerView.snp.top).offset(1) + make.trailing.equalTo(containerView.snp.trailing).offset(-1) + make.size.equalTo(25) + } + + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingMiddle + + removeBtn.addShadow() + + videoIconIV.tintColor = .adamant.active + videoIconIV.addShadow() + } + + func layoutConstraints(_ file: FileResult) { + if file.preview == nil { + imageView.snp.remakeConstraints { make in + make.top.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(10) + make.bottom.equalTo(nameLabel.snp.top).offset(-7) + } + return + } + + imageView.snp.remakeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(nameLabel.snp.top).offset(-7) + } + } +} + +private let defaultImage: UIImage? = .asset(named: "defaultFileIcon") +private let nameFont = UIFont.systemFont(ofSize: 13) +private let additionalFont = UIFont.boldSystemFont(ofSize: 15) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift new file mode 100644 index 000000000..8fe5294ba --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -0,0 +1,164 @@ +// +// FilesToolbarView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 17.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import FilesStorageKit +import CommonKit + +final class FilesToolbarView: UIView { + private lazy var collectionView: UICollectionView = { + let flow = UICollectionViewFlowLayout() + flow.minimumInteritemSpacing = 5 + flow.minimumLineSpacing = 5 + flow.scrollDirection = .horizontal + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flow) + collectionView.register(FilesToolbarCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: FilesToolbarCollectionViewCell.self) + ) + collectionView.backgroundColor = .clear + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + return collectionView + }() + + private lazy var containerView: UIView = { + let view = UIView() + view.addSubview(collectionView) + + collectionView.snp.makeConstraints { + $0.directionalEdges.equalToSuperview() + } + return view + }() + + private lazy var closeBtn: UIButton = { + let btn = UIButton() + btn.setImage( + UIImage(systemName: "xmark")?.withTintColor(.adamant.alert), + for: .normal + ) + btn.addTarget(self, action: #selector(didTapCloseBtn), for: .touchUpInside) + + btn.snp.makeConstraints { make in + make.size.equalTo(30) + } + return btn + }() + + private lazy var horizontalStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [containerView, closeBtn]) + stack.axis = .horizontal + stack.spacing = horizontalStackSpacing + return stack + }() + + // MARK: Proprieties + + private var data: [FileResult] = [] + var closeAction: (() -> Void)? + var updatedDataAction: (([FileResult]) -> Void)? + var openFileAction: ((FileResult) -> Void)? + + // MARK: Init + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + func configure() { + addSubview(horizontalStack) + horizontalStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview().inset(verticalInsets) + $0.horizontalEdges.equalToSuperview().inset(horizontalInsets) + } + } + + // MARK: Actions + + @objc private func didTapCloseBtn() { + closeAction?() + } + + private func removeFile(at index: Int) { + data.remove(at: index) + collectionView.reloadData() + updatedDataAction?(data) + } +} + +extension FilesToolbarView { + func update(_ data: [FileResult]) { + self.data = data + collectionView.reloadData() + Task { + collectionView.scrollToItem( + at: .init(row: data.count - 1, section: .zero), + at: .right, + animated: true + ) + } + } +} + +extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + data.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: String(describing: FilesToolbarCollectionViewCell.self), + for: indexPath + ) as? FilesToolbarCollectionViewCell else { + return UICollectionViewCell() + } + + cell.update(data[indexPath.row], tag: indexPath.row) + cell.buttonActionHandler = { [weak self] index in + self?.removeFile(at: index) + } + return cell + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + .init( + width: self.frame.height - itemOffset, + height: self.frame.height - itemOffset + ) + } + + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + openFileAction?(data[indexPath.row]) + } +} + +private let horizontalStackSpacing: CGFloat = 25 +private let verticalInsets: CGFloat = 8 +private let horizontalInsets: CGFloat = 12 +private let itemOffset: CGFloat = 10 diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift new file mode 100644 index 000000000..da6cef3ae --- /dev/null +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -0,0 +1,1175 @@ +// +// ChatFileService.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 01.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import CommonKit +import UIKit +import Combine +import FilesStorageKit +import CoreData + +private struct FileUpload { + let file: FileResult + var isUploaded: Bool + var serverFileID: String? + var fileNonce: String? + var preview: RichMessageFile.Preview? +} + +private struct FileMessage { + var files: [FileUpload] + var message: String? + var txId: String? +} + +final class ChatFileService: ChatFileProtocol { + typealias UploadResult = (decodedData: Data, encodedData: Data, nonce: String, cid: String) + + // MARK: Dependencies + + private let accountService: AccountService + private let filesStorage: FilesStorageProtocol + private let chatsProvider: ChatsProvider + private let filesNetworkManager: FilesNetworkManagerProtocol + private let adamantCore: AdamantCore + + @Atomic private var downloadingFilesIDsArray: [String: DownloadStatus] = [:] + @Atomic private var uploadingFilesIDsArray: [String] = [] + @Atomic private var ignoreFilesIDsArray: [String] = [] + @Atomic private var busyFilesIDs: [String] = [] + @Atomic private var fileDownloadAttemptsCount: [String: Int] = [:] + @Atomic private var uploadingFilesDictionary: [String: FileMessage] = [:] + @Atomic private var fileProgressValue: [String: Int] = [:] + @Atomic private var previewDownloadsAttemps: [String: Int] = [:] + + private var subscriptions = Set() + private let maxDownloadAttemptsCount = 3 + private let maxDownloadPreivewAttemptsCount = 2 + + var uploadingFiles: [String] { + $uploadingFilesIDsArray.wrappedValue + } + + var downloadingFiles: [String: DownloadStatus] { + $downloadingFilesIDsArray.wrappedValue + } + + var filesLoadingProgress: [String: Int] { + $fileProgressValue.wrappedValue + } + + let updateFileFields = ObservableSender() + + init( + accountService: AccountService, + filesStorage: FilesStorageProtocol, + chatsProvider: ChatsProvider, + filesNetworkManager: FilesNetworkManagerProtocol, + adamantCore: AdamantCore + ) { + self.accountService = accountService + self.filesStorage = filesStorage + self.chatsProvider = chatsProvider + self.filesNetworkManager = filesNetworkManager + self.adamantCore = adamantCore + + addObservers() + } + + func sendFile( + text: String?, + chatroom: Chatroom?, + filesPicked: [FileResult]?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws { + guard let filesPicked = filesPicked else { return } + + let files = filesPicked.map { + FileUpload( + file: $0, + isUploaded: false, + serverFileID: nil, + fileNonce: nil, + preview: nil + ) + } + + let fileMessage = FileMessage.init(files: files) + + try await sendFile( + text: text, + chatroom: chatroom, + fileMessage: fileMessage, + replyMessage: replyMessage, + saveEncrypted: saveEncrypted + ) + } + + func resendMessage( + with id: String, + text: String?, + chatroom: Chatroom?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws { + guard let fileMessage = $uploadingFilesDictionary.wrappedValue[id] + else { return } + + try await sendFile( + text: text, + chatroom: chatroom, + fileMessage: fileMessage, + replyMessage: replyMessage, + saveEncrypted: saveEncrypted + ) + } + + func downloadFile( + file: ChatFile, + chatroom: Chatroom?, + saveEncrypted: Bool, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool + ) async throws { + let isCachedOriginal = filesStorage.isCachedLocally(file.file.id) + let isCachedPreview = filesStorage.isCachedInMemory(file.file.preview?.id ?? .empty) + + try await downloadFile( + file: file, + chatroom: chatroom, + shouldDownloadOriginalFile: !isCachedOriginal && fullMediaDownloadAllowed, + shouldDownloadPreviewFile: !isCachedPreview && previewDownloadAllowed, + saveEncrypted: saveEncrypted + ) + } + + func autoDownload( + file: ChatFile, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool + ) async { + guard !downloadingFiles.keys.contains(file.file.id), + !$ignoreFilesIDsArray.wrappedValue.contains(file.file.id), + !$busyFilesIDs.wrappedValue.contains(file.file.id) + else { + return + } + + defer { + $busyFilesIDs.mutate { $0.removeAll(where: { $0 == file.file.id }) } + } + + $busyFilesIDs.mutate { $0.append(file.file.id) } + + await handleAutoDownload( + file: file, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) + } + + func getDecodedData( + file: FilesStorageKit.File, + nonce: String, + chatroom: Chatroom? + ) throws -> Data { + guard let keyPair = accountService.keypair else { + throw FileManagerError.cantDecryptFile + } + + let data = try Data(contentsOf: file.url) + + guard file.isEncrypted else { + return data + } + + guard let decodedData = adamantCore.decodeData( + data, + rawNonce: nonce, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + privateKey: keyPair.privateKey + ) else { + throw FileManagerError.cantDecryptFile + } + + return decodedData + } + + func isDownloadPreviewLimitReached(for fileId: String) -> Bool { + let count = $previewDownloadsAttemps.wrappedValue[fileId] ?? .zero + guard count < maxDownloadPreivewAttemptsCount else { return true } + + $previewDownloadsAttemps.mutate { $0[fileId] = count + 1 } + return false + } +} + +private extension ChatFileService { + func addObservers() { + NotificationCenter.default + .publisher(for: .AdamantReachabilityMonitor.reachabilityChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + let connection = data.userInfo?[AdamantUserInfoKey.ReachabilityMonitor.connection] as? Bool + + guard connection == true else { return } + self?.ignoreFilesIDsArray.removeAll() + } + .store(in: &subscriptions) + + NotificationCenter.default + .publisher(for: .Storage.storageClear) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.ignoreFilesIDsArray.removeAll() + self?.fileProgressValue.removeAll() + self?.fileDownloadAttemptsCount.removeAll() + } + .store(in: &subscriptions) + } +} + +private extension ChatFileService { + func handleAutoDownload( + file: ChatFile, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool + ) async { + let shouldDownloadPreviewFile = shoudDownloadPreview( + file: file, + previewDownloadPolicy: previewDownloadPolicy, + havePartnerName: havePartnerName + ) + + let shouldDownloadOriginalFile = shoudDownloadOriginal( + file: file, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + havePartnerName: havePartnerName + ) + + guard shouldDownloadOriginalFile || shouldDownloadPreviewFile else { + cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) + return + } + + do { + try await downloadFile( + file: file, + chatroom: chatroom, + shouldDownloadOriginalFile: shouldDownloadOriginalFile, + shouldDownloadPreviewFile: shouldDownloadPreviewFile, + saveEncrypted: saveEncrypted + ) + } catch { + await handleDownloadError( + file: file, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) + } + } + + func handleDownloadError( + file: ChatFile, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool + ) async { + let count = $fileDownloadAttemptsCount.wrappedValue[file.file.id] ?? .zero + + guard count < maxDownloadAttemptsCount else { + $ignoreFilesIDsArray.mutate { $0.append(file.file.id) } + return + } + + $fileDownloadAttemptsCount.mutate { $0[file.file.id] = count + 1 } + + await handleAutoDownload( + file: file, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) + } + + func cacheFileToMemoryIfNeeded( + file: ChatFile, + chatroom: Chatroom? + ) { + guard let id = file.file.preview?.id, + let nonce = file.file.preview?.nonce, + let fileDTO = try? filesStorage.getFile(with: id).get(), + fileDTO.isPreview, + filesStorage.isCachedLocally(id), + !filesStorage.isCachedInMemory(id), + let image = try? cacheFileToMemory( + id: id, + file: fileDTO, + nonce: nonce, + chatroom: chatroom + ) + else { + return + } + + updateFileFields.send(.init( + id: file.file.id, + newId: nil, + fileNonce: nil, + preview: .some(image), + cached: nil, + downloadStatus: nil, + uploading: nil, + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil) + ) + } + + func cacheFileToMemory( + id: String, + file: FilesStorageKit.File, + nonce: String, + chatroom: Chatroom? + ) throws -> UIImage? { + let data = try Data(contentsOf: file.url) + + guard file.isEncrypted else { + return filesStorage.cacheImageToMemoryIfNeeded(id: id, data: data) + } + + let decodedData = try getDecodedData( + file: file, + nonce: nonce, + chatroom: chatroom + ) + + return filesStorage.cacheImageToMemoryIfNeeded(id: id, data: decodedData) + } + + func downloadFile( + file: ChatFile, + chatroom: Chatroom?, + shouldDownloadOriginalFile: Bool, + shouldDownloadPreviewFile: Bool, + saveEncrypted: Bool + ) async throws { + guard let keyPair = accountService.keypair, + let ownerId = accountService.account?.address, + let recipientId = chatroom?.partner?.address, + NetworkFileProtocolType(rawValue: file.storage) != nil, + (shouldDownloadOriginalFile || shouldDownloadPreviewFile), + !downloadingFiles.keys.contains(file.file.id) + else { return } + + guard !file.file.id.isEmpty, + !file.file.nonce.isEmpty + else { + throw FileManagerError.cantDownloadFile + } + + defer { + $downloadingFilesIDsArray.mutate { + $0[file.file.id] = nil + } + sendUpdate( + for: [file.file.id], + downloadStatus: .init( + isPreviewDownloading: false, + isOriginalDownloading: false + ), + uploading: nil + ) + } + + let downloadFile = shouldDownloadOriginalFile + && !filesStorage.isCachedLocally(file.file.id) + + let downloadPreview = file.file.preview != nil + && shouldDownloadPreviewFile + && !filesStorage.isCachedLocally(file.file.preview?.id ?? .empty) + + let downloadStatus: DownloadStatus = .init( + isPreviewDownloading: downloadPreview, + isOriginalDownloading: downloadFile + ) + + $downloadingFilesIDsArray.mutate { $0[file.file.id] = downloadStatus } + + // Here we start showing progress from the last saved value (fileProgressValue) instead of zero because in the UI we need to show progress when the download is frozen. We have N attempts to download, and the progress is overridden. + // So we start from the last saved progress and override it with 'downloadProgress' upon successful start of the download. + sendUpdate( + for: [file.file.id], + downloadStatus: .init( + isPreviewDownloading: downloadPreview, + isOriginalDownloading: downloadFile + ), + uploading: nil, + progress: downloadFile + ? $fileProgressValue.wrappedValue[file.file.id] ?? .zero + : nil + ) + + let totalProgress = Progress(totalUnitCount: 100) + + let (previewWeight, fileWeight) = getProgressWeights( + downloadPreview: false, + downloadFile: downloadFile + ) + + let previewProgress = Progress(totalUnitCount: previewWeight) + totalProgress.addChild(previewProgress, withPendingUnitCount: previewWeight) + + let fileProgress = Progress(totalUnitCount: fileWeight) + totalProgress.addChild(fileProgress, withPendingUnitCount: fileWeight) + + if let previewDTO = file.file.preview { + if downloadPreview { + try await downloadAndCacheFile( + id: previewDTO.id, + nonce: previewDTO.nonce, + storage: file.storage, + publicKey: chatroom?.partner?.publicKey ?? .empty, + privateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: .image, + fileExtension: previewDTO.extension ?? .empty, + isPreview: true, + downloadProgress: { value in + previewProgress.completedUnitCount = Int64(value.fractionCompleted * Double(previewWeight)) + } + ) + + let preview = filesStorage.getPreview(for: previewDTO.id) + + updateFileFields.send(.init( + id: file.file.id, + newId: nil, + fileNonce: nil, + preview: .some(preview), + cached: nil, + downloadStatus: nil, + uploading: nil, + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + )) + } else if !filesStorage.isCachedInMemory(previewDTO.id) { + cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) + } + } + + if downloadFile { + try await downloadAndCacheFile( + id: file.file.id, + nonce: file.nonce, + storage: file.storage, + publicKey: chatroom?.partner?.publicKey ?? .empty, + privateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: file.fileType, + fileExtension: file.file.extension ?? .empty, + isPreview: false, + downloadProgress: { [weak self] value in + fileProgress.completedUnitCount = Int64(value.fractionCompleted * Double(fileWeight)) + + self?.sendProgress( + for: file.file.id, + progress: Int(totalProgress.fractionCompleted * 100) + ) + } + ) + + let cached = filesStorage.isCachedLocally(file.file.id) + + updateFileFields.send(.init( + id: file.file.id, + newId: nil, + fileNonce: nil, + preview: nil, + cached: cached, + downloadStatus: nil, + uploading: nil, + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil) + ) + } + } + + func getProgressWeights( + downloadPreview: Bool, + downloadFile: Bool + ) -> (previewWeight: Int64, fileWeight: Int64) { + var previewWeight: Int64 = .zero + var fileWeight: Int64 = .zero + + if downloadPreview && downloadFile { + previewWeight = 10 + fileWeight = 90 + } else if downloadPreview && !downloadFile { + previewWeight = 100 + fileWeight = .zero + } else if !downloadPreview && downloadFile { + previewWeight = .zero + fileWeight = 100 + } + + return (previewWeight, fileWeight) + } + + func downloadAndCacheFile( + id: String, + nonce: String, + storage: String, + publicKey: String, + privateKey: String, + ownerId: String, + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + fileExtension: String, + isPreview: Bool, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws { + let result = try await downloadFile( + id: id, + storage: storage, + senderPublicKey: publicKey, + recipientPrivateKey: privateKey, + nonce: nonce, + saveEncrypted: saveEncrypted, + downloadProgress: downloadProgress + ) + + try filesStorage.cacheFile( + id: id, + fileExtension: fileExtension, + url: nil, + decodedData: result.decodedData, + encodedData: result.encodedData, + ownerId: ownerId, + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: fileType, + isPreview: isPreview + ) + } + + func shoudDownloadOriginal( + file: ChatFile, + fullMediaDownloadPolicy: DownloadPolicy, + havePartnerName: Bool + ) -> Bool { + let isMedia = file.fileType == .image || file.fileType == .video + let shouldDownloadOriginalFile: Bool + switch fullMediaDownloadPolicy { + case .nobody: + shouldDownloadOriginalFile = false + case .everybody: + shouldDownloadOriginalFile = !filesStorage.isCachedLocally(file.file.id) && isMedia + ? true + : false + case .contacts: + shouldDownloadOriginalFile = !filesStorage.isCachedLocally(file.file.id) && isMedia + ? havePartnerName + : false + } + + return shouldDownloadOriginalFile + } + + func shoudDownloadPreview( + file: ChatFile, + previewDownloadPolicy: DownloadPolicy, + havePartnerName: Bool + ) -> Bool { + let shouldDownloadPreviewFile: Bool + switch previewDownloadPolicy { + case .nobody: + shouldDownloadPreviewFile = false + case .everybody: + shouldDownloadPreviewFile = needsPreviewDownload(file: file) + ? true + : false + case .contacts: + shouldDownloadPreviewFile = needsPreviewDownload(file: file) + ? havePartnerName + : false + } + + return shouldDownloadPreviewFile + } + + func needsPreviewDownload(file: ChatFile) -> Bool { + if let previewId = file.file.preview?.id, + file.file.preview?.nonce != nil, + !$ignoreFilesIDsArray.wrappedValue.contains(previewId), + !filesStorage.isCachedLocally(previewId) { + return true + } + + return false + } + + func downloadFile( + id: String, + storage: String, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String, + saveEncrypted: Bool, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws -> (decodedData: Data, encodedData: Data) { + let encodedData = try await filesNetworkManager.downloadFile( + id, + type: storage, + downloadProgress: downloadProgress + ).get() + + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) else { + throw FileManagerError.cantDecryptFile + } + + return (decodedData, encodedData) + } +} + +private extension ChatFileService { + func sendUpdate( + for files: [String], + downloadStatus: DownloadStatus?, + uploading: Bool?, + progress: Int? = nil + ) { + files.forEach { id in + updateFileFields.send(.init( + id: id, + newId: nil, + fileNonce: nil, + preview: nil, + cached: nil, + downloadStatus: downloadStatus, + uploading: uploading, + progress: progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + )) + + if progress != nil { + $fileProgressValue.mutate { + $0[id] = progress + } + } + } + } + + func sendProgress(for fileId: String, progress: Int) { + guard $fileProgressValue.wrappedValue[fileId] != progress else { return } + + updateFileFields.send(.init( + id: fileId, + newId: nil, + fileNonce: nil, + preview: nil, + cached: nil, + downloadStatus: nil, + uploading: nil, + progress: progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + )) + + $fileProgressValue.mutate { + $0[fileId] = progress + } + } +} + +// MARK: Upload +private extension ChatFileService { + func sendFile( + text: String?, + chatroom: Chatroom?, + fileMessage: FileMessage?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws { + guard let partnerAddress = chatroom?.partner?.address, + let keyPair = accountService.keypair, + let ownerId = accountService.account?.address, + var fileMessage = fileMessage, + chatroom?.partner?.isDummy != true + else { return } + + let storageProtocol = NetworkFileProtocolType.ipfs + let files = fileMessage.files + var richFiles = createRichFiles(from: files) + + let messageLocally = createAdamantMessage( + with: richFiles, + text: text, + replyMessage: replyMessage, + storageProtocol: storageProtocol + ) + + cachePreviewFiles(files) + + let txId = try await sendMessageLocallyIfNeeded( + fileMessage: fileMessage, + partnerAddress: partnerAddress, + chatroom: chatroom, + messageLocally: messageLocally + ) + + fileMessage.txId = txId + + let needToLoadFiles = richFiles.filter { $0.nonce.isEmpty } + updateUploadingFilesIDs(with: needToLoadFiles.map { $0.id }, uploading: true) + + $uploadingFilesDictionary.mutate { + $0[txId] = fileMessage + } + + do { + try await processFilesUpload( + fileMessage: &fileMessage, + chatroom: chatroom, + keyPair: keyPair, + storageProtocol: storageProtocol, + ownerId: ownerId, + partnerAddress: partnerAddress, + saveEncrypted: saveEncrypted, + txId: txId, + richFiles: &richFiles, + messageLocally: messageLocally + ) + + let message = createAdamantMessage( + with: richFiles, + text: text, + replyMessage: replyMessage, + storageProtocol: storageProtocol + ) + + _ = try await chatsProvider.sendFileMessage( + message, + recipientId: partnerAddress, + transactionLocalyId: txId, + from: chatroom + ) + + $uploadingFilesDictionary.mutate { + $0[txId] = nil + } + } catch { + await handleUploadError( + for: needToLoadFiles, + txId: txId + ) + + throw error + } + } + + func createRichFiles(from files: [FileUpload]) -> [RichMessageFile.File] { + files.compactMap { + .init( + id: $0.serverFileID ?? $0.file.url.absoluteString, + size: $0.file.size, + nonce: $0.fileNonce ?? .empty, + name: $0.file.name, + extension: $0.file.extenstion, + mimeType: $0.file.mimeType, + preview: $0.preview ?? $0.file.previewUrl.map { + RichMessageFile.Preview( + id: $0.absoluteString, + nonce: .empty, + extension: .empty + ) + }, + resolution: $0.file.resolution, + duration: $0.file.duration + ) + } + } + + func createAdamantMessage( + with richFiles: [RichMessageFile.File], + text: String?, + replyMessage: MessageModel?, + storageProtocol: NetworkFileProtocolType + ) -> AdamantMessage { + guard let replyMessage = replyMessage else { + return .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: .init(id: storageProtocol.rawValue), + comment: text + ) + ) + } + + return .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: .init(id: storageProtocol.rawValue), + comment: text + ) + ) + ) + } + + func cachePreviewFiles(_ files: [FileUpload]) { + let needToCache = files.filter { !$0.isUploaded } + for url in needToCache.compactMap({ $0.file.previewUrl }) { + filesStorage.cacheTemporaryFile( + url: url, + isEncrypted: false, + fileType: .image, + isPreview: true + ) + } + } + + func sendMessageLocallyIfNeeded( + fileMessage: FileMessage, + partnerAddress: String, + chatroom: Chatroom?, + messageLocally: AdamantMessage + ) async throws -> String { + let txId: String + + if let transactionId = fileMessage.txId { + txId = transactionId + + try? await chatsProvider.setTxMessageStatus( + txId: txId, + status: .pending + ) + } else { + let txLocallyId = try await chatsProvider.sendFileMessageLocally( + messageLocally, + recipientId: partnerAddress, + from: chatroom + ) + txId = txLocallyId + } + + return txId + } + + func updateUploadingFilesIDs(with ids: [String], uploading: Bool) { + $uploadingFilesIDsArray.mutate { currentIDs in + if uploading { + currentIDs.append(contentsOf: ids) + } else { + ids.forEach { id in + currentIDs.removeAll { $0 == id } + } + } + } + + sendUpdate( + for: ids, + downloadStatus: nil, + uploading: uploading, + progress: uploading ? .zero : nil + ) + } + + func processFilesUpload( + fileMessage: inout FileMessage, + chatroom: Chatroom?, + keyPair: Keypair, + storageProtocol: NetworkFileProtocolType, + ownerId: String, + partnerAddress: String, + saveEncrypted: Bool, + txId: String, + richFiles: inout [RichMessageFile.File], + messageLocally: AdamantMessage + ) async throws { + let files = fileMessage.files + + for i in files.indices where !files[i].isUploaded { + let file = files[i].file + + let uploadProgress: ((Int) -> Void) = { [weak self, file] value in + self?.sendProgress( + for: file.url.absoluteString, + progress: value + ) + } + + let result = try await uploadFileToServer( + file: file, + recipientPublicKey: chatroom?.partner?.publicKey ?? .empty, + senderPrivateKey: keyPair.privateKey, + storageProtocol: storageProtocol, + progress: uploadProgress + ) + + sendProgress( + for: result.file.cid, + progress: 100 + ) + + try cacheUploadedFile( + fileResult: result.file, + previewResult: result.preview, + file: file, + ownerId: ownerId, + partnerAddress: partnerAddress, + saveEncrypted: saveEncrypted + ) + + await updateRichFile( + oldId: file.url.absoluteString, + fileResult: result.file, + previewResult: result.preview, + fileMessage: &fileMessage, + richFiles: &richFiles, + file: file, + txId: txId, + messageLocally: messageLocally + ) + } + } + + func cacheUploadedFile( + fileResult: UploadResult, + previewResult: UploadResult?, + file: FileResult, + ownerId: String, + partnerAddress: String, + saveEncrypted: Bool + ) throws { + try filesStorage.cacheFile( + id: fileResult.cid, + fileExtension: file.extenstion ?? .empty, + url: file.url, + decodedData: fileResult.decodedData, + encodedData: fileResult.encodedData, + ownerId: ownerId, + recipientId: partnerAddress, + saveEncrypted: saveEncrypted, + fileType: file.type, + isPreview: false + ) + + if let previewUrl = file.previewUrl, + let previewResult = previewResult { + try filesStorage.cacheFile( + id: previewResult.cid, + fileExtension: file.previewExtension ?? .empty, + url: previewUrl, + decodedData: previewResult.decodedData, + encodedData: previewResult.encodedData, + ownerId: ownerId, + recipientId: partnerAddress, + saveEncrypted: saveEncrypted, + fileType: .image, + isPreview: true + ) + } + } + + func updateRichFile( + oldId: String, + fileResult: UploadResult, + previewResult: UploadResult?, + fileMessage: inout FileMessage, + richFiles: inout [RichMessageFile.File], + file: FileResult, + txId: String, + messageLocally: AdamantMessage + ) async { + let cached = filesStorage.isCachedLocally(fileResult.cid) + + $uploadingFilesIDsArray.mutate { $0.removeAll { $0 == oldId } } + + updateFileFields.send(.init( + id: oldId, + newId: fileResult.cid, + fileNonce: fileResult.nonce, + preview: .some(filesStorage.getPreview(for: previewResult?.cid ?? .empty)), + cached: cached, + downloadStatus: nil, + uploading: false, + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + )) + + var previewDTO: RichMessageFile.Preview? + if let cid = previewResult?.cid, + let nonce = previewResult?.nonce { + previewDTO = .init( + id: cid, + nonce: nonce, + extension: file.previewExtension + ) + } + + if let index = richFiles.firstIndex(where: { $0.id == oldId }) { + richFiles[index].id = fileResult.cid + richFiles[index].nonce = fileResult.nonce + richFiles[index].preview = previewDTO + } + + if let index = fileMessage.files.firstIndex(where: { + $0.file.url.absoluteString == oldId + }) { + fileMessage.files[index].isUploaded = true + fileMessage.files[index].serverFileID = fileResult.cid + fileMessage.files[index].fileNonce = fileResult.nonce + fileMessage.files[index].preview = previewDTO + + $uploadingFilesDictionary.mutate { + $0[txId] = fileMessage + } + } + + guard case let .richMessage(payload) = messageLocally, + var richMessage = payload as? RichMessageFile + else { return } + + richMessage.files = richFiles + + try? await chatsProvider.updateTxMessageContent( + txId: txId, + richMessage: richMessage + ) + } + + func handleUploadError( + for richFiles: [RichMessageFile.File], + txId: String + ) async { + updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: false) + + try? await chatsProvider.setTxMessageStatus( + txId: txId, + status: .failed + ) + } + + func uploadFileToServer( + file: FileResult, + recipientPublicKey: String, + senderPrivateKey: String, + storageProtocol: NetworkFileProtocolType, + progress: @escaping ((Int) -> Void) + ) async throws -> (file: UploadResult, preview: UploadResult?) { + let totalProgress = Progress(totalUnitCount: 100) + var previewWeight: Int64 = .zero + var fileWeight: Int64 = 100 + + if file.previewUrl != nil { + previewWeight = 10 + fileWeight = 90 + } + + let previewProgress = Progress(totalUnitCount: previewWeight) + totalProgress.addChild(previewProgress, withPendingUnitCount: previewWeight) + + let fileProgress = Progress(totalUnitCount: fileWeight) + totalProgress.addChild(fileProgress, withPendingUnitCount: fileWeight) + + let result = try await uploadFile( + url: file.url, + recipientPublicKey: recipientPublicKey, + senderPrivateKey: senderPrivateKey, + storageProtocol: storageProtocol, + uploadProgress: { value in + fileProgress.completedUnitCount = Int64(value.fractionCompleted * Double(fileWeight)) + progress(Int(totalProgress.fractionCompleted * 100)) + } + ) + + var preview: UploadResult? + + if let url = file.previewUrl { + preview = try await uploadFile( + url: url, + recipientPublicKey: recipientPublicKey, + senderPrivateKey: senderPrivateKey, + storageProtocol: storageProtocol, + uploadProgress: { value in + previewProgress.completedUnitCount = Int64(value.fractionCompleted * Double(previewWeight)) + progress(Int(totalProgress.fractionCompleted * 100)) + } + ) + } + + return (result, preview) + } + + func uploadFile( + url: URL, + recipientPublicKey: String, + senderPrivateKey: String, + storageProtocol: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) + ) async throws -> UploadResult { + defer { + url.stopAccessingSecurityScopedResource() + } + _ = url.startAccessingSecurityScopedResource() + + let data = try Data(contentsOf: url) + + let encodedResult = adamantCore.encodeData( + data, + recipientPublicKey: recipientPublicKey, + privateKey: senderPrivateKey + ) + + guard let encodedData = encodedResult?.data, + let nonce = encodedResult?.nonce + else { + throw FileManagerError.cantEncryptFile + } + + let cid = try await filesNetworkManager.uploadFiles( + encodedData, + type: storageProtocol, + uploadProgress: uploadProgress + ).get() + + return (data, encodedData, nonce, cid) + } +} diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 22832f057..13fff642a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -10,6 +10,7 @@ import UIKit import MarkdownKit import MessageKit import CommonKit +import FilesStorageKit struct ChatMessageFactory { private let walletServiceCompose: WalletServiceCompose @@ -36,7 +37,9 @@ struct ChatMessageFactory { font: .adamantCodeDefault, textHighlightColor: .adamant.codeBlockText, textBackgroundColor: .adamant.codeBlock - ) + ), + MarkdownFileRaw(emoji: "📸", font: .adamantChatFileRawDefault), + MarkdownFileRaw(emoji: "📄", font: .adamantChatFileRawDefault) ] ) @@ -58,7 +61,9 @@ struct ChatMessageFactory { MarkdownAdvancedAdm( font: .adamantChatDefault, color: .adamant.active - ) + ), + MarkdownFileRaw(emoji: "📸", font: .adamantChatFileRawDefault), + MarkdownFileRaw(emoji: "📄", font: .adamantChatFileRawDefault) ] ) @@ -126,7 +131,8 @@ private extension ChatMessageFactory { ) case let transaction as RichMessageTransaction: if transaction.additionalType == .reply, - !transaction.isTransferReply() { + !transaction.isTransferReply(), + !transaction.isFileReply() { return makeReplyContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -134,6 +140,16 @@ private extension ChatMessageFactory { ) } + if transaction.additionalType == .file || + (transaction.additionalType == .reply && + transaction.isFileReply()) { + return makeFileContent( + transaction, + isFromCurrentSender: isFromCurrentSender, + backgroundColor: backgroundColor + ) + } + return makeContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -156,17 +172,7 @@ private extension ChatMessageFactory { backgroundColor: ChatMessageBackgroundColor ) -> ChatMessage.Content { transaction.message.map { - let attributedString = Self.markdownParser.parse($0) - - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 1.15 - mutableAttributedString.addAttribute( - NSAttributedString.Key.paragraphStyle, - value: paragraphStyle, - range: NSRange(location: 0, length: attributedString.length) - ) - + let text = makeAttributed($0) let reactions = transaction.reactions let address = transaction.isOutgoing @@ -180,7 +186,7 @@ private extension ChatMessageFactory { return .message(.init( value: .init( id: transaction.txId, - text: mutableAttributedString, + text: text, backgroundColor: backgroundColor, isFromCurrentSender: isFromCurrentSender, reactions: reactions, @@ -199,11 +205,12 @@ private extension ChatMessageFactory { backgroundColor: ChatMessageBackgroundColor ) -> ChatMessage.Content { guard let replyId = transaction.getRichValue(for: RichContentKeys.reply.replyToId), - let replyMessage = transaction.getRichValue(for: RichContentKeys.reply.replyMessage) + let replyMessageRaw = transaction.getRichValue(for: RichContentKeys.reply.replyMessage) else { return .default } + let replyMessage = makeAttributed(replyMessageRaw) let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." let decodedMessageMarkDown = Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set @@ -220,7 +227,7 @@ private extension ChatMessageFactory { value: .init( id: transaction.txId, replyId: replyId, - message: Self.markdownParser.parse(replyMessage), + message: replyMessage, messageReply: decodedMessageMarkDown, backgroundColor: backgroundColor, isFromCurrentSender: isFromCurrentSender, @@ -282,6 +289,120 @@ private extension ChatMessageFactory { ))) } + func makeFileContent( + _ transaction: RichMessageTransaction, + isFromCurrentSender: Bool, + backgroundColor: ChatMessageBackgroundColor + ) -> ChatMessage.Content { + let id = transaction.chatMessageId ?? "" + + let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] + + let decodedMessage = decodeMessage(transaction) + let storageData: [String: Any] = transaction.getRichValue(for: RichContentKeys.file.storage) ?? [:] + let storage = RichMessageFile.Storage(storageData).id + + let commentRaw = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty + let replyId = transaction.getRichValue(for: RichContentKeys.reply.replyToId) ?? .empty + let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set + let comment = makeAttributed(commentRaw) + + let address = transaction.isOutgoing + ? transaction.senderAddress + : transaction.recipientAddress + + let opponentAddress = transaction.isOutgoing + ? transaction.recipientAddress + : transaction.senderAddress + + let chatFiles = makeChatFiles( + from: files, + isFromCurrentSender: isFromCurrentSender, + storage: storage + ) + + let isMediaFilesOnly = chatFiles.allSatisfy { + $0.fileType == .image || $0.fileType == .video + } + + let fileModel = ChatMediaContentView.FileModel( + messageId: id, + files: chatFiles, + isMediaFilesOnly: isMediaFilesOnly, + isFromCurrentSender: isFromCurrentSender, + txStatus: transaction.statusEnum + ) + + return .file(.init(value: .init( + id: id, + isFromCurrentSender: isFromCurrentSender, + reactions: reactions, + content: .init( + id: id, + fileModel: fileModel, + isHidden: false, + isFromCurrentSender: isFromCurrentSender, + isReply: transaction.isFileReply(), + replyMessage: decodedMessage, + replyId: replyId, + comment: comment, + backgroundColor: backgroundColor + ), + address: address, + opponentAddress: opponentAddress, + txStatus: transaction.statusEnum, + status: .failed + ))) + } + + func makeAttributed(_ text: String) -> NSMutableAttributedString { + let attributedString = Self.markdownParser.parse(text) + + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + + mutableAttributedString.addAttribute( + NSAttributedString.Key.paragraphStyle, + value: paragraphStyle, + range: NSRange(location: .zero, length: attributedString.length) + ) + + return mutableAttributedString + } + + func decodeMessage(_ transaction: RichMessageTransaction) -> NSMutableAttributedString { + let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." + return Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() + } + + func makeChatFiles( + from files: [[String: Any]], + isFromCurrentSender: Bool, + storage: String + ) -> [ChatFile] { + return files.map { + let file = RichMessageFile.File($0) + let fileType = FileType(raw: file.extension ?? .empty) ?? .other + + return ChatFile( + file: file, + previewImage: nil, + downloadStatus: .default, + isUploading: false, + isCached: false, + storage: storage, + nonce: file.nonce, + isFromCurrentSender: isFromCurrentSender, + fileType: fileType, + progress: nil, + isPreviewDownloadAllowed: false, + isFullMediaDownloadAllowed: false + ) + } + } + func makeContent( _ transaction: TransferTransaction, isFromCurrentSender: Bool, @@ -376,7 +497,7 @@ private extension ChatMessageFactory { func makePendingMessageString() -> NSAttributedString { let attachment = NSTextAttachment() attachment.image = .asset(named: "status_pending") - attachment.bounds = CGRect(x: .zero, y: -1, width: 7, height: 7) + attachment.bounds = CGRect(x: .zero, y: -1, width: 10, height: 10) return NSAttributedString(attachment: attachment) } @@ -430,3 +551,5 @@ private extension ChatSender { ) } } + +private let lineSpacing: CGFloat = 1.15 diff --git a/Adamant/Modules/Chat/ViewModel/ChatPreservationProtocol.swift b/Adamant/Modules/Chat/ViewModel/ChatPreservationProtocol.swift index 763f11546..97b3179cf 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatPreservationProtocol.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatPreservationProtocol.swift @@ -6,9 +6,16 @@ // Copyright © 2023 Adamant. All rights reserved. // +import CommonKit + protocol ChatPreservationProtocol: AnyObject { func preserveMessage(_ message: String, forAddress address: String) func getPreservedMessageFor(address: String, thenRemoveIt: Bool) -> String? func setReplyMessage(_ message: MessageModel?, forAddress address: String) func getReplyMessage(address: String, thenRemoveIt: Bool) -> MessageModel? + func preserveFiles(_ files: [FileResult]?, forAddress address: String) + func getPreservedFiles( + for address: String, + thenRemoveIt: Bool + ) -> [FileResult]? } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 4f81b7fdb..edc8eae10 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -13,6 +13,8 @@ import UIKit import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker +import FilesPickerKit +import FilesStorageKit @MainActor final class ChatViewModel: NSObject { @@ -32,7 +34,13 @@ final class ChatViewModel: NSObject { private let avatarService: AvatarService private let emojiService: EmojiService private let chatPreservation: ChatPreservationProtocol - + private let filesStorage: FilesStorageProtocol + private let chatFileService: ChatFileProtocol + private let filesStorageProprieties: FilesStorageProprietiesProtocol + private let nodesStorage: NodesStorageProtocol + private let reachabilityMonitor: ReachabilityMonitor + private let filesPicker: FilesPickerProtocol + let chatMessagesListViewModel: ChatMessagesListViewModel // MARK: Properties @@ -62,7 +70,8 @@ final class ChatViewModel: NSObject { private let partnerImageSize: CGFloat = 25 private let maxMessageLenght: Int = 10000 private var previousArg: ChatContextMenuArguments? - + private var havePartnerName: Bool = false + let minIndexForStartLoadNewMessages = 4 let minOffsetForStartLoadNewMessages: CGFloat = 100 var tempOffsets: [String] = [] @@ -79,6 +88,12 @@ final class ChatViewModel: NSObject { let layoutIfNeeded = ObservableSender() let presentKeyboard = ObservableSender() let didTapSelectText = ObservableSender() + let presentFilePicker = ObservableSender() + let presentSendTokensVC = ObservableSender() + let presentMediaPickerVC = ObservableSender() + let presentDocumentPickerVC = ObservableSender() + let presentDocumentViewerVC = ObservableSender<([FileResult], Int)>() + let presentDropView = ObservableSender() @ObservableValue private(set) var isHeaderLoading = false @ObservableValue private(set) var fullscreenLoading = false @@ -93,6 +108,7 @@ final class ChatViewModel: NSObject { @ObservableValue var inputText = "" @ObservableValue var replyMessage: MessageModel? @ObservableValue var scrollToMessage: (toId: String?, fromId: String?) + @ObservableValue var filesPicked: [FileResult]? var startPosition: ChatStartPosition? { if let messageIdToShow = messageIdToShow { @@ -122,6 +138,11 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } + lazy private(set) var mediaPickerDelegate = MediaPickerService(helper: filesPicker) + lazy private(set) var documentPickerDelegate = DocumentPickerService(helper: filesPicker) + lazy private(set) var documentViewerService = DocumentInteractionService() + lazy private(set) var dropInteractionService = DropInteractionService(helper: filesPicker) + init( chatsProvider: ChatsProvider, markdownParser: MarkdownParser, @@ -137,7 +158,13 @@ final class ChatViewModel: NSObject { avatarService: AvatarService, chatMessagesListViewModel: ChatMessagesListViewModel, emojiService: EmojiService, - chatPreservation: ChatPreservationProtocol + chatPreservation: ChatPreservationProtocol, + filesStorage: FilesStorageProtocol, + chatFileService: ChatFileProtocol, + filesStorageProprieties: FilesStorageProprietiesProtocol, + nodesStorage: NodesStorageProtocol, + reachabilityMonitor: ReachabilityMonitor, + filesPicker: FilesPickerProtocol ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -154,6 +181,13 @@ final class ChatViewModel: NSObject { self.chatMessagesListViewModel = chatMessagesListViewModel self.emojiService = emojiService self.chatPreservation = chatPreservation + self.filesStorage = filesStorage + self.chatFileService = chatFileService + self.filesStorageProprieties = filesStorageProprieties + self.nodesStorage = nodesStorage + self.reachabilityMonitor = reachabilityMonitor + self.filesPicker = filesPicker + super.init() setupObservers() } @@ -187,11 +221,20 @@ final class ChatViewModel: NSObject { fullscreenLoading = cachedMessages == nil replyMessage = chatPreservation.getReplyMessage(address: partnerAddress, thenRemoveIt: true) + + filesPicked = chatPreservation.getPreservedFiles( + for: partnerAddress, + thenRemoveIt: true + ) } } func presentKeyboardOnStartIfNeeded() { - guard !inputText.isEmpty || replyMessage != nil else { return } + guard !inputText.isEmpty + || replyMessage != nil + || (filesPicked?.count ?? .zero) > .zero + else { return } + presentKeyboard.send() } @@ -240,6 +283,33 @@ final class ChatViewModel: NSObject { return } + guard reachabilityMonitor.connection else { + dialog.send(.alert(.adamant.sharedErrors.networkError)) + return + } + + guard nodesStorage.haveActiveNode(in: .adm) else { + dialog.send(.alert(ApiServiceError.noEndpointsAvailable( + coin: NodeGroup.adm.name + ).localizedDescription)) + return + } + + if !(filesPicked?.isEmpty ?? true) { + Task { + do { + try await sendFiles(with: text) + } catch { + await handleMessageSendingError( + error: error, + sentText: text, + filesPicked: filesPicked + ) + } + } + return + } + Task { let message: AdamantMessage @@ -288,6 +358,11 @@ final class ChatViewModel: NSObject { chatPreservation.preserveMessage(message, forAddress: partnerAddress) } + func preserveFiles() { + guard let partnerAddress = chatroom?.partner?.address else { return } + chatPreservation.preserveFiles(filesPicked, forAddress: partnerAddress) + } + func preserveReplayMessage() { guard let partnerAddress = chatroom?.partner?.address else { return } chatPreservation.setReplyMessage(replyMessage, forAddress: partnerAddress) @@ -320,6 +395,7 @@ final class ChatViewModel: NSObject { }.stored(in: tasksStorage) partnerName = newName + havePartnerName = !newName.isEmpty } func saveChatOffset(_ offset: CGFloat?) { @@ -405,6 +481,19 @@ final class ChatViewModel: NSObject { guard let transaction = chatTransactions.first(where: { $0.chatMessageId == id }) else { return } + let message = messages.first(where: { $0.messageId == id }) + + if case let .file(model) = message?.content { + try? await chatFileService.resendMessage( + with: id, + text: model.value.content.comment.string, + chatroom: chatroom, + replyMessage: nil, + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) + return + } + do { try await chatsProvider.retrySendMessage(transaction) } catch { @@ -549,6 +638,10 @@ final class ChatViewModel: NSObject { replyMessage = nil } + func clearPickedFiles() { + filesPicked = nil + } + func presentMenu(arg: ChatContextMenuArguments) { let didSelectEmojiAction: ChatDialogManager.DidSelectEmojiAction = { [weak self] emoji, messageId in self?.dialog.send(.dismissMenu) @@ -610,8 +703,250 @@ final class ChatViewModel: NSObject { return false } + guard nodesStorage.haveActiveNode(in: .adm) else { + dialog.send(.alert(ApiServiceError.noEndpointsAvailable( + coin: NodeGroup.adm.name + ).localizedDescription)) + return false + } + return true } + + func openFile(messageId: String, file: ChatFile) { + let tx = chatTransactions.first(where: { $0.txId == messageId }) + let message = messages.first(where: { $0.messageId == messageId }) + + guard let tx = tx, + tx.statusEnum != .failed + else { + dialog.send(.failedMessageAlert(id: messageId, sender: nil)) + return + } + + guard !chatFileService.downloadingFiles.keys.contains(file.file.id), + !chatFileService.uploadingFiles.contains(file.file.id), + case let(.file(fileModel)) = message?.content + else { return } + + let chatFiles = fileModel.value.content.fileModel.files + + let isPreviewAutoDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadPreviewPolicy(), + havePartnerName: havePartnerName + ) + + if !isPreviewAutoDownloadAllowed, + file.previewImage == nil, + file.file.preview != nil, + !chatFileService.isDownloadPreviewLimitReached(for: file.file.id) { + forceDownloadAllFiles(messageId: messageId, files: chatFiles) + return + } + + guard !file.isCached, + !filesStorage.isCachedLocally(file.file.id) + else { + self.presentFileInFullScreen(id: file.file.id, chatFiles: chatFiles) + return + } + + guard tx.statusEnum == .delivered else { return } + + downloadFile( + file: file, + previewDownloadAllowed: true, + fullMediaDownloadAllowed: true + ) + } + + func downloadContentIfNeeded( + messageId: String, + files: [ChatFile] + ) { + let tx = chatTransactions.first(where: { $0.txId == messageId }) + + guard tx?.statusEnum == .delivered || tx?.statusEnum == nil else { return } + + let chatFiles = files.filter { + $0.fileType == .image || $0.fileType == .video + } + + chatFiles.forEach { file in + Task { + await chatFileService.autoDownload( + file: file, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), + fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) + } + } + } + + func forceDownloadAllFiles(messageId: String, files: [ChatFile]) { + let isPreviewDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadPreviewPolicy(), + havePartnerName: havePartnerName + ) + + let isFullMediaDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + havePartnerName: havePartnerName + ) + + let needToDownload: [ChatFile] + + let shouldDownloadFile: (ChatFile) -> Bool = { file in + !file.isCached || (file.fileType.isMedia && file.previewImage == nil && isPreviewDownloadAllowed) + } + + let previewFiles = files.filter { file in + (file.fileType == .image || file.fileType == .video) && file.previewImage == nil + } + + let notCachedFiles = files.filter { !$0.isCached } + + let downloadPreview: Bool + let downloadFullMedia: Bool + + switch (isPreviewDownloadAllowed, isFullMediaDownloadAllowed) { + case (true, true): + needToDownload = files.filter(shouldDownloadFile) + downloadPreview = true + downloadFullMedia = true + case (true, false): + needToDownload = previewFiles.isEmpty + ? notCachedFiles + : previewFiles + + downloadPreview = previewFiles.isEmpty + ? false + : true + + downloadFullMedia = previewFiles.isEmpty + ? true + : false + case (false, true): + needToDownload = notCachedFiles.isEmpty + ? previewFiles + : notCachedFiles + + downloadPreview = notCachedFiles.isEmpty + ? true + : false + + downloadFullMedia = notCachedFiles.isEmpty + ? false + : true + case (false, false): + needToDownload = previewFiles.isEmpty + ? notCachedFiles + : previewFiles + + downloadPreview = previewFiles.isEmpty + ? false + : true + + downloadFullMedia = previewFiles.isEmpty + ? true + : false + } + + needToDownload.forEach { file in + downloadFile( + file: file, + previewDownloadAllowed: downloadPreview, + fullMediaDownloadAllowed: downloadFullMedia + ) + } + } + + func downloadFile( + file: ChatFile, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool + ) { + Task { [weak self] in + try? await self?.chatFileService.downloadFile( + file: file, + chatroom: self?.chatroom, + saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true, + previewDownloadAllowed: previewDownloadAllowed, + fullMediaDownloadAllowed: fullMediaDownloadAllowed + ) + } + } + + func presentActionMenu() { + dialog.send(.actionMenu) + } + + func didSelectMenuAction(_ action: ShareType) { + if case(.sendTokens) = action { + presentSendTokensVC.send() + } + + if case(.uploadMedia) = action { + presentMediaPickerVC.send() + } + + if case(.uploadFile) = action { + presentDocumentPickerVC.send() + } + } + + @MainActor + func processFileResult(_ result: Result<[FileResult], Error>) { + switch result { + case .success(let files): + var oldFiles = filesPicked ?? [] + + files.forEach { file in + if !oldFiles.contains(where: { $0.assetId == file.assetId }) { + oldFiles.append(file) + } + } + + if oldFiles.count > FilesConstants.maxFilesCount { + let numberOfExtraElements = oldFiles.count - FilesConstants.maxFilesCount + let extraFilesToRemove = oldFiles.prefix(numberOfExtraElements) + for file in extraFilesToRemove { + let urls = [file.url] + (file.previewUrl.map { [$0] } ?? []) + filesStorage.removeTempFiles(at: urls) + } + + oldFiles.removeFirst(numberOfExtraElements) + } + + filesPicked = oldFiles + case .failure(let error): + dialog.send(.alert(error.localizedDescription)) + } + } + + func presentDialog(progress: Bool) { + dialog.send(.progress(progress)) + } + + func dropSessionUpdated(_ value: Bool) { + presentDropView.send(value) + } + + func updatePreviewFor(indexes: [IndexPath]) { + indexes.forEach { index in + guard let message = messages[safe: index.section], + case let .file(model) = message.content + else { return } + + downloadContentIfNeeded( + messageId: message.messageId, + files: model.value.content.fileModel.files + ) + } + } } extension ChatViewModel { @@ -658,6 +993,26 @@ extension ChatViewModel { func updatePartnerName() { partnerName = chatroom?.getName(addressBookService: addressBookService) + } + + func updateFiles(_ data: [FileResult]?) { + if (data?.count ?? .zero) == .zero { + let previewUrls = filesPicked?.compactMap { $0.previewUrl } ?? [] + let fileUrls = filesPicked?.compactMap { $0.url } ?? [] + + filesStorage.removeTempFiles(at: previewUrls + fileUrls) + } + + filesPicked = data + } + + func handlePastedImage(_ image: UIImage) { + do { + let file = try filesPicker.getFileResult(for: image) + processFileResult(.success([file])) + } catch { + processFileResult(.failure(error)) + } } } @@ -668,12 +1023,60 @@ extension ChatViewModel: NSFetchedResultsControllerDelegate { } private extension ChatViewModel { + func sendFiles(with text: String) async throws { + guard nodesStorage.haveActiveNode(in: .ipfs) else { + dialog.send(.alert(ApiServiceError.noEndpointsAvailable( + coin: NodeGroup.ipfs.name + ).localizedDescription)) + return + } + + let replyMessage = replyMessage + let filesPicked = filesPicked + + self.replyMessage = nil + self.filesPicked = nil + + try await chatFileService.sendFile( + text: text, + chatroom: chatroom, + filesPicked: filesPicked, + replyMessage: replyMessage, + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) + } + func setupObservers() { $inputText .removeDuplicates() .sink { [weak self] _ in self?.inputTextUpdated() } .store(in: &subscriptions) + chatFileService.updateFileFields + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + guard let self = self else { return } + + let fileProprieties = FileUpdateProperties( + id: data.id, + newId: data.newId, + fileNonce: data.fileNonce, + preview: data.preview, + cached: data.cached, + downloadStatus: data.downloadStatus, + uploading: data.uploading, + progress: data.progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + ) + + self.updateFileFields( + &self.messages, + fileProprieties: fileProprieties + ) + } + .store(in: &subscriptions) + NotificationCenter.default .publisher(for: .AdamantVisibleWalletsService.visibleWallets) .receive(on: RunLoop.main) @@ -688,6 +1091,50 @@ private extension ChatViewModel { } .store(in: &subscriptions) }.stored(in: tasksStorage) + + dropInteractionService.onPreparedDataCallback = { [weak self] result in + Task { @MainActor in + self?.dropSessionUpdated(false) + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + dropInteractionService.onPreparingDataCallback = { [weak self] in + Task { @MainActor in + self?.presentDialog(progress: true) + } + } + + dropInteractionService.onSessionCallback = { [weak self] fileOnScreen in + self?.dropSessionUpdated(fileOnScreen) + } + + mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in + Task { @MainActor in + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + mediaPickerDelegate.onPreparingDataCallback = { [weak self] in + Task { @MainActor in + self?.presentDialog(progress: true) + } + } + + documentPickerDelegate.onPreparedDataCallback = { [weak self] result in + Task { @MainActor in + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + documentPickerDelegate.onPreparingDataCallback = { [weak self] in + Task { @MainActor in + self?.presentDialog(progress: true) + } + } } func loadMessages(address: String, offset: Int) async { @@ -729,13 +1176,15 @@ private extension ChatViewModel { defer { completion() } var expirationTimestamp: TimeInterval? - let messages = await chatMessagesListFactory.makeMessages( + var messages = await chatMessagesListFactory.makeMessages( transactions: chatTransactions, sender: sender, isNeedToLoadMoreMessages: isNeedToLoadMoreMessages, expirationTimestamp: &expirationTimestamp ) + postProcess(messages: &messages) + await setupNewMessages( newMessages: messages, resetLoadingProperty: resetLoadingProperty, @@ -750,6 +1199,65 @@ private extension ChatViewModel { } } + @MainActor + func postProcess(messages: inout[ChatMessage]) { + let indexes = messages.indices.filter { + messages[$0].getFiles().count > .zero + } + + indexes.forEach { index in + guard case let .file(model) = messages[index].content else { return } + + model.value.content.fileModel.files.forEach { file in + setupFileFields(file, messages: &messages, index: index) + } + } + } + + func setupFileFields( + _ file: ChatFile, + messages: inout[ChatMessage], + index: Int + ) { + let fileId = file.file.id + + let previewImage = (file.file.preview?.id).flatMap { + !$0.isEmpty + ? filesStorage.getPreview(for: $0) + : nil + } + + let progress = chatFileService.filesLoadingProgress[fileId] + let downloadStatus = chatFileService.downloadingFiles[fileId] ?? .default + let cached = filesStorage.isCachedLocally(fileId) + let isUploading = chatFileService.uploadingFiles.contains(fileId) + + let isPreviewDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadPreviewPolicy(), + havePartnerName: havePartnerName + ) + + let isFullMediaDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + havePartnerName: havePartnerName + ) + + let fileProprieties = FileUpdateProperties( + id: file.file.id, + newId: nil, + fileNonce: nil, + preview: .some(previewImage), + cached: cached, + downloadStatus: downloadStatus, + uploading: isUploading, + progress: progress, + isPreviewDownloadAllowed: isPreviewDownloadAllowed, + isFullMediaDownloadAllowed: isFullMediaDownloadAllowed + ) + + updateFileMessageFields(for: &messages[index], fileProprieties: fileProprieties) + } + func setupNewMessages( newMessages: [ChatMessage], resetLoadingProperty: Bool, @@ -797,12 +1305,17 @@ private extension ChatViewModel { } } - func handleMessageSendingError(error: Error, sentText: String) async { + func handleMessageSendingError( + error: Error, + sentText: String, + filesPicked: [FileResult]? = nil + ) async { switch error as? ChatsProviderError { case .messageNotValid: inputText = sentText case .notEnoughMoneyToSend: inputText = sentText + self.filesPicked = filesPicked guard await transfersProvider.hasTransactions else { dialog.send(.freeTokenAlert) return @@ -810,8 +1323,6 @@ private extension ChatViewModel { case .accountNotFound, .accountNotInitiated, .dependencyError, .internalError, .networkError, .notLogged, .requestCancelled, .serverError, .transactionNotFound, .invalidTransactionStatus, .none: break } - - dialog.send(.richError(error)) } func inputTextUpdated() { @@ -834,6 +1345,7 @@ private extension ChatViewModel { } partnerName = chatroom?.getName(addressBookService: addressBookService) + havePartnerName = chatroom?.hasPartnerName(addressBookService: addressBookService) ?? false guard let avatarName = chatroom?.partner?.avatar, let avatar = UIImage.asset(named: avatarName) @@ -936,12 +1448,78 @@ private extension ChatViewModel { } } + // TODO: Post process func updateHiddenMessage(_ messages: inout [ChatMessage]) { messages.indices.forEach { messages[$0].isHidden = messages[$0].id == hiddenMessageID } } + func updateFileFields( + _ messages: inout [ChatMessage], + fileProprieties: FileUpdateProperties + ) { + let indexes = messages.indices.filter { + messages[$0].getFiles().contains { $0.file.id == fileProprieties.id } + } + + guard !indexes.isEmpty else { + return + } + + indexes.forEach { index in + updateFileMessageFields(for: &messages[index], fileProprieties: fileProprieties) + } + } + + func updateFileMessageFields( + for message: inout ChatMessage, + fileProprieties: FileUpdateProperties + ) { + message.updateFileFields( + id: fileProprieties.id + ) { file in + fileProprieties.newId.map { file.file.id = $0 } + fileProprieties.fileNonce.map { file.file.nonce = $0 } + fileProprieties.preview.map { file.previewImage = $0 } + fileProprieties.cached.map { file.isCached = $0 } + fileProprieties.uploading.map { file.isUploading = $0 } + fileProprieties.downloadStatus.map { file.downloadStatus = $0 } + fileProprieties.progress.map { file.progress = $0 } + fileProprieties.isPreviewDownloadAllowed.map { file.isPreviewDownloadAllowed = $0 } + fileProprieties.isFullMediaDownloadAllowed.map { file.isFullMediaDownloadAllowed = $0 } + } mutateModel: { model in + model.status = getStatus(from: model) + } + } + + func getStatus(from model: ChatMediaContainerView.Model) -> FileMessageStatus { + if model.txStatus == .failed { + return .failed + } + + if model.content.fileModel.files.first(where: { $0.isBusy }) != nil { + return .busy + } + + if model.content.fileModel.files.contains(where: { + !$0.isCached || + ($0.isCached + && $0.file.preview != nil + && $0.previewImage == nil + && ($0.fileType == .image || $0.fileType == .video)) + }) { + let failed = model.content.fileModel.files.contains(where: { + guard let progress = $0.progress else { return false } + return progress < 100 + }) + + return .needToDownload(failed: failed) + } + + return .success + } + func isNewReaction(old: [ChatTransaction], new: [ChatTransaction]) -> Bool { guard let processedDate = old.getMostRecentElementDate(), @@ -950,6 +1528,57 @@ private extension ChatViewModel { return newLastReactionDate > processedDate } + + func presentFileInFullScreen(id: String, chatFiles: [ChatFile]) { + dialog.send(.progress(true)) + + let files: [FileResult] = chatFiles.compactMap { file in + guard file.isCached, + !file.isBusy, + let fileDTO = try? filesStorage.getFile(with: file.file.id).get() + else { + return nil + } + + let data = try? chatFileService.getDecodedData( + file: fileDTO, + nonce: file.file.nonce, + chatroom: chatroom + ) + + return FileResult.init( + assetId: file.file.id, + url: fileDTO.url, + type: file.fileType, + preview: nil, + previewUrl: nil, + previewExtension: nil, + size: file.file.size, + name: file.file.name, + extenstion: file.file.extension, + resolution: nil, + data: data + ) + } + + dialog.send(.progress(false)) + let index = files.firstIndex(where: { $0.assetId == id }) ?? .zero + presentDocumentViewerVC.send((files, index)) + } + + func isDownloadAllowed( + policy: DownloadPolicy, + havePartnerName: Bool + ) -> Bool { + switch policy { + case .everybody: + return true + case .nobody: + return false + case .contacts: + return havePartnerName + } + } } private extension ChatMessage { @@ -962,6 +1591,8 @@ private extension ChatMessage { return model.value.isHidden case let .transaction(model): return model.value.content.isHidden + case let .file(model): + return model.value.content.isHidden } } @@ -979,9 +1610,42 @@ private extension ChatMessage { var model = model.value model.content.isHidden = newValue content = .transaction(.init(value: model)) + case let .file(model): + var model = model.value + model.content.isHidden = newValue + content = .file(.init(value: model)) } } } + + func getFiles() -> [ChatFile] { + guard case let .file(model) = content else { return [] } + return model.value.content.fileModel.files + } + + mutating func updateFileFields( + id: String, + mutateFile: (inout ChatFile) -> Void, + mutateModel: (inout ChatMediaContainerView.Model) -> Void + ) { + guard case let .file(fileModel) = content else { return } + var model = fileModel.value + + guard let index = model.content.fileModel.files.firstIndex( + where: { $0.file.id == id } + ) else { return } + + let previousValue = model + + mutateFile(&model.content.fileModel.files[index]) + mutateModel(&model) + + guard model != previousValue else { + return + } + + content = .file(.init(value: model)) + } } private extension Sequence where Element == ChatTransaction { diff --git a/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift b/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift index f65484731..f572003a8 100644 --- a/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift +++ b/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift @@ -24,7 +24,7 @@ enum ChatDialog { case dummy(String) case url(URL) case progress(Bool) - case failedMessageAlert(id: String, sender: UIAlertController.SourceView) + case failedMessageAlert(id: String, sender: UIAlertController.SourceView?) case presentMenu( presentReactions: Bool, arg: ChatContextMenuArguments, @@ -35,4 +35,5 @@ enum ChatDialog { ) case dismissMenu case renameAlert + case actionMenu } diff --git a/Adamant/Modules/Chat/ViewModel/Models/ChatMessage.swift b/Adamant/Modules/Chat/ViewModel/Models/ChatMessage.swift index d2422e146..115e9065f 100644 --- a/Adamant/Modules/Chat/ViewModel/Models/ChatMessage.swift +++ b/Adamant/Modules/Chat/ViewModel/Models/ChatMessage.swift @@ -51,6 +51,7 @@ extension ChatMessage { case message(EqualWrapper) case transaction(EqualWrapper) case reply(EqualWrapper) + case file(EqualWrapper) static let `default` = Self.message(.init(value: .default)) } @@ -71,6 +72,8 @@ extension ChatMessage: MessageType { ? model.value.message : model.value.messageReply return .attributedText(message) + case let .file(model): + return .custom(model) } } } diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 62c14079f..27039571e 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -102,7 +102,9 @@ final class ChatListViewController: KeyboardObservingViewController { font: .adamantCodeDefault, textHighlightColor: .adamant.codeBlockText, textBackgroundColor: .adamant.codeBlock - ) + ), + MarkdownFileRaw(emoji: "📸", font: .adamantChatFileRawDefault), + MarkdownFileRaw(emoji: "📄", font: .adamantChatFileRawDefault) ] ) @@ -133,7 +135,7 @@ final class ChatListViewController: KeyboardObservingViewController { private var loadNewChatTask: Task<(), Never>? private var subscriptions = Set() - //MARK: Init + // MARK: Init init( accountService: AccountService, @@ -300,6 +302,30 @@ final class ChatListViewController: KeyboardObservingViewController { self?.updateUITitles() } .store(in: &subscriptions) + + NotificationCenter.default + .publisher(for: .Storage.storageClear) + .receive(on: OperationQueue.main) + .sink { [weak self] _ in + self?.closeDetailVC() + } + .store(in: &subscriptions) + + NotificationCenter.default + .publisher(for: .Storage.storageProprietiesUpdated) + .receive(on: OperationQueue.main) + .sink { [weak self] _ in + self?.closeDetailVC() + } + .store(in: &subscriptions) + } + + private func closeDetailVC() { + guard let splitVC = tabBarController?.viewControllers?.first as? UISplitViewController, + !splitVC.isCollapsed + else { return } + + splitVC.showDetailViewController(WelcomeViewController(), sender: nil) } private func updateUITitles() { @@ -923,40 +949,8 @@ extension ChatListViewController { if richMessage.additionalType == .reply, let content = richMessage.richContent, - var text = content[RichContentKeys.reply.replyMessage] as? String { - text = MessageProcessHelper.process(text) - - let prefix = richMessage.isOutgoing - ? "\(String.adamant.chatList.sentMessagePrefix)" - : "" - - let replyImageAttachment = NSTextAttachment() - - replyImageAttachment.image = UIImage( - systemName: "arrowshape.turn.up.left" - )?.withTintColor(.adamant.primary) - - replyImageAttachment.bounds = CGRect( - x: .zero, - y: -3, - width: 23, - height: 20 - ) - - let extraSpace = richMessage.isOutgoing ? " " : "" - let imageString = NSAttributedString(attachment: replyImageAttachment) - - let markDownText = markdownParser.parse("\(extraSpace)\(text)").resolveLinkColor() - - var fullString = NSMutableAttributedString(string: prefix) - if richMessage.isOutgoing { - fullString.append(imageString) - } - fullString.append(markDownText) - - fullString = MessageProcessHelper.process(attributedText: fullString) - - return fullString + let text = content[RichContentKeys.reply.replyMessage] as? String { + return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: text) } if richMessage.additionalType == .reaction, @@ -973,6 +967,26 @@ extension ChatListViewController { return text } + if richMessage.additionalType == .reply, + let content = richMessage.richContent, + richMessage.isFileReply() { + let text = FilePresentationHelper.getFilePresentationText(content) + return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: text) + } + + if richMessage.additionalType == .file, + let content = richMessage.richContent { + let prefix = richMessage.isOutgoing + ? "\(String.adamant.chatList.sentMessagePrefix)" + : "" + + let fileText = FilePresentationHelper.getFilePresentationText(content) + + let attributesText = markdownParser.parse(prefix + fileText).resolveLinkColor() + + return attributesText + } + if let serialized = richMessage.serializedMessage() { return NSAttributedString(string: serialized) } @@ -994,6 +1008,38 @@ extension ChatListViewController { return nil } } + + private func getRawReplyPresentation(isOutgoing: Bool, text: String) -> NSMutableAttributedString { + let prefix = isOutgoing + ? "\(String.adamant.chatList.sentMessagePrefix)" + : "" + + let replyImageAttachment = NSTextAttachment() + + replyImageAttachment.image = UIImage( + systemName: "arrowshape.turn.up.left" + )?.withTintColor(.adamant.primary) + + replyImageAttachment.bounds = CGRect( + x: .zero, + y: -3, + width: 23, + height: 20 + ) + + let extraSpace = isOutgoing ? " " : "" + let imageString = NSAttributedString(attachment: replyImageAttachment) + + let markDownText = markdownParser.parse("\(extraSpace)\(text)").resolveLinkColor() + + let fullString = NSMutableAttributedString(string: prefix) + if isOutgoing { + fullString.append(imageString) + } + fullString.append(markDownText) + + return MessageProcessHelper.process(attributedText: fullString) + } } // MARK: - Swipe actions diff --git a/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift b/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift index 081c56256..e760451ba 100644 --- a/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift +++ b/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift @@ -54,7 +54,8 @@ private struct CoinsNodesListAssembly: Assembly { klyService: $0.resolve(KlyServiceApiService.self)!, doge: $0.resolve(DogeApiService.self)!, dash: $0.resolve(DashApiService.self)!, - adm: $0.resolve(ApiService.self)! + adm: $0.resolve(ApiService.self)!, + ipfs: $0.resolve(IPFSApiService.self)! ) ) }.inObjectScope(.weak) diff --git a/Adamant/Modules/CoinsNodesList/ViewModel/CoinsNodesListViewModel+ApiServices.swift b/Adamant/Modules/CoinsNodesList/ViewModel/CoinsNodesListViewModel+ApiServices.swift index df6da88d3..a4c8ca2aa 100644 --- a/Adamant/Modules/CoinsNodesList/ViewModel/CoinsNodesListViewModel+ApiServices.swift +++ b/Adamant/Modules/CoinsNodesList/ViewModel/CoinsNodesListViewModel+ApiServices.swift @@ -17,6 +17,7 @@ extension CoinsNodesListViewModel { let doge: WalletApiService let dash: WalletApiService let adm: WalletApiService + let ipfs: WalletApiService } } @@ -37,6 +38,8 @@ extension CoinsNodesListViewModel.ApiServices { return dash case .adm: return adm + case .ipfs: + return ipfs } } } diff --git a/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift b/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift index 794ca2a6d..a2b5af184 100644 --- a/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift +++ b/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift @@ -27,6 +27,7 @@ struct AdamantScreensFactory: ScreensFactory { private let partnerQRFactory: PartnerQRFactory private let coinsNodesListFactory: CoinsNodesListFactory private let chatSelectTextFactory: ChatSelectTextViewFactory + private let storageUsageFactory: StorageUsageFactory init(assembler: Assembler) { admWalletFactory = .init(assembler: assembler) @@ -44,6 +45,7 @@ struct AdamantScreensFactory: ScreensFactory { partnerQRFactory = .init(parent: assembler) coinsNodesListFactory = .init(parent: assembler) chatSelectTextFactory = .init() + storageUsageFactory = .init(parent: assembler) walletFactoryCompose = AdamantWalletFactoryCompose( klyWalletFactory: .init(assembler: assembler), @@ -168,6 +170,10 @@ struct AdamantScreensFactory: ScreensFactory { contributeFactory.makeViewController() } + func makeStorageUsage() -> UIViewController { + storageUsageFactory.makeViewController() + } + func makeLogin() -> LoginViewController { loginFactory.makeViewController(screenFactory: self) } diff --git a/Adamant/Modules/ScreensFactory/ScreensFactory.swift b/Adamant/Modules/ScreensFactory/ScreensFactory.swift index d0490b382..b5ecfa2f8 100644 --- a/Adamant/Modules/ScreensFactory/ScreensFactory.swift +++ b/Adamant/Modules/ScreensFactory/ScreensFactory.swift @@ -59,6 +59,7 @@ protocol ScreensFactory { func makeNotifications() -> UIViewController func makeVisibleWallets() -> UIViewController func makeContribute() -> UIViewController + func makeStorageUsage() -> UIViewController func makeLogin() -> LoginViewController func makeVibrationSelection() -> UIViewController func makePartnerQR(partner: CoreDataAccount) -> UIViewController diff --git a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift new file mode 100644 index 000000000..fbb88989e --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift @@ -0,0 +1,39 @@ +// +// StorageUsageFactory.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Swinject +import SwiftUI +import FilesStorageKit + +struct StorageUsageFactory { + private let assembler: Assembler + + init(parent: Assembler) { + assembler = .init([StorageUsageAssembly()], parent: parent) + } + + func makeViewController() -> UIViewController { + UIHostingController( + rootView: StorageUsageView { + assembler.resolve(StorageUsageViewModel.self)! + } + ) + } +} + +private struct StorageUsageAssembly: Assembly { + func assemble(container: Container) { + container.register(StorageUsageViewModel.self) { + StorageUsageViewModel( + filesStorage: $0.resolve(FilesStorageProtocol.self)!, + dialogService: $0.resolve(DialogService.self)!, + filesStorageProprieties: $0.resolve(FilesStorageProprietiesProtocol.self)! + ) + }.inObjectScope(.weak) + } +} diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift new file mode 100644 index 000000000..daab84c31 --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -0,0 +1,156 @@ +// +// StorageUsageView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SwiftUI +import CommonKit +import Charts + +struct StorageUsageView: View { + @StateObject private var viewModel: StorageUsageViewModel + + init(viewModel: @escaping () -> StorageUsageViewModel) { + _viewModel = .init(wrappedValue: viewModel()) + } + + var body: some View { + VStack { + List { + storageSection + autoDownloadSection + saveEncryptedSection + } + .listStyle(.insetGrouped) + .navigationTitle(storageTitle) + + Spacer() + + makeClearButton() + } + .alert( + clearPopupTitle, + isPresented: $viewModel.isRemoveAlertShown + ) { + Button(String.adamant.alert.cancel, role: .cancel) {} + Button(clearTitle) { viewModel.clearStorage() } + } + .onAppear(perform: { + viewModel.loadData() + }) + .withoutListBackground() + .background(Color(.adamant.secondBackgroundColor)) + } +} + +private extension StorageUsageView { + var storageSection: some View { + Section( + content: { + content + .listRowBackground(Color(uiColor: .adamant.cellColor)) + }, + footer: { Text(verbatim: storageDescription) } + ) + } + + var autoDownloadSection: some View { + Section( + content: { + autoDownloadContent(for: .preview) + .listRowBackground(Color(uiColor: .adamant.cellColor)) + autoDownloadContent(for: .fullMedia) + .listRowBackground(Color(uiColor: .adamant.cellColor)) + }, + header: { Text(verbatim: autDownloadHeader) }, + footer: { Text(verbatim: autDownloadDescription) } + ) + } + + var saveEncryptedSection: some View { + Section( + content: { + Toggle( + saveEncryptedTitle, + isOn: $viewModel.saveEncrypted + ) + .onChange(of: viewModel.saveEncrypted) { value in + print(value) + viewModel.saveFileEncrypted(value) + } + .listRowBackground(Color(uiColor: .adamant.cellColor)) + .tint(Color(uiColor: .adamant.active)) + }, + footer: { Text(verbatim: saveEncryptedDescription) } + ) + } + + var content: some View { + HStack { + Image(uiImage: storageImage) + Text(verbatim: storageUsedTitle) + Spacer() + if let storage = viewModel.storageUsedDescription { + Text(storage) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + } + + func autoDownloadContent( + for type: StorageUsageViewModel.AutoDownloadMediaType + ) -> some View { + Button { + viewModel.presentPicker(for: type) + } label: { + HStack { + Image(uiImage: previewImage) + Text(type.title) + + Spacer() + + switch type { + case .preview: + Text(viewModel.autoDownloadPreview.title) + case .fullMedia: + Text(viewModel.autoDownloadFullMedia.title) + } + + NavigationLink(destination: { EmptyView() }, label: { EmptyView() }).fixedSize() + } + } + } + + func makeClearButton() -> some View { + Button(action: showClearAlert) { + Text(clearTitle) + .expanded(axes: .horizontal) + } + .frame(maxWidth: .infinity) + .frame(height: 45) + .background(Color(uiColor: .adamant.cellColor)) + .clipShape(.rect(cornerRadius: 8.0)) + .padding() + } + + func showClearAlert() { + viewModel.isRemoveAlertShown = true + } +} + +private var storageUsedTitle: String { .localized("StorageUsed.Title") } +private let storageImage: UIImage = .asset(named: "row_storage")! +private var storageDescription: String { .localized("StorageUsage.Description") } +private var storageTitle: String { .localized("StorageUsage.Title") } +private var clearPopupTitle: String { .localized("StorageUsage.Clear.Popup.Title") } +private var clearTitle: String { .localized("StorageUsage.Clear.Title") } +private let previewImage: UIImage = .asset(named: "row_preview")! +private var autDownloadHeader: String { .localized("Storage.AutoDownloadPreview.Header") } +private var autDownloadDescription: String { .localized("Storage.AutoDownloadPreview.Description") } +private var saveEncryptedTitle: String { .localized("Storage.SaveEncrypted.Title") } +private var saveEncryptedDescription: String { .localized("Storage.SaveEncrypted.Description") } diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift new file mode 100644 index 000000000..ae140d751 --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -0,0 +1,166 @@ +// +// StorageUsageViewModel.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit +import SwiftUI +import FilesStorageKit + +@MainActor +final class StorageUsageViewModel: ObservableObject { + private let filesStorage: FilesStorageProtocol + private let dialogService: DialogService + private let filesStorageProprieties: FilesStorageProprietiesProtocol + + @Published var storageUsedDescription: String? + @Published var autoDownloadPreview: DownloadPolicy = .everybody + @Published var autoDownloadFullMedia: DownloadPolicy = .everybody + @Published var isRemoveAlertShown: Bool = false + @Published var saveEncrypted: Bool = false + + enum AutoDownloadMediaType { + case preview + case fullMedia + + var title: String { + switch self { + case .preview: + return .localized("Storage.AutoDownloadPreview.Title") + case .fullMedia: + return .localized("Storage.AutoDownloadFullMedia.Title") + } + } + } + + nonisolated init( + filesStorage: FilesStorageProtocol, + dialogService: DialogService, + filesStorageProprieties: FilesStorageProprietiesProtocol + ) { + self.filesStorage = filesStorage + self.dialogService = dialogService + self.filesStorageProprieties = filesStorageProprieties + } + + func loadData() { + autoDownloadPreview = filesStorageProprieties.autoDownloadPreviewPolicy() + autoDownloadFullMedia = filesStorageProprieties.autoDownloadFullMediaPolicy() + saveEncrypted = filesStorageProprieties.saveFileEncrypted() + updateCacheSize() + } + + func saveFileEncrypted(_ value: Bool) { + filesStorageProprieties.setSaveFileEncrypted(value) + saveEncrypted = value + } + + func clearStorage() { + do { + dialogService.showProgress(withMessage: nil, userInteractionEnable: false) + try filesStorage.clearCache() + dialogService.dismissProgress() + dialogService.showSuccess(withMessage: nil) + updateCacheSize() + NotificationCenter.default.post(name: .Storage.storageClear, object: nil) + } catch { + dialogService.dismissProgress() + dialogService.showError( + withMessage: error.localizedDescription, + supportEmail: false, + error: error + ) + } + } + + func presentPicker(for type: AutoDownloadMediaType) { + let action: ((DownloadPolicy) -> Void)? = { [weak self] policy in + guard let self = self else { return } + + switch type { + case .preview: + self.filesStorageProprieties.setAutoDownloadPreview(policy) + self.autoDownloadPreview = policy + case .fullMedia: + self.filesStorageProprieties.setAutoDownloadFullMedia(policy) + self.autoDownloadFullMedia = policy + } + NotificationCenter.default.post(name: .Storage.storageProprietiesUpdated, object: nil) + } + + dialogService.showAlert( + title: nil, + message: nil, + style: .actionSheet, + actions: [ + makeAction( + title: DownloadPolicy.everybody.title, + action: { [action] _ in action?(.everybody) } + ), + makeAction( + title: DownloadPolicy.contacts.title, + action: { [action] _ in action?(.contacts) } + ), + makeAction( + title: DownloadPolicy.nobody.title, + action: { [action] _ in action?(.nobody) } + ), + makeCancelAction() + ], + from: nil + ) + } +} + +private extension StorageUsageViewModel { + func updateCacheSize() { + DispatchQueue.global().async { + let size = (try? self.filesStorage.getCacheSize().get()) ?? .zero + DispatchQueue.main.async { + self.storageUsedDescription = self.formatSize(size) + } + } + } + + func formatSize(_ bytes: Int64) -> String { + if #available(iOS 16.0, *) { + let count = Measurement( + value: Double(bytes), + unit: UnitInformationStorage.bytes + ) + + let style = Measurement.FormatStyle.ByteCount( + style: .file, + allowedUnits: .all, + spellsOutZero: true, + includesActualByteCount: false, + locale: String.locale() + ) + + return style.format(count) + } + + let formatter = ByteCountFormatter() + formatter.allowedUnits = .useAll + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} + +private extension StorageUsageViewModel { + func makeAction(title: String, action: ((UIAlertAction) -> Void)?) -> UIAlertAction { + .init( + title: title, + style: .default, + handler: action + ) + } + + func makeCancelAction() -> UIAlertAction { + .init(title: .adamant.alert.cancel, style: .cancel, handler: nil) + } +} diff --git a/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift b/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift index b3e61438f..04c3d04ca 100644 --- a/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift +++ b/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift @@ -75,7 +75,8 @@ extension DogeWalletService: WalletServiceTwoStepSend { path: DogeApiCommands.sendTransaction(), method: .post, parameters: ["rawtx": txHex], - encoding: .json + encoding: .json, + downloadProgress: { _ in } ) guard diff --git a/Adamant/Release.entitlements b/Adamant/Release.entitlements index de1075df5..854da1316 100644 --- a/Adamant/Release.entitlements +++ b/Adamant/Release.entitlements @@ -8,6 +8,16 @@ applinks:msg.adamant.im + com.apple.developer.icloud-container-identifiers + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox com.apple.security.device.camera diff --git a/Adamant/ServiceProtocols/APICoreProtocol.swift b/Adamant/ServiceProtocols/APICoreProtocol.swift index 4c1ea329a..1a5e8c28a 100644 --- a/Adamant/ServiceProtocols/APICoreProtocol.swift +++ b/Adamant/ServiceProtocols/APICoreProtocol.swift @@ -14,12 +14,20 @@ import UIKit enum ApiCommands {} protocol APICoreProtocol: Actor { + func sendRequestMultipartFormData( + node: Node, + path: String, + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) + ) async -> APIResponseModel + func sendRequestBasic( node: Node, path: String, method: HTTPMethod, parameters: Parameters, - encoding: APIParametersEncoding + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel /// jsonParameters - arrays and dictionaries are allowed only @@ -46,10 +54,47 @@ extension APICoreProtocol { path: path, method: method, parameters: parameters, - encoding: encoding + encoding: encoding, + downloadProgress: { _ in } + ).result + } + + func sendRequest( + node: Node, + path: String, + method: HTTPMethod, + parameters: Parameters, + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> ApiServiceResult { + await sendRequestBasic( + node: node, + path: path, + method: method, + parameters: parameters, + encoding: encoding, + downloadProgress: downloadProgress ).result } + func sendRequest( + node: Node, + path: String, + method: HTTPMethod, + parameters: Parameters, + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> APIResponseModel { + await sendRequestBasic( + node: node, + path: path, + method: method, + parameters: parameters, + encoding: encoding, + downloadProgress: downloadProgress + ) + } + func sendRequestJsonResponse( node: Node, path: String, @@ -92,6 +137,36 @@ extension APICoreProtocol { ) } + func sendRequest( + node: Node, + path: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> ApiServiceResult { + await sendRequest( + node: node, + path: path, + method: .get, + parameters: emptyParameters, + encoding: .url, + downloadProgress: downloadProgress + ) + } + + func sendRequest( + node: Node, + path: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> APIResponseModel { + await sendRequest( + node: node, + path: path, + method: .get, + parameters: emptyParameters, + encoding: .url, + downloadProgress: downloadProgress + ) + } + func sendRequestJsonResponse( node: Node, path: String, @@ -106,6 +181,20 @@ extension APICoreProtocol { ).result.flatMap { parseJSON(data: $0) } } + func sendRequestMultipartFormDataJsonResponse( + node: Node, + path: String, + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) + ) async -> ApiServiceResult { + await sendRequestMultipartFormData( + node: node, + path: path, + models: models, + uploadProgress: uploadProgress + ).result.flatMap { parseJSON(data: $0) } + } + func sendRequestRPC( node: Node, path: String, diff --git a/Adamant/ServiceProtocols/AdamantCore/AdamantCore.swift b/Adamant/ServiceProtocols/AdamantCore/AdamantCore.swift index 0be586ec4..c6dd059ea 100644 --- a/Adamant/ServiceProtocols/AdamantCore/AdamantCore.swift +++ b/Adamant/ServiceProtocols/AdamantCore/AdamantCore.swift @@ -23,6 +23,19 @@ protocol AdamantCore: AnyObject { func decodeMessage(rawMessage: String, rawNonce: String, senderPublicKey: String, privateKey: String) -> String? func encodeValue(_ value: [String: Any], privateKey: String) -> (message: String, nonce: String)? func decodeValue(rawMessage: String, rawNonce: String, privateKey: String) -> String? + + func encodeData( + _ data: Data, + recipientPublicKey publicKey: String, + privateKey privateKeyHex: String + ) -> (data: Data, nonce: String)? + + func decodeData( + _ data: Data, + rawNonce: String, + senderPublicKey senderKeyHex: String, + privateKey privateKeyHex: String + ) -> Data? } protocol SignableTransaction { diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift new file mode 100644 index 000000000..9e5b13348 --- /dev/null +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -0,0 +1,77 @@ +// +// ChatFileProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 22.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit +import Combine +import UIKit +import FilesStorageKit + +struct FileUpdateProperties { + let id: String + let newId: String? + let fileNonce: String? + let preview: UIImage?? + let cached: Bool? + let downloadStatus: DownloadStatus? + let uploading: Bool? + let progress: Int? + let isPreviewDownloadAllowed: Bool? + let isFullMediaDownloadAllowed: Bool? +} + +protocol ChatFileProtocol { + var downloadingFiles: [String: DownloadStatus] { get } + var uploadingFiles: [String] { get } + var filesLoadingProgress: [String: Int] { get } + + var updateFileFields: PassthroughSubject { + get + } + + func sendFile( + text: String?, + chatroom: Chatroom?, + filesPicked: [FileResult]?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws + + func downloadFile( + file: ChatFile, + chatroom: Chatroom?, + saveEncrypted: Bool, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool + ) async throws + + func autoDownload( + file: ChatFile, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool + ) async + + func getDecodedData( + file: FilesStorageKit.File, + nonce: String, + chatroom: Chatroom? + ) throws -> Data + + func resendMessage( + with id: String, + text: String?, + chatroom: Chatroom?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws + + func isDownloadPreviewLimitReached(for fileId: String) -> Bool +} diff --git a/Adamant/ServiceProtocols/ChatPreservation.swift b/Adamant/ServiceProtocols/ChatPreservation.swift index c64b1701c..83fcdfaef 100644 --- a/Adamant/ServiceProtocols/ChatPreservation.swift +++ b/Adamant/ServiceProtocols/ChatPreservation.swift @@ -12,7 +12,8 @@ import Combine final class ChatPreservation: ChatPreservationProtocol { @Atomic private var preservedMessages: [String: String] = [:] - @Atomic private var replayMessage: [String: MessageModel] = [:] + @Atomic private var preservedReplayMessage: [String: MessageModel] = [:] + @Atomic private var preservedFiles: [String: [FileResult]] = [:] @Atomic private var notificationsSet: Set = [] init() { @@ -28,7 +29,8 @@ final class ChatPreservation: ChatPreservationProtocol { private func clearPreservedMessages() { preservedMessages = [:] - replayMessage = [:] + preservedReplayMessage = [:] + preservedFiles = [:] } func preserveMessage(_ message: String, forAddress address: String) { @@ -48,11 +50,11 @@ final class ChatPreservation: ChatPreservationProtocol { } func setReplyMessage(_ message: MessageModel?, forAddress address: String) { - replayMessage[address] = message + preservedReplayMessage[address] = message } func getReplyMessage(address: String, thenRemoveIt: Bool) -> MessageModel? { - guard let replayMessage = replayMessage[address] else { + guard let replayMessage = preservedReplayMessage[address] else { return nil } @@ -62,4 +64,23 @@ final class ChatPreservation: ChatPreservationProtocol { return replayMessage } + + func preserveFiles(_ files: [FileResult]?, forAddress address: String) { + preservedFiles[address] = files + } + + func getPreservedFiles( + for address: String, + thenRemoveIt: Bool + ) -> [FileResult]? { + guard let files = preservedFiles[address] else { + return nil + } + + if thenRemoveIt { + preservedFiles.removeValue(forKey: address) + } + + return files + } } diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index 9a6532da3..b45672bba 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -217,6 +217,29 @@ protocol ChatsProvider: DataProvider, Actor { func sendMessage(_ message: AdamantMessage, recipientId: String, from chatroom: Chatroom?) async throws -> ChatTransaction func retrySendMessage(_ message: ChatTransaction) async throws + func sendFileMessageLocally( + _ message: AdamantMessage, + recipientId: String, + from chatroom: Chatroom? + ) async throws -> String + + func sendFileMessage( + _ message: AdamantMessage, + recipientId: String, + transactionLocalyId: String, + from chatroom: Chatroom? + ) async throws -> ChatTransaction + + func updateTxMessageContent( + txId: String, + richMessage: RichMessage + ) throws + + func setTxMessageStatus( + txId: String, + status: MessageStatus + ) throws + // MARK: - Delete local message func cancelMessage(_ message: ChatTransaction) async throws func isMessageDeleted(id: String) -> Bool diff --git a/Adamant/ServiceProtocols/DialogService.swift b/Adamant/ServiceProtocols/DialogService.swift index 9c7a04df9..e7cd87f1e 100644 --- a/Adamant/ServiceProtocols/DialogService.swift +++ b/Adamant/ServiceProtocols/DialogService.swift @@ -29,6 +29,16 @@ extension String.adamant.alert { static var renameContactInitial: String { String.localized("Shared.RenameContactInitial", comment: "Partner screen 'Give contact a name' at first") + } + + static var sendTokens: String { + String.localized("Shared.SendTokens", comment: "Shared alert 'Send tokens'") + } + static var uploadFile: String { + String.localized("Shared.UploadFile", comment: "Shared alert 'Upload File'") + } + static var uploadMedia: String { + String.localized("Shared.UploadMedia", comment: "Shared alert 'Upload Media'") } } @@ -52,6 +62,9 @@ enum ShareType { case generateQr(encodedContent: String?, sharingTip: String?, withLogo: Bool) case saveToPhotolibrary(image: UIImage) case partnerQR + case sendTokens + case uploadMedia + case uploadFile var localized: String { switch self { @@ -66,6 +79,15 @@ enum ShareType { case .saveToPhotolibrary: return String.adamant.alert.saveToPhotolibrary + + case .sendTokens: + return String.adamant.alert.sendTokens + + case .uploadMedia: + return String.adamant.alert.uploadMedia + + case .uploadFile: + return String.adamant.alert.uploadFile } } } @@ -147,7 +169,7 @@ protocol DialogService: AnyObject { // MARK: - Indicators func showProgress(withMessage: String?, userInteractionEnable: Bool) func dismissProgress() - func showSuccess(withMessage: String) + func showSuccess(withMessage: String?) func showWarning(withMessage: String) func showError(withMessage: String, supportEmail: Bool, error: Error?) func showRichError(error: RichError) diff --git a/Adamant/ServiceProtocols/FileApiServiceProtocol.swift b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift new file mode 100644 index 000000000..8a1d30942 --- /dev/null +++ b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift @@ -0,0 +1,21 @@ +// +// FileApiServiceProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +protocol FileApiServiceProtocol: WalletApiService { + func uploadFile( + data: Data, + uploadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult + + func downloadFile( + id: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult +} diff --git a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift new file mode 100644 index 000000000..76dba9f9c --- /dev/null +++ b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift @@ -0,0 +1,23 @@ +// +// FilesNetworkManagerProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +protocol FilesNetworkManagerProtocol { + func uploadFiles( + _ data: Data, + type: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult + + func downloadFile( + _ id: String, + type: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult +} diff --git a/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift new file mode 100644 index 000000000..9029cb59e --- /dev/null +++ b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift @@ -0,0 +1,18 @@ +// +// FilesStorageProprietiesProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 03.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +protocol FilesStorageProprietiesProtocol { + func autoDownloadPreviewPolicy() -> DownloadPolicy + func setAutoDownloadPreview(_ value: DownloadPolicy) + func autoDownloadFullMediaPolicy() -> DownloadPolicy + func setAutoDownloadFullMedia(_ value: DownloadPolicy) + func saveFileEncrypted() -> Bool + func setSaveFileEncrypted(_ value: Bool) +} diff --git a/Adamant/ServiceProtocols/NotificationsService.swift b/Adamant/ServiceProtocols/NotificationsService.swift index b02c913ff..ca35842e3 100644 --- a/Adamant/ServiceProtocols/NotificationsService.swift +++ b/Adamant/ServiceProtocols/NotificationsService.swift @@ -121,11 +121,10 @@ enum AdamantNotificationType { // MARK: - Notifications extension Notification.Name { - struct AdamantNotificationService { + enum AdamantNotificationService { /// Raised when user has logged out. static let notificationsModeChanged = Notification.Name("adamant.notificationService.notificationsMode") static let notificationsSoundChanged = Notification.Name("adamant.notificationService.notificationsSound") - private init() {} } } diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index f3748aa34..bfcd37c0f 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -19,18 +19,48 @@ actor APICore: APICoreProtocol { private lazy var session: Session = { let configuration = AF.sessionConfiguration configuration.waitsForConnectivity = true - configuration.timeoutIntervalForRequest = timeoutInterval - configuration.timeoutIntervalForResource = timeoutInterval + configuration.timeoutIntervalForRequest = timeoutIntervalForRequest + configuration.timeoutIntervalForResource = timeoutIntervalForResource configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpMaximumConnectionsPerHost = maximumConnectionsPerHost return Alamofire.Session.init(configuration: configuration) }() + func sendRequestMultipartFormData( + node: Node, + path: String, + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) + ) async -> APIResponseModel { + do { + let request = session.upload(multipartFormData: { multipartFormData in + models.forEach { file in + multipartFormData.append( + file.data, + withName: file.keyName, + fileName: file.fileName + ) + } + }, to: try buildUrl(node: node, path: path)) + .uploadProgress(queue: .global(), closure: uploadProgress) + + return await sendRequest(request: request) + } catch { + return .init( + result: .failure(.internalError(message: error.localizedDescription, error: error)), + data: nil, + code: nil + ) + } + } + func sendRequestBasic( node: Node, path: String, method: HTTPMethod, parameters: Parameters, - encoding: APIParametersEncoding + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel { do { let request = session.request( @@ -39,7 +69,7 @@ actor APICore: APICoreProtocol { parameters: parameters.asDictionary(), encoding: encoding.parametersEncoding, headers: HTTPHeaders(["Content-Type": "application/json"]) - ) + ).downloadProgress(closure: downloadProgress) return await sendRequest(request: request) } catch { @@ -100,4 +130,6 @@ private extension APICore { } } -private let timeoutInterval: TimeInterval = 15 +private let timeoutIntervalForRequest: TimeInterval = 15 +private let timeoutIntervalForResource: TimeInterval = 24 * 3600 +private let maximumConnectionsPerHost = 100 diff --git a/Adamant/Services/AdamantDialogService.swift b/Adamant/Services/AdamantDialogService.swift index 1da3dfe68..57ddad131 100644 --- a/Adamant/Services/AdamantDialogService.swift +++ b/Adamant/Services/AdamantDialogService.swift @@ -85,7 +85,7 @@ extension AdamantDialogService { popupManager.dismissAlert() } - func showSuccess(withMessage message: String) { + func showSuccess(withMessage message: String?) { vibroService.applyVibration(.success) popupManager.showSuccessAlert(message: message) } @@ -441,10 +441,10 @@ extension AdamantDialogService { } alert.addAction(action) - - case .partnerQR: + + default: let action = UIAlertAction(title: type.localized, style: .default) { [didSelect] _ in - didSelect?(.partnerQR) + didSelect?(type) } alert.addAction(action) diff --git a/Adamant/Services/DataProviders/AdamantChatTransactionService.swift b/Adamant/Services/DataProviders/AdamantChatTransactionService.swift index 4dbcdc330..af0a86d58 100644 --- a/Adamant/Services/DataProviders/AdamantChatTransactionService.swift +++ b/Adamant/Services/DataProviders/AdamantChatTransactionService.swift @@ -178,6 +178,15 @@ actor AdamantChatTransactionService: ChatTransactionService { break } + if let trs = fileTransaction( + decodedMessage, + transaction: transaction, + context: context + ) { + messageTransaction = trs + break + } + let trs = MessageTransaction(entity: MessageTransaction.entity(), insertInto: context) trs.message = decodedMessage messageTransaction = trs @@ -272,7 +281,9 @@ private extension AdamantChatTransactionService { let richContent = RichMessageTools.richContent(from: data), let type = richContent[RichContentKeys.type] as? String, type != RichContentKeys.reply.reply, - richContent[RichContentKeys.reply.replyToId] == nil + type != RichContentKeys.file.file, + richContent[RichContentKeys.reply.replyToId] == nil, + richContent[RichContentKeys.file.files] == nil else { return nil } let trs = RichMessageTransaction( @@ -390,4 +401,35 @@ private extension AdamantChatTransactionService { return trs } + + func fileTransaction( + _ decodedMessage: String, + transaction: Transaction, + context: NSManagedObjectContext + ) -> ChatTransaction? { + guard let data = decodedMessage.data(using: String.Encoding.utf8), + let richContent = RichMessageTools.richContent(from: data), + richContent[RichContentKeys.file.files] != nil + else { return nil } + + if let trs = getChatTransactionFromDB( + id: String(transaction.id), + context: context + ) { + return trs + } + + let trs = RichMessageTransaction( + entity: RichMessageTransaction.entity(), + insertInto: context + ) + + trs.richTransferHash = richContent[RichContentKeys.hash] as? String + trs.richContent = richContent + trs.richType = RichContentKeys.file.file + trs.transactionStatus = nil + trs.additionalType = .file + + return trs + } } diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 575269f31..346873b93 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -403,7 +403,7 @@ extension AdamantChatsProvider { array.prefix(preLoadChatsCount).forEach { transaction in let recipientAddress = transaction.recipientId == address ? transaction.senderId : transaction.recipientId Task { - let isChatLoading = isChatLoading(with: address) + let isChatLoading = isChatLoading(with: recipientAddress) guard !isChatLoading else { return } await getChatMessages(with: recipientAddress, offset: nil) } @@ -862,9 +862,143 @@ extension AdamantChatsProvider { from: chatroom ) + return transactionLocaly + } + + func sendFileMessageLocally( + _ message: AdamantMessage, + recipientId: String, + from chatroom: Chatroom? + ) async throws -> String { + guard let loggedAccount = accountService.account, let keypair = accountService.keypair else { + throw ChatsProviderError.notLogged + } + + guard loggedAccount.balance >= message.fee else { + throw ChatsProviderError.notEnoughMoneyToSend + } + + switch validateMessage(message) { + case .isValid: + break + case .empty: + throw ChatsProviderError.messageNotValid(.empty) + case .tooLong: + throw ChatsProviderError.messageNotValid(.tooLong) + } + + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.parent = stack.container.viewContext + + guard case let .richMessage(payload) = message else { + throw ChatsProviderError.messageNotValid(.empty) + } + + let transactionLocaly = try await sendRichMessageLocaly( + richContent: payload.content(), + richContentSerialized: payload.serialized(), + richType: payload.type, + additionalType: payload.additionalType, + senderId: loggedAccount.address, + recipientId: recipientId, + keypair: keypair, + context: context, + from: chatroom + ) + + return transactionLocaly.transactionId + } + + func sendFileMessage( + _ message: AdamantMessage, + recipientId: String, + transactionLocalyId: String, + from chatroom: Chatroom? + ) async throws -> ChatTransaction { + guard let loggedAccount = accountService.account, let keypair = accountService.keypair else { + throw ChatsProviderError.notLogged + } + + guard loggedAccount.balance >= message.fee else { + throw ChatsProviderError.notEnoughMoneyToSend + } + + switch validateMessage(message) { + case .isValid: + break + case .empty: + throw ChatsProviderError.messageNotValid(.empty) + case .tooLong: + throw ChatsProviderError.messageNotValid(.tooLong) + } + + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.parent = stack.container.viewContext + + guard let transactionLocaly = getBaseTransactionFromDB( + id: transactionLocalyId, + context: context + ) as? RichMessageTransaction + else { + throw ChatsProviderError.transactionNotFound(id: transactionLocalyId) + } + + guard case let .richMessage(payload) = message else { + throw ChatsProviderError.messageNotValid(.empty) + } + + transactionLocaly.richContent = payload.content() + transactionLocaly.richContentSerialized = payload.serialized() + + let transaction = try await sendMessageToServer( + senderId: loggedAccount.address, + recipientId: recipientId, + transaction: transactionLocaly, + type: message.chatType, + keypair: keypair, + context: context, + from: chatroom + ) + return transaction } + func setTxMessageStatus( + txId: String, + status: MessageStatus + ) throws { + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.parent = stack.container.viewContext + + guard let transaction = getBaseTransactionFromDB( + id: txId, + context: context + ) as? RichMessageTransaction + else { + throw ChatsProviderError.transactionNotFound(id: txId) + } + + transaction.statusEnum = status + try context.save() + } + + func updateTxMessageContent(txId: String, richMessage: RichMessage) throws { + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + context.parent = stack.container.viewContext + + guard let transaction = getBaseTransactionFromDB( + id: txId, + context: context + ) as? RichMessageTransaction + else { + throw ChatsProviderError.transactionNotFound(id: txId) + } + + transaction.richContent = richMessage.content() + transaction.richContentSerialized = richMessage.serialized() + try context.save() + } + private func sendTextMessageLocaly( text: String, isMarkdown: Bool, @@ -919,7 +1053,7 @@ extension AdamantChatsProvider { keypair: Keypair, context: NSManagedObjectContext, from chatroom: Chatroom? = nil - ) async throws -> ChatTransaction { + ) async throws -> RichMessageTransaction { let type = ChatType.richMessage let id = UUID().uuidString let transaction = RichMessageTransaction(context: context) @@ -1572,6 +1706,7 @@ extension AdamantChatsProvider { transactionInProgress.append(trs.transaction.id) if let objectId = unconfirmedTransactions[trs.transaction.id], let unconfirmed = context.object(with: objectId) as? ChatTransaction { + print("confirmTransaction tr=\(trs.transaction.id)") confirmTransaction( unconfirmed, id: trs.transaction.id, @@ -1589,6 +1724,7 @@ extension AdamantChatsProvider { // if transaction in pending status then ignore it if unconfirmedTransactionsBySignature.contains(trs.transaction.signature) { + print("ignore tr=\(trs.transaction.id)") continue } diff --git a/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift new file mode 100644 index 000000000..8cd6b09e9 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift @@ -0,0 +1,46 @@ +// +// FilesNetworkManager.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 09.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +final class FilesNetworkManager: FilesNetworkManagerProtocol { + private let ipfsService: IPFSApiService + + init(ipfsService: IPFSApiService) { + self.ipfsService = ipfsService + } + + func uploadFiles( + _ data: Data, + type: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult { + switch type { + case .ipfs: + return await ipfsService.uploadFile(data: data, uploadProgress: uploadProgress) + } + } + + func downloadFile( + _ id: String, + type: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult { + guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { + return .failure(.cantDownloadFile) + } + + switch netwrokProtocol { + case .ipfs: + return await ipfsService.downloadFile( + id: id, + downloadProgress: downloadProgress + ) + } + } +} diff --git a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift new file mode 100644 index 000000000..bda6da969 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift @@ -0,0 +1,51 @@ +// +// IPFS+Constants.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +extension IPFSApiService { + var preferredNodeIds: [UUID] { + service.preferredNodeIds + } + + func healthCheck() { + service.healthCheck() + } + + static var symbol: String { + "IPFS" + } + + static var nodes: [Node] { + [ + Node( + url: URL(string: "https://ipfs4.adm.im")!, + altUrl: URL(string: "http://95.216.45.88:44099")! + ), + Node( + url: URL(string: "https://ipfs5.adamant.im")!, + altUrl: URL(string: "http://62.72.43.99:44099")! + ), + Node( + url: URL(string: "https://ipfs6.adamant.business")!, + altUrl: URL(string: "http://75.119.138.235:44099")! + ) + ] + } + + static let healthCheckParameters = CoinHealthCheckParameters( + normalUpdateInterval: 210, + crucialUpdateInterval: 30, + onScreenUpdateInterval: 10, + threshold: 3, + normalServiceUpdateInterval: 210, + crucialServiceUpdateInterval: 30, + onScreenServiceUpdateInterval: 10 + ) +} diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift b/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift new file mode 100644 index 000000000..f75a75918 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift @@ -0,0 +1,47 @@ +// +// IPFSApiCore.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 09.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +extension IPFSApiCommands { + static let status = "/api/node/info" +} + +final class IPFSApiCore { + let apiCore: APICoreProtocol + + init(apiCore: APICoreProtocol) { + self.apiCore = apiCore + } + + func getNodeStatus(node: Node) async -> ApiServiceResult { + await apiCore.sendRequestJsonResponse( + node: node, + path: IPFSApiCommands.status + ) + } +} + +extension IPFSApiCore: BlockchainHealthCheckableService { + func getStatusInfo(node: Node) async -> ApiServiceResult { + let startTimestamp = Date.now.timeIntervalSince1970 + let statusResponse = await getNodeStatus(node: node) + let ping = Date.now.timeIntervalSince1970 - startTimestamp + + return statusResponse.map { _ in + .init( + ping: ping, + height: .zero, + wsEnabled: false, + wsPort: nil, + version: nil + ) + } + } +} diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift new file mode 100644 index 000000000..ece6b084b --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -0,0 +1,105 @@ +// +// IPFSApiService.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 09.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +enum IPFSApiCommands { + static let file = ( + upload: "api/file/upload", + download: "api/file/", + fieldName: "files" + ) +} + +final class IPFSApiService: FileApiServiceProtocol { + let service: BlockchainHealthCheckWrapper + + init( + healthCheckWrapper: BlockchainHealthCheckWrapper + ) { + service = healthCheckWrapper + } + + func request( + _ request: @Sendable (APICoreProtocol, Node) async -> ApiServiceResult + ) async -> ApiServiceResult { + await service.request { admApiCore, node in + await request(admApiCore.apiCore, node) + } + } + + func uploadFile( + data: Data, + uploadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult { + let model: MultipartFormDataModel = .init( + keyName: IPFSApiCommands.file.fieldName, + fileName: defaultFileName, + data: data + ) + + let result: Result = await request { core, node in + await core.sendRequestMultipartFormDataJsonResponse( + node: node, + path: IPFSApiCommands.file.upload, + models: [model], + uploadProgress: uploadProgress + ) + } + + return result.flatMap { result in + guard let cid = result.cids.first else { + return .failure( + .serverError(error: FileManagerError.cantUploadFile.localizedDescription) + ) + } + + return .success(cid) + }.mapError { .apiError(error: $0) } + } + + func downloadFile( + id: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async -> FileApiServiceResult { + let result: Result = await request { core, node in + let result: APIResponseModel = await core.sendRequest( + node: node, + path: "\(IPFSApiCommands.file.download)\(id)", + downloadProgress: downloadProgress + ) + + if let error = handleError(result) { + return .failure(error) + } + + return result.result + } + + return result.flatMap { .success($0) } + .mapError { .apiError(error: $0) } + } +} + +private extension IPFSApiService { + func handleError(_ result: APIResponseModel) -> ApiServiceError? { + guard let code = result.code, + !(200 ... 299).contains(code) + else { return nil } + + let serverError = ApiServiceError.serverError(error: "\(code)") + let error = code == 500 || code == 502 || code == 504 + ? .networkError(error: serverError) + : serverError + + return error + } +} + +private let defaultFileName = "fileName" diff --git a/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift new file mode 100644 index 000000000..e36259dc5 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift @@ -0,0 +1,38 @@ +// +// FileManagerError.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +enum NetworkFileProtocolType: String { + case ipfs +} + +enum FileManagerError: Error { + case cantDownloadFile + case cantUploadFile + case cantEncryptFile + case cantDecryptFile + case apiError(error: ApiServiceError) +} + +extension FileManagerError: LocalizedError { + var errorDescription: String? { + switch self { + case .cantDownloadFile: + return .localized("FileManagerError.CantDownloadFile") + case .cantUploadFile: + return .localized("FileManagerError.CantUploadFile") + case .cantEncryptFile: + return .localized("FileManagerError.CantEncryptFile") + case .cantDecryptFile: + return .localized("FileManagerError.CantDecryptFile") + case let .apiError(error: error): + return error.localizedDescription + } + } +} diff --git a/Adamant/Services/FilesNetworkManager/Models/IPFSDTO.swift b/Adamant/Services/FilesNetworkManager/Models/IPFSDTO.swift new file mode 100644 index 000000000..b2a6cea86 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/Models/IPFSDTO.swift @@ -0,0 +1,13 @@ +// +// IPFSDTO.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +struct IpfsDTO: Decodable { + let cids: [String] +} diff --git a/Adamant/Services/FilesStorageProprietiesService.swift b/Adamant/Services/FilesStorageProprietiesService.swift new file mode 100644 index 000000000..d548e39c2 --- /dev/null +++ b/Adamant/Services/FilesStorageProprietiesService.swift @@ -0,0 +1,120 @@ +// +// FilesStorageProprietiesService.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 03.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import Combine +import CommonKit + +final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { + // MARK: Dependencies + + let securedStore: SecuredStore + + // MARK: Proprieties + + @Atomic private var notificationsSet: Set = [] + private var autoDownloadPreviewState: DownloadPolicy = .everybody + private var autoDownloadFullMediaState: DownloadPolicy = .everybody + private let autoDownloadPreviewDefaultState: DownloadPolicy = .contacts + private let autoDownloadFullMediaDefaultState: DownloadPolicy = .contacts + private var saveFileEncryptedValue = true + private let saveFileEncryptedDefault = true + + // MARK: Lifecycle + + init(securedStore: SecuredStore) { + self.securedStore = securedStore + + NotificationCenter.default + .publisher(for: .AdamantAccountService.userLoggedIn) + .sink { [weak self] _ in + self?.userLoggedIn() + } + .store(in: ¬ificationsSet) + + NotificationCenter.default + .publisher(for: .AdamantAccountService.userLoggedOut) + .sink { [weak self] _ in + self?.userLoggedOut() + } + .store(in: ¬ificationsSet) + } + + // MARK: Notification actions + + private func userLoggedIn() { + autoDownloadPreviewState = getAutoDownloadPreview() + autoDownloadFullMediaState = getAutoDownloadFullMedia() + saveFileEncryptedValue = getSaveFileEncrypted() + } + + private func userLoggedOut() { + setAutoDownloadPreview(autoDownloadPreviewDefaultState) + setAutoDownloadFullMedia(autoDownloadFullMediaDefaultState) + saveFileEncryptedValue = saveFileEncryptedDefault + } + + // MARK: Update data + + func saveFileEncrypted() -> Bool { + saveFileEncryptedValue + } + + func getSaveFileEncrypted() -> Bool { + guard let result: Bool = securedStore.get( + StoreKey.storage.saveFileEncrypted + ) else { + return saveFileEncryptedDefault + } + + return result + } + + func setSaveFileEncrypted(_ value: Bool) { + securedStore.set(value, for: StoreKey.storage.saveFileEncrypted) + saveFileEncryptedValue = value + } + + func autoDownloadPreviewPolicy() -> DownloadPolicy { + autoDownloadPreviewState + } + + func getAutoDownloadPreview() -> DownloadPolicy { + guard let result: String = securedStore.get( + StoreKey.storage.autoDownloadPreview + ) else { + return autoDownloadPreviewDefaultState + } + + return DownloadPolicy(rawValue: result) ?? autoDownloadPreviewDefaultState + } + + func setAutoDownloadPreview(_ value: DownloadPolicy) { + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadPreview) + autoDownloadPreviewState = value + } + + func autoDownloadFullMediaPolicy() -> DownloadPolicy { + autoDownloadFullMediaState + } + + func getAutoDownloadFullMedia() -> DownloadPolicy { + guard let result: String = securedStore.get( + StoreKey.storage.autoDownloadFullMedia + ) else { + return autoDownloadFullMediaDefaultState + } + + return DownloadPolicy(rawValue: result) ?? autoDownloadFullMediaDefaultState + } + + func setAutoDownloadFullMedia(_ value: DownloadPolicy) { + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadFullMedia) + autoDownloadFullMediaState = value + } +} diff --git a/Adamant/Services/NodesStorage.swift b/Adamant/Services/NodesStorage.swift index f58c484f4..dae6bda6b 100644 --- a/Adamant/Services/NodesStorage.swift +++ b/Adamant/Services/NodesStorage.swift @@ -132,6 +132,8 @@ private extension NodesStorage { return DashWalletService.nodes.map { .init(group: .dash, node: $0) } case .adm: return AdmWalletService.nodes.map { .init(group: .adm, node: $0) } + case .ipfs: + return IPFSApiService.nodes.map { .init(group: .ipfs, node: $0) } } } diff --git a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift index c191dac09..6e8ce8798 100644 --- a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift +++ b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift @@ -209,6 +209,21 @@ private extension AdamantRichTransactionReplyService { break } + if let data = decodedMessage.data(using: String.Encoding.utf8), + let richContent = RichMessageTools.richContent(from: data), + let replyMessage = richContent[RichContentKeys.reply.replyMessage] as? [String: Any], + replyMessage[RichContentKeys.file.files] is [[String: Any]] { + message = FilePresentationHelper.getFilePresentationText(richContent) + break + } + + if let data = decodedMessage.data(using: String.Encoding.utf8), + let richContent = RichMessageTools.richContent(from: data), + richContent[RichContentKeys.file.files] is [[String: Any]] { + message = FilePresentationHelper.getFilePresentationText(richContent) + break + } + message = decodedMessage } @@ -258,6 +273,12 @@ private extension AdamantRichTransactionReplyService { break } + if let richContent = trs.richContent, + let _: [[String: Any]] = trs.getRichValue(for: RichContentKeys.file.files) { + message = FilePresentationHelper.getFilePresentationText(richContent) + break + } + message = unknownErrorMessage default: message = unknownErrorMessage diff --git a/Adamant/SharedViews/ReplyView.swift b/Adamant/SharedViews/ReplyView.swift index c2f8cd6a0..57b6b1bc7 100644 --- a/Adamant/SharedViews/ReplyView.swift +++ b/Adamant/SharedViews/ReplyView.swift @@ -89,8 +89,8 @@ final class ReplyView: UIView { func configure() { addSubview(horizontalStack) horizontalStack.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(verticalInsets) - $0.leading.trailing.equalToSuperview().inset(15) + $0.verticalEdges.equalToSuperview().inset(verticalInsets) + $0.horizontalEdges.equalToSuperview().inset(horizontalInsets) } } @@ -123,3 +123,4 @@ extension ReplyView { private let messageFont = UIFont.systemFont(ofSize: 14) private let horizontalStackSpacing: CGFloat = 25 private let verticalInsets: CGFloat = 8 +private let horizontalInsets: CGFloat = 12 diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 4088c7870..034264e2f 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -739,6 +739,51 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Title" = "Die Datei verschlüsselt aufbewahren"; + +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Description" = "Die Datei verschlüsselt im lokalen Speicher aufbewahren (Kann die Leistung beeinträchtigen)"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Vorschau"; + +/* Storage: Auto download full media */ +"Storage.AutoDownloadFullMedia.Title" = "Vollständige Medien"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Everybody.Title" = "Alle"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Nobody.Title" = "Niemand"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Contacts.Title" = "Meine Kontakte"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "Um einen Kontakt zu erstellen, geben Sie der ADM-Adresse einen Namen"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Header" = "Automatischer Download von Medien"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "Sie werden erneut Dateien und Bilder herunterladen. Weiter?"; + +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Klarer Speicher"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Speicherung und Daten"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Speicherung"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "Gesamtgröße der Dateien und Bilder im sicheren Speicher der App"; + +/* Chats: Auto preview download is disabled */ +"Chats.AutoDownloadPreview.Disabled" = "Automatischer Vorschau-Download ist deaktiviert"; + /* Security: Notification modes description. Markdown supported. */ "SecurityPage.Row.Notifications.ModesDescription" = "#### Benachrichtigungsmodi\n\n#### Deaktiviert\nKeine Benachrichtigungen.\n\n#### Hintergrundaktualisierung\nIhr Gerät erhält neue Nachrichteninformationen automatisch. Keine externen Aufrufe. Die Hintergrundaktualisierung wird von iOS verwaltet, die Zeit wird vom Betriebssystem bestimmt und ist von vielen Faktoren wie Akkustand, Netzwerkauslastung, Nutzungsmuster abhängig und kann nicht vorhergesagt werden. Es können 20 Minuten, 6 Stunden, oder ein Tag sein. Sie können die App trotzdem öffnen und nachschauen, ob eine Nachricht angekommen ist.\n\n#### Push\nBenachrichtigungen werden auf Ihr Gerät vom ADAMANT Benachrichtigungsservice gesendet. Sie erhalten eine Benachrichtigung umgehend, nachdem die Nachricht versendet und von der Blockchain bestätigt wurde - mit einer kleinen Verzögerung. Jedoch erfordert dieser Modus, dass der Gerätetoken Ihres Geräts in der Servicedatenbank registriert ist. Gerätetokens sind sicher, und diese Option ist zu empfehlen.\n\nSie können mehr über die Geräteregistrierung auf der ADAMANTs Github-Seite nachlesen."; @@ -892,6 +937,15 @@ /* Partner screen 'Rename contact'. */ "Shared.RenameContact" = "Kontakt umbenennen"; +/* Shared alert 'Send tokens' */ +"Shared.SendTokens" = "💸 Tokens senden"; + +/* Shared alert 'Upload file' */ +"Shared.UploadFile" = "📄 Datei hochladen"; + +/* Shared alert 'Upload Media' */ +"Shared.UploadMedia" = "📸 Medien hochladen"; + /* Shared alert 'Settings' button. Used to go to system Settings app, on application settings page. Should be same as Settings application title. */ "Shared.Settings" = "Einstellungen"; @@ -1245,3 +1299,32 @@ /* Transaction: Unknown token */ "Transaction.UnknownTokenTitle" = "Unbekannt"; +/* FileManager error 'Can't download file' */ +"FileManagerError.CantDownloadFile" = "Datei kann nicht heruntergeladen werden"; + +/* FileManager error 'Can't upload file' */ +"FileManagerError.CantUploadFile" = "Datei kann nicht hochgeladen werden"; + +/* FileManager error 'Can't encrypt file' */ +"FileManagerError.CantEncryptFile" = "Datei kann nicht verschlüsselt werden"; + +/* FileManager error 'Can't decrypt file' */ +"FileManagerError.CantDecryptFile" = "Datei kann nicht entschlüsselt werden"; + +/* File validation error 'Too many files' */ +"FileValidationError.TooManyFiles" = "Zu viele Dateien. Höchstens erlaubt: %lld"; + +/* File validation error 'File size exceeds limit' */ +"FileValidationError.FileSizeExceedsLimit" = "Dateigröße überschreitet das Limit. Maximal erlaubt: %lld MB"; + +/* File validation error 'File not found' */ +"FileValidationError.FileNotFound" = "Datei nicht gefunden"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "Datei kann nicht ausgewählt werden: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Dateien hier ablegen"; + +/* Chat unknown */ +"Chat.unknown.title" = "Unbekannt"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 37bca7fef..24d1f315d 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -724,6 +724,51 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Title" = "Store files encrypted"; + +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Description" = "Store files in the local storage encrypted (May impact performance)"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Preview"; + +/* Storage: Auto download full media */ +"Storage.AutoDownloadFullMedia.Title" = "Full Media"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Everybody.Title" = "Everybody"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Nobody.Title" = "Nobody"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Contacts.Title" = "My contacts"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Header" = "Auto-download media"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "To create a contact, give ADM address a name"; + +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Clear storage"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "You'll download files and images again. Continue?"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Storage and data"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Storage"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "Total file and image size in the app's secure storage"; + +/* Chats: Auto preview download is disabled */ +"Chats.AutoDownloadPreview.Disabled" = "Auto download is disabled"; + /* Security: Notification modes description. Markdown supported. */ "SecurityPage.Row.Notifications.ModesDescription" = "#### Notification modes\n\n#### Disabled\nNo notifications.\n\n#### Background Fetch\nYour device fetchs for new messages by itself. No external calls. Fetch is initiated by iOS, the actual time determined by the operating system based on many factors like battery charge, cellular network, application usage patterns and cannot be predicted. It can be 20 minutes, or 6 hours, or maybe even a day. You still can open app and check for a new message though.\n\n#### Push\nNotifications sent to your device by ADAMANT Notification Service. You will receive notification almost instantly after a message was sent and approved by the Blockchain — a few seconds delay. But this mode requires your device to register it's Device Token in the Service's database. Device tokens are safe and secure, and this option is recommended in most cases.\n\nYou can read more about device registration on ADAMANT's Github page.\n\n"; @@ -877,6 +922,15 @@ /* Partner screen 'Rename contact'. */ "Shared.RenameContact" = "Rename contact"; +/* Shared alert 'Send tokens' */ +"Shared.SendTokens" = "💸 Transfer crypto"; + +/* Shared alert 'Upload file' */ +"Shared.UploadFile" = "📄 Send file"; + +/* Shared alert 'Upload Media' */ +"Shared.UploadMedia" = "📸 Send media"; + /* Shared alert 'Settings' button. Used to go to system Settings app, on application settings page. Should be same as Settings application title. */ "Shared.Settings" = "Settings"; @@ -1218,3 +1272,32 @@ /* Transaction: Unknown token */ "Transaction.UnknownTokenTitle" = "Unknown"; +/* FileManager error 'Can't download file' */ +"FileManagerError.CantDownloadFile" = "Can't download file"; + +/* FileManager error 'Can't upload file' */ +"FileManagerError.CantUploadFile" = "Can't upload file"; + +/* FileManager error 'Can't encrypt file' */ +"FileManagerError.CantEncryptFile" = "Can't encrypt file"; + +/* FileManager error 'Can't decrypt file' */ +"FileManagerError.CantDecryptFile" = "Can't decrypt file"; + +/* File validation error 'Too many files' */ +"FileValidationError.TooManyFiles" = "Too many files. Maximum allowed: %lld"; + +/* File validation error 'File size exceeds limit' */ +"FileValidationError.FileSizeExceedsLimit" = "Send files up to %lld MB"; + +/* File validation error 'File not found' */ +"FileValidationError.FileNotFound" = "File not found"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "Can't select file: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Drop files here"; + +/* Chat unknown */ +"Chat.unknown.title" = "Unknown"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 066703b31..154ed882b 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -724,6 +724,51 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Некорректный адрес хоста"; +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Title" = "Хранить файлы зашифрованными"; + +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Description" = "Хранить файлы в зашифрованном виде в локальном хранилище (Может влиять на производительность)"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Превью"; + +/* Storage: Auto download full media */ +"Storage.AutoDownloadFullMedia.Title" = "Медиа полностью"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Everybody.Title" = "Все"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Nobody.Title" = "Никто"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Contacts.Title" = "Контакты"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Header" = "Автоматическая загрузка медиафайлов"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "Чтобы сохранить ADM-адрес в контакты, дайте ему имя"; + +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Очистить хранилище"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "Файлы и изображения придется скачивать повторно. Продолжить?"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Память и данные"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Хранилище"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "Общий объем файлов и изображений в защищенном хранилище приложения"; + +/* Chats: Auto preview download is disabled */ +"Chats.AutoDownloadPreview.Disabled" = "Автоматическая загрузка превью отключена"; + /* Security: Notification modes description. Markdown supported. */ "SecurityPage.Row.Notifications.ModesDescription" = "#### Режимы уведомлений\n\n#### Отключены\nНе присылать никаких уведомлений.\n\n#### Фоновое обновление\nПроверка новых сообщений производится самим устройством. Проверку инициирует iOS, и интервалы между проверками определяются системой на основании множества факторов, таких как доступность сотовой сети, заряд батареи, и использование приложения. Интервалы могут быть 20 минут, могут быть 6 часов, могут доходить и до нескольких дней. Однако, вы всегда можете открыть приложение и проверить новые сообщения вручную.\n\n#### Push\nУведомления о новых сообщениях присылаются сервисом ADAMANT Notification Service. Уведомления приходят практически мгновенно (несколько секунд), но необходима регистрация устройства в сервисе. Это безопасно и сохраняет высокий уровень секретности, для большинства пользователей это предпочтительный вариант.\n\nО регистрации устройств вы можете прочитать больше на странице проекта в Github.\n\n"; @@ -874,6 +919,15 @@ /* Partner screen 'Rename contact'. */ "Shared.RenameContact" = "Переименовать"; +/* Shared alert 'Send tokens' */ +"Shared.SendTokens" = "💸 Перевести крипту"; + +/* Shared alert 'Upload file' */ +"Shared.UploadFile" = "📄 Отправить файл"; + +/* Shared alert 'Upload Media' */ +"Shared.UploadMedia" = "📸 Отправить медиа"; + /* Shared alert 'Settings' button. Used to go to system Settings app, on application settings page. Should be same as Settings application title. */ "Shared.Settings" = "Настройки"; @@ -1215,3 +1269,32 @@ /* Transaction: Unknown token */ "Transaction.UnknownTokenTitle" = "Unknown"; +/* FileManager error 'Can't download file' */ +"FileManagerError.CantDownloadFile" = "Не удалось загрузить файл"; + +/* FileManager error 'Can't upload file' */ +"FileManagerError.CantUploadFile" = "Не удалось загрузить файл на сервер"; + +/* FileManager error 'Can't encrypt file' */ +"FileManagerError.CantEncryptFile" = "Не удалось зашифровать файл"; + +/* FileManager error 'Can't decrypt file' */ +"FileManagerError.CantDecryptFile" = "Не удалось расшифровать файл"; + +/* File validation error 'Too many files' */ +"FileValidationError.TooManyFiles" = "Превышено максимально допустимое количество файлов (%lld)"; + +/* File validation error 'File size exceeds limit' */ +"FileValidationError.FileSizeExceedsLimit" = "Превышен размер файла. Отправляйте файлы до %lld МБ"; + +/* File validation error 'File not found' */ +"FileValidationError.FileNotFound" = "Файл не найден"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "Невозможно выбрать файл: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Перетащите файлы сюда"; + +/* Chat unknown */ +"Chat.unknown.title" = "Неизвестный"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 63fafa539..005f1d6fc 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -682,6 +682,51 @@ /* NodeList: Inform that default nodes was loaded, if user deleted all nodes */ "NodeList.DefaultNodesLoaded" = "已加载默认节点列表"; +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Title" = "保持文件加密"; + +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Description" = " 在本地存储中以加密形式存储文件(可能会影响性能)"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "预览"; + +/* Storage: Auto download full media */ +"Storage.AutoDownloadFullMedia.Title" = "完整媒体文件"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Everybody.Title" = "每个人"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Nobody.Title" = "没有人"; + +/* Storage: Download policy */ +"Storage.DownloadPolicy.Contacts.Title" = "我的联系人"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "要创建联系人,请为ADM地址命名"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Header" = "自动下载媒体文件"; + +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "清晰的存储"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "您将再次下载文件和图像。继续?"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "存储和数据"; + +/* Storage used: Title */ +"StorageUsed.Title" = "存储"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "应用程序安全存储中的文件和图像总大小"; + +/* Chats: Auto preview download is disabled */ +"Chats.AutoDownloadPreview.Disabled" = "自动预览下载已禁用"; + /* CoinsNodesList: Title */ "CoinsNodesList.Title" = "Coin和服务节点列表"; @@ -877,6 +922,15 @@ /* Partner screen 'Rename contact'. */ "Shared.RenameContact" = "重新命名联系人"; +/* Shared alert 'Send tokens' */ +"Shared.SendTokens" = "💸 发送代币"; + +/* Shared alert 'Upload file' */ +"Shared.UploadFile" = "📄 上传文件"; + +/* Shared alert 'Upload Media' */ +"Shared.UploadMedia" = "📸 上传媒体"; + /* Shared alert 'Settings' button. Used to go to system Settings app, on application settings page. Should be same as Settings application title. */ "Shared.Settings" = "设置"; @@ -1215,3 +1269,33 @@ /* Transaction: Unknown token */ "Transaction.UnknownTokenTitle" = "未知"; + +/* FileManager error 'Can't download file' */ +"FileManagerError.CantDownloadFile" = "无法下载文件"; + +/* FileManager error 'Can't upload file' */ +"FileManagerError.CantUploadFile" = "无法上传文件"; + +/* FileManager error 'Can't encrypt file' */ +"FileManagerError.CantEncryptFile" = "无法加密文件"; + +/* FileManager error 'Can't decrypt file' */ +"FileManagerError.CantDecryptFile" = "无法解密文件"; + +/* File validation error 'Too many files' */ +"FileValidationError.TooManyFiles" = "文件太多。 最大允许:%lld"; + +/* File validation error 'File size exceeds limit' */ +"FileValidationError.FileSizeExceedsLimit" = "文件大小超出限制。 最大允许:%lld MB"; + +/* File validation error 'File not found' */ +"FileValidationError.FileNotFound" = "找不到文件"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "无法选择文件: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "将文件拖到这里"; + +/* Chat unknown */ +"Chat.unknown.title" = "未知"; diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/Contents.json new file mode 100644 index 000000000..90447ae7b --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "preview.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "preview@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "preview@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview.png new file mode 100644 index 000000000..355cd0192 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@2x.png new file mode 100644 index 000000000..d98a81568 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@3x.png new file mode 100644 index 000000000..71362a7f7 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/Contents.json new file mode 100644 index 000000000..541d9ee6c --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "storage_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "storage_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "storage_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon.png new file mode 100644 index 000000000..45f9033a0 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@2x.png new file mode 100644 index 000000000..c53a7e61b Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@3x.png new file mode 100644 index 000000000..3970c1abf Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/Contents.json new file mode 100644 index 000000000..7ceeaa1fa --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "checkMarkIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "checkMarkIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "checkMarkIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon.png new file mode 100644 index 000000000..cd1504001 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@2x.png new file mode 100644 index 000000000..21c924d09 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@3x.png new file mode 100644 index 000000000..02c355000 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/Contents.json new file mode 100644 index 000000000..4b509890e --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "file-default-box.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/file-default-box.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/file-default-box.png new file mode 100644 index 000000000..8d219f701 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/file-default-box.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/Contents.json new file mode 100644 index 000000000..e11c06bc2 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images" : [ + { + "filename" : "light-blue-modern-elegant-background-vector.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "image-4.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/image-4.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/image-4.jpg new file mode 100644 index 000000000..2b98adb5f Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/image-4.jpg differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/light-blue-modern-elegant-background-vector.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/light-blue-modern-elegant-background-vector.jpg new file mode 100644 index 000000000..6cba28b29 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/light-blue-modern-elegant-background-vector.jpg differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/Contents.json new file mode 100644 index 000000000..aaf58dd66 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "download-circular-error.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "download-circular-error-dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "download-circular-error@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "download-circular-error-dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "download-circular-error@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "download-circular-error-dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark.png new file mode 100644 index 000000000..da7e29780 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@2x.png new file mode 100644 index 000000000..ec02d6a23 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@3x.png new file mode 100644 index 000000000..95d55d42f Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error.png new file mode 100644 index 000000000..9a8e982e2 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@2x.png new file mode 100644 index 000000000..5bb1a0b99 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@3x.png new file mode 100644 index 000000000..338a09ac1 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/Contents.json new file mode 100644 index 000000000..042f69fff --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "download-circular.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "download-circular@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "download-circular@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular.png new file mode 100644 index 000000000..c732f085b Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@2x.png new file mode 100644 index 000000000..c2f520330 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@3x.png new file mode 100644 index 000000000..ba90b0f2a Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/Contents.json new file mode 100644 index 000000000..a066d942c --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "downloadIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "downloadIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "downloadIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon.png new file mode 100644 index 000000000..cb622db70 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@2x.png new file mode 100644 index 000000000..a5c1fa19d Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@3x.png new file mode 100644 index 000000000..2c9cda119 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/Contents.json new file mode 100644 index 000000000..5e8671390 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "playVideoIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "playVideoIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "playVideoIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon.png new file mode 100644 index 000000000..25f68d69f Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@2x.png new file mode 100644 index 000000000..a812ce5f8 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@2x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@3x.png new file mode 100644 index 000000000..be5dac1e7 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@3x.png differ diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/Contents.json new file mode 100644 index 000000000..1cdd539ee --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "cloud-computing.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/cloud-computing.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/cloud-computing.png new file mode 100644 index 000000000..9c3f420d0 Binary files /dev/null and b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/cloud-computing.png differ diff --git a/CommonKit/Sources/CommonKit/Core/NativeAdamantCore.swift b/CommonKit/Sources/CommonKit/Core/NativeAdamantCore.swift index ba37eb7ca..a3e75721c 100644 --- a/CommonKit/Sources/CommonKit/Core/NativeAdamantCore.swift +++ b/CommonKit/Sources/CommonKit/Core/NativeAdamantCore.swift @@ -67,6 +67,64 @@ public final class NativeAdamantCore { return decrepted.utf8String } + public func encodeData( + _ data: Data, + recipientPublicKey publicKey: String, + privateKey privateKeyHex: String + ) -> (data: Data, nonce: String)? { + let message = data.bytes + let recipientKey = publicKey.hexBytes() + let privateKey = privateKeyHex.hexBytes() + + guard let publicKey = Crypto.ed2Curve.publicKey(recipientKey) else { + print("FAIL to create ed2curve publick key from SHA256") + return nil + } + + guard let secretKey = Crypto.ed2Curve.privateKey(privateKey) else { + print("FAIL to create ed2curve secret key from SHA256") + return nil + } + + guard let encrypted = Crypto.box.seal(message: message, recipientPublicKey: publicKey, senderSecretKey: secretKey) else { + print("FAIL to encrypt") + return nil + } + + let encryptedData = encrypted.authenticatedCipherText.toData() + let nonce = encrypted.nonce.hexString() + + return (data: encryptedData, nonce: nonce) + } + + public func decodeData( + _ data: Data, + rawNonce: String, + senderPublicKey senderKeyHex: String, + privateKey privateKeyHex: String + ) -> Data? { + let message = data.bytes + let nonce = rawNonce.hexBytes() + let senderKey = senderKeyHex.hexBytes() + let privateKey = privateKeyHex.hexBytes() + + guard let publicKey = Crypto.ed2Curve.publicKey(senderKey) else { + print("FAIL to create ed2curve publick key from SHA256") + return nil + } + + guard let secretKey = Crypto.ed2Curve.privateKey(privateKey) else { + print("FAIL to create ed2curve secret key from SHA256") + return nil + } + + guard let decrepted = Crypto.box.open(authenticatedCipherText: message, senderPublicKey: publicKey, recipientSecretKey: secretKey, nonce: nonce) else { + print("FAIL to decrypt") + return nil + } + + return decrepted.toData() + } // MARK: - Values public func encodeValue(_ value: [String: Any], privateKey privateKeyHex: String) -> (message: String, nonce: String)? { diff --git a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift index 8a7a3d920..7d40ff833 100644 --- a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift +++ b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift @@ -56,6 +56,12 @@ public extension StoreKey { public static let language = "language" public static let languageLocale = "language.locale" } + + enum storage { + public static let autoDownloadPreview = "autoDownloadPreviewEnabled" + public static let autoDownloadFullMedia = "autoDownloadFullMediaEnabled" + public static let saveFileEncrypted = "saveFileEncrypted" + } } public protocol SecuredStore: AnyObject { diff --git a/CommonKit/Sources/CommonKit/Helpers/Array+adamant.swift b/CommonKit/Sources/CommonKit/Helpers/Array+adamant.swift new file mode 100644 index 000000000..df45b552d --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/Array+adamant.swift @@ -0,0 +1,16 @@ +// +// Array+adamant.swift +// +// +// Created by Stanislav Jelezoglo on 21.03.2024. +// + +import Foundation + +public extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift new file mode 100644 index 000000000..1d7f56bd7 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -0,0 +1,57 @@ +// +// FilePresentationHelper.swift +// +// +// Created by Stanislav Jelezoglo on 26.04.2024. +// + +import Foundation + +public class FilePresentationHelper { + public static func getFilePresentationText( + mediaFilesCount: Int, + otherFilesCount: Int, + comment: String + ) -> String { + let mediaCountText = mediaFilesCount > 1 + ? "\(mediaFilesCount)" + : .empty + + let otherFilesCountText = otherFilesCount > 1 + ? "\(otherFilesCount)" + : .empty + + let mediaText = mediaFilesCount > 0 ? "📸\(mediaCountText)" : .empty + let fileText = otherFilesCount > 0 ? "📄\(otherFilesCountText)" : .empty + + let text = [mediaText, fileText, comment].filter { + !$0.isEmpty + }.joined(separator: " ") + + return text + } + + public static func getFilePresentationText(_ richContent: [String: Any]) -> String { + let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent + + let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] + + let mediaFilesCount = files.filter { file in + let mimeType = file[RichContentKeys.file.mimeType] as? String ?? .empty + let fileType = FileType(mimeType: mimeType) ?? .other + return fileType == .image || fileType == .video + }.count + + let otherFilesCount = files.count - mediaFilesCount + + let comment = (content[RichContentKeys.file.comment] as? String).flatMap { + $0.isEmpty ? nil : $0 + } ?? .empty + + return Self.getFilePresentationText( + mediaFilesCount: mediaFilesCount, + otherFilesCount: otherFilesCount, + comment: comment + ) + } +} diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift new file mode 100644 index 000000000..42c74b911 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -0,0 +1,17 @@ +// +// FilesConstants.swift +// +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// + +import Foundation + +public final class FilesConstants { + public static let maxFilesCount = 10 + public static let maxFileSize: Int64 = 250 * 1024 * 1024 + public static let previewSize: CGSize = .init(squareSize: 400) + public static let previewVideoSize: CGSize = .init(squareSize: 700) + public static let previewTag: String = "preview_" + public static let previewCompressQuality: CGFloat = 0.8 +} diff --git a/Adamant/Helpers/MacOSDeterminer.swift b/CommonKit/Sources/CommonKit/Helpers/MacOSDeterminer.swift similarity index 55% rename from Adamant/Helpers/MacOSDeterminer.swift rename to CommonKit/Sources/CommonKit/Helpers/MacOSDeterminer.swift index bd12338e7..4cc529b99 100644 --- a/Adamant/Helpers/MacOSDeterminer.swift +++ b/CommonKit/Sources/CommonKit/Helpers/MacOSDeterminer.swift @@ -1,14 +1,13 @@ // // MacOSDeterminer.swift -// Adamant // -// Created by Stanislav Jelezoglo on 08.07.2022. -// Copyright © 2022 Adamant. All rights reserved. +// +// Created by Stanislav Jelezoglo on 14.03.2024. // import Foundation -var isMacOS: Bool = { +public var isMacOS: Bool = { #if targetEnvironment(macCatalyst) true #else diff --git a/CommonKit/Sources/CommonKit/Helpers/MessageProcessHelper.swift b/CommonKit/Sources/CommonKit/Helpers/MessageProcessHelper.swift index cf1104cf4..d2251564b 100644 --- a/CommonKit/Sources/CommonKit/Helpers/MessageProcessHelper.swift +++ b/CommonKit/Sources/CommonKit/Helpers/MessageProcessHelper.swift @@ -5,7 +5,6 @@ // Created by Yana Silosieva on 29.04.2024. // -import Foundation import UIKit public final class MessageProcessHelper { diff --git a/CommonKit/Sources/CommonKit/Helpers/UIHelpers/CGSize+adamant.swift b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/CGSize+adamant.swift index 22939b8ba..bed51f6b8 100644 --- a/CommonKit/Sources/CommonKit/Helpers/UIHelpers/CGSize+adamant.swift +++ b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/CGSize+adamant.swift @@ -13,3 +13,10 @@ public extension CGSize { self.init(width: squareSize, height: squareSize) } } + +extension CGSize: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(width) + hasher.combine(height) + } +} diff --git a/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIColor+hex.swift b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIColor+hex.swift index d361748bc..834c60dc8 100644 --- a/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIColor+hex.swift +++ b/CommonKit/Sources/CommonKit/Helpers/UIHelpers/UIColor+hex.swift @@ -6,7 +6,6 @@ // Copyright © 2018 Adamant. All rights reserved. // -import Foundation import UIKit public extension UIColor { diff --git a/CommonKit/Sources/CommonKit/Helpers/UIImage+adamant.swift b/CommonKit/Sources/CommonKit/Helpers/UIImage+adamant.swift index 072dc5b41..88308c55d 100644 --- a/CommonKit/Sources/CommonKit/Helpers/UIImage+adamant.swift +++ b/CommonKit/Sources/CommonKit/Helpers/UIImage+adamant.swift @@ -19,3 +19,7 @@ public extension UIImage { } } } + +public func getLocalImageUrl(by name: String, withExtension ext: String) -> URL? { + Bundle.module.url(forResource: name, withExtension: ext) +} diff --git a/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift b/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift index e2e9bdf77..7e310afcb 100644 --- a/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift +++ b/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift @@ -11,6 +11,17 @@ import Foundation public extension String { enum adamant {} + static func locale() -> Locale { + guard let languageRaw = UserDefaults.standard.string(forKey: StoreKey.language.language), + !languageRaw.isEmpty, + languageRaw != Language.auto.rawValue + else { + return .current + } + + return Locale(identifier: languageRaw) + } + static func localized(_ key: String, comment: String = .empty) -> String { guard let languageRaw = UserDefaults.standard.string(forKey: StoreKey.language.language), !languageRaw.isEmpty, diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift new file mode 100644 index 000000000..7279ac4b0 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -0,0 +1,91 @@ +// +// FileResult.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import UIKit + +public enum FileType { + case image + case video + case other + + public var isMedia: Bool { + switch self { + case .image, .video: + return true + case .other: + return false + } + } +} + +public extension FileType { + init?(mimeType: String) { + if mimeType.hasPrefix("image/") { + self = .image + } else if mimeType.hasPrefix("video/") { + self = .video + } else { + self = .other + } + } + + init?(raw: String) { + switch raw.uppercased() { + case "JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2": + self = .image + case "MOV", "MP4": + self = .video + default: self = .other + } + } +} + +public struct FileResult { + public let assetId: String? + public let url: URL + public let type: FileType + public let previewUrl: URL? + public let preview: UIImage? + public let previewExtension: String? + public let size: Int64 + public let name: String? + public let extenstion: String? + public let resolution: CGSize? + public let data: Data? + public let duration: Float64? + public let mimeType: String? + + public init( + assetId: String? = nil, + url: URL, + type: FileType, + preview: UIImage?, + previewUrl: URL?, + previewExtension: String?, + size: Int64, + name: String?, + extenstion: String?, + resolution: CGSize?, + data: Data? = nil, + duration: Float64? = nil, + mimeType: String? = nil + ) { + self.assetId = assetId + self.url = url + self.type = type + self.previewUrl = previewUrl + self.previewExtension = previewExtension + self.size = size + self.name = name + self.extenstion = extenstion + self.preview = preview + self.resolution = resolution + self.data = data + self.duration = duration + self.mimeType = mimeType + } +} diff --git a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift new file mode 100644 index 000000000..68b439014 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift @@ -0,0 +1,52 @@ +// +// FileValidationError.swift +// +// +// Created by Stanislav Jelezoglo on 11.04.2024. +// + +import Foundation + +public enum FilePickersError: Error { + case cantSelectFile(String) +} + +extension FilePickersError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .cantSelectFile(name): + return String.localizedStringWithFormat(.localized( + "FileValidationError.CantSelectFile", + comment: "File picker error 'Cant select file'" + ), name) + } + } +} + +public enum FileValidationError: Error { + case tooManyFiles + case fileSizeExceedsLimit + case fileNotFound + case unknownError(Error) +} + +extension FileValidationError: LocalizedError { + public var errorDescription: String? { + switch self { + case .tooManyFiles: + return String.localizedStringWithFormat(.localized( + "FileValidationError.TooManyFiles", + comment: "File validation error 'Too many files'" + ), FilesConstants.maxFilesCount) + case .fileSizeExceedsLimit: + return String.localizedStringWithFormat(.localized( + "FileValidationError.FileSizeExceedsLimit", + comment: "File validation error 'File size exceeds limit'" + ), Int(FilesConstants.maxFileSize / (1024 * 1024))) + case .fileNotFound: + return .localized("FileValidationError.FileNotFound") + case let .unknownError(error): + return error.localizedDescription + } + } +} diff --git a/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift b/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift index a5113d3f4..739e258fb 100644 --- a/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift +++ b/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift @@ -12,7 +12,7 @@ public extension NodeGroup { switch self { case .adm: return false - case .eth, .doge, .dash, .btc, .klyNode, .klyService: + case .eth, .doge, .dash, .btc, .klyNode, .klyService, .ipfs: return true } } diff --git a/CommonKit/Sources/CommonKit/Models/NodeGroup.swift b/CommonKit/Sources/CommonKit/Models/NodeGroup.swift index e76fc8001..8d3fe5dae 100644 --- a/CommonKit/Sources/CommonKit/Models/NodeGroup.swift +++ b/CommonKit/Sources/CommonKit/Models/NodeGroup.swift @@ -13,4 +13,5 @@ public enum NodeGroup: Codable, CaseIterable, Hashable { case doge case dash case adm + case ipfs } diff --git a/CommonKit/Sources/CommonKit/Models/RichAdditionalType.swift b/CommonKit/Sources/CommonKit/Models/RichAdditionalType.swift index 8a0a3cb9b..678fbecd5 100644 --- a/CommonKit/Sources/CommonKit/Models/RichAdditionalType.swift +++ b/CommonKit/Sources/CommonKit/Models/RichAdditionalType.swift @@ -11,4 +11,5 @@ import Foundation case reply = 0 case reaction = 1 case base = 2 + case file = 3 } diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index 088256378..b69e5e62b 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -45,6 +45,24 @@ public enum RichContentKeys { public static let react_message = "react_message" public static let reactions = "reactions" } + + public enum file { + public static let file = "file" + public static let files = "files" + public static let file_id = "file_id" + public static let comment = "comment" + public static let storage = "storage" + public static let nonce = "nonce" + public static let resolution = "resolution" + public static let id = "id" + public static let size = "size" + public static let type = "type" + public static let name = "name" + public static let preview = "preview" + public static let `extension` = "extension" + public static let duration = "duration" + public static let mimeType = "mimeType" + } } // MARK: - RichMessageReaction @@ -74,6 +92,219 @@ public struct RichMessageReaction: RichMessage { } } +// MARK: - RichMessageFile + +public struct RichMessageFile: RichMessage { + public struct Preview: Codable, Equatable, Hashable { + public var id: String + public var nonce: String + public var `extension`: String? + + public init( + id: String, + nonce: String, + extension: String? + ) { + self.id = id + self.nonce = nonce + self.extension = `extension` + } + + public init(_ data: [String: Any]) { + self.id = (data[RichContentKeys.file.id] as? String) ?? .empty + self.nonce = data[RichContentKeys.file.nonce] as? String ?? .empty + self.extension = data[RichContentKeys.file.extension] as? String ?? .empty + } + + public func content() -> [String: Any] { + var contentDict: [String : Any] = [:] + + if !id.isEmpty { + contentDict[RichContentKeys.file.id] = id + } + + if !nonce.isEmpty { + contentDict[RichContentKeys.file.nonce] = nonce + } + + if !nonce.isEmpty { + contentDict[RichContentKeys.file.extension] = `extension` + } + + return contentDict + } + } + + public struct File: Codable, Equatable, Hashable { + public var preview: Preview? + public var id: String + public var `extension`: String? + public var mimeType: String? + public var size: Int64 + public var nonce: String + public var resolution: CGSize? + public var name: String? + public var duration: Float64? + + public init( + id: String, + size: Int64, + nonce: String, + name: String?, + `extension`: String? = nil, + mimeType: String? = nil, + preview: Preview? = nil, + resolution: CGSize? = nil, + duration: Float64? = nil + ) { + self.id = id + self.extension = `extension` + self.mimeType = mimeType + self.size = size + self.nonce = nonce + self.name = name + self.preview = preview + self.resolution = resolution + self.duration = duration + } + + public init(_ data: [String: Any]) { + self.id = (data[RichContentKeys.file.id] as? String) ?? .empty + self.`extension` = data[RichContentKeys.file.extension] as? String + ?? data[RichContentKeys.file.type] as? String + self.size = (data[RichContentKeys.file.size] as? Int64) ?? .zero + self.name = data[RichContentKeys.file.name] as? String + self.nonce = data[RichContentKeys.file.nonce] as? String ?? .empty + self.duration = data[RichContentKeys.file.duration] as? Float64 + self.mimeType = data[RichContentKeys.file.mimeType] as? String + + if let previewData = data[RichContentKeys.file.preview] as? [String: Any] { + self.preview = Preview(previewData) + } + + if let resolution = data[RichContentKeys.file.resolution] as? [CGFloat] { + self.resolution = .init( + width: resolution.first ?? .zero, + height: resolution.last ?? .zero + ) + } else if let resolution = data[RichContentKeys.file.resolution] as? CGSize { + self.resolution = resolution + } else { + self.resolution = nil + } + } + + public func content() -> [String: Any] { + var contentDict: [String : Any] = [ + RichContentKeys.file.id: id, + RichContentKeys.file.size: size, + RichContentKeys.file.nonce: nonce + ] + + if let value = `extension`, !value.isEmpty { + contentDict[RichContentKeys.file.extension] = value + } + + if let preview = preview { + contentDict[RichContentKeys.file.preview] = preview.content() + } + + if let name = name, !name.isEmpty { + contentDict[RichContentKeys.file.name] = name + } + + if let resolution = resolution { + contentDict[RichContentKeys.file.resolution] = resolution + } + + if let duration = duration { + contentDict[RichContentKeys.file.duration] = duration + } + + if let mimeType = mimeType { + contentDict[RichContentKeys.file.mimeType] = mimeType + } + + return contentDict + } + } + + public struct Storage: Codable, Equatable, Hashable { + public var id: String + + public init(id: String) { + self.id = id + } + + public init(_ data: [String: Any]) { + self.id = (data[RichContentKeys.file.id] as? String) ?? .empty + } + + public func content() -> [String: Any] { + let contentDict: [String : Any] = [ + RichContentKeys.file.id: id + ] + + return contentDict + } + } + + public var type: String + public var additionalType: RichAdditionalType + public var files: [File] + public var storage: Storage + public var comment: String? + + public enum CodingKeys: String, CodingKey { + case files, storage, comment + } + + public init(files: [File], storage: Storage, comment: String?) { + self.type = RichContentKeys.file.file + self.files = files + self.storage = storage + self.comment = comment + self.additionalType = .file + } + + public func content() -> [String: Any] { + var contentDict: [String : Any] = [ + RichContentKeys.file.files: files.map { $0.content() }, + RichContentKeys.file.storage: storage.content() + ] + + if let comment = comment, !comment.isEmpty { + contentDict[RichContentKeys.file.comment] = comment + } + return contentDict + } +} + +public struct RichFileReply: RichMessage { + public var type: String + public var additionalType: RichAdditionalType + public var replyto_id: String + public var reply_message: RichMessageFile + + public enum CodingKeys: String, CodingKey { + case replyto_id, reply_message + } + + public init(replyto_id: String, reply_message: RichMessageFile) { + self.type = RichContentKeys.reply.reply + self.replyto_id = replyto_id + self.reply_message = reply_message + self.additionalType = .reply + } + + public func content() -> [String: Any] { + return [ + RichContentKeys.reply.replyToId: replyto_id, + RichContentKeys.reply.replyMessage: reply_message.content() + ] + } +} + // MARK: - RichMessageReply public struct RichMessageReply: RichMessage { diff --git a/FilesPickerKit/.gitignore b/FilesPickerKit/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FilesPickerKit/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/FilesPickerKit/Package.swift b/FilesPickerKit/Package.swift new file mode 100644 index 000000000..a6e60c84c --- /dev/null +++ b/FilesPickerKit/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "FilesPickerKit", + platforms: [ + .iOS(.v15) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "FilesPickerKit", + targets: ["FilesPickerKit"]), + ], + dependencies: [ + .package(path: "../CommonKit"), + .package(path: "../FilesStorageKit") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "FilesPickerKit", + dependencies: ["CommonKit", "FilesStorageKit"] + ), + .testTarget( + name: "FilesPickerKitTests", + dependencies: ["FilesPickerKit"]), + ] +) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift new file mode 100644 index 000000000..ec1e77081 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -0,0 +1,294 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit +import SwiftUI +import AVFoundation +import QuickLook +import FilesStorageKit + +public final class FilesPickerKit: FilesPickerProtocol { + private let storageKit: FilesStorageProtocol + public var previewExtension: String { "jpeg" } + + public init(storageKit: FilesStorageProtocol) { + self.storageKit = storageKit + } + + public func getFileSize(from url: URL) throws -> Int64 { + try storageKit.getFileSize(from: url).get() + } + + public func getUrl(for image: UIImage?, name: String) throws -> URL { + try storageKit.getTempUrl(for: image, name: name) + } + + public func validateFiles(_ files: [FileResult]) throws { + guard files.count <= FilesConstants.maxFilesCount else { + throw FileValidationError.tooManyFiles + } + + for file in files { + guard file.size <= FilesConstants.maxFileSize else { + throw FileValidationError.fileSizeExceedsLimit + } + } + } + + public func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let newSize = getPreviewSize( + from: image.size, + previewSize: FilesConstants.previewSize + ) + + return image.imageResized(to: newSize) + } + + public func getOriginalSize(for url: URL) -> CGSize? { + guard let track = AVURLAsset(url: url).tracks( + withMediaType: AVMediaType.video + ).first + else { return nil } + + let naturalSize = track.naturalSize.applying(track.preferredTransform) + + return .init(width: abs(naturalSize.width), height: abs(naturalSize.height)) + } + + public func getThumbnailImage( + forUrl url: URL, + originalSize: CGSize? + ) async throws -> UIImage? { + var thumbnailSize: CGSize? + + if let size = originalSize { + thumbnailSize = getPreviewSize( + from: size, + previewSize: FilesConstants.previewVideoSize + ) + } + + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: thumbnailSize ?? FilesConstants.previewVideoSize, + scale: 1.0, + representationTypes: .thumbnail + ) + + let image = try await QLThumbnailGenerator.shared.generateBestRepresentation( + for: request + ).uiImage + + return image + } + + public func getFileResult(for url: URL) throws -> FileResult { + try createFileResult( + from: url, + name: url.lastPathComponent, + extension: url.pathExtension + ) + } + + public func getFileResult(for image: UIImage) throws -> FileResult { + let fileName = "\(imagePrefix)\(String.random(length: 4)).\(previewExtension)" + + let newUrl = try storageKit.getTempUrl(for: image, name: fileName) + + return try createFileResult( + from: newUrl, + name: fileName, + extension: previewExtension + ) + } + + @MainActor + public func getUrlConforms( + to type: UTType, + for itemProvider: NSItemProvider + ) async throws -> URL { + for identifier in itemProvider.registeredTypeIdentifiers { + guard let utType = UTType(identifier), utType.conforms(to: type) else { + continue + } + + do { + return try await getFileURL(by: identifier, itemProvider: itemProvider) + } catch { + continue + } + } + + throw FilePickersError.cantSelectFile(itemProvider.suggestedName ?? .empty) + } + + @MainActor + public func getUrl(for itemProvider: NSItemProvider) async throws -> URL { + for type in itemProvider.registeredTypeIdentifiers { + do { + return try await getFileURL(by: type, itemProvider: itemProvider) + } catch { + continue + } + } + + throw FileValidationError.fileNotFound + } + + @MainActor + public func getFileURL( + by type: String, + itemProvider: NSItemProvider + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: type) { [weak self] url, error in + if let error = error { + continuation.resume(throwing: error) + } else if let url = url { + if let targetURL = try? self?.storageKit.copyFileToTempCache(from: url) { + continuation.resume(returning: targetURL) + } else { + continuation.resume(throwing: FileValidationError.fileNotFound) + } + } else { + continuation.resume(throwing: FileValidationError.fileNotFound) + } + } + } + } + + public func getVideoDuration(from url: URL) -> Float64? { + guard isFileType(format: .movie, atURL: url) else { return nil } + + let asset = AVAsset(url: url) + + let duration = asset.duration + let durationTime = CMTimeGetSeconds(duration) + + return durationTime + } + + public func getMimeType(for url: URL) -> String? { + var mimeType: String? + + let pathExtension = url.pathExtension + if let type = UTType(filenameExtension: pathExtension) { + mimeType = type.preferredMIMEType + } + + return mimeType + } +} + +private extension FilesPickerKit { + func createFileResult( + from url: URL, + name: String, + extension: String + ) throws -> FileResult { + let newUrl = try storageKit.copyFileToTempCache(from: url) + let preview = getPreview(for: newUrl) + let fileSize = try storageKit.getFileSize(from: newUrl).get() + let duration = getVideoDuration(from: newUrl) + let mimeType = getMimeType(for: newUrl) + + return FileResult( + assetId: url.absoluteString, + url: newUrl, + type: .other, + preview: preview.image, + previewUrl: preview.url, + previewExtension: previewExtension, + size: fileSize, + name: name, + extenstion: `extension`, + resolution: preview.resolution, + duration: duration, + mimeType: mimeType + ) + } + + func getPreviewSize( + from originalSize: CGSize?, + previewSize: CGSize + ) -> CGSize { + guard let size = originalSize else { return FilesConstants.previewSize } + + let width = abs(size.width) + let height = abs(size.height) + + let widthRatio = previewSize.width / width + let heightRatio = previewSize.height / height + + var newSize: CGSize + if(widthRatio > heightRatio) { + newSize = CGSize( + width: width * heightRatio, + height: height * heightRatio + ) + } else { + newSize = CGSize( + width: width * widthRatio, + height: height * widthRatio + ) + } + + return newSize + } + + func isFileType(format: UTType, atURL fileURL: URL) -> Bool { + guard let mimeType = getMimeType(for: fileURL) else { return false } + + return UTType(mimeType: mimeType)?.conforms(to: format) ?? false + } + + func getPreview(for url: URL) -> (image: UIImage?, url: URL?, resolution: CGSize?) { + defer { + url.stopAccessingSecurityScopedResource() + } + + _ = url.startAccessingSecurityScopedResource() + + var image: UIImage? + + if isFileType(format: .image, atURL: url) { + image = UIImage(contentsOfFile: url.path) + } + + if isFileType(format: .movie, atURL: url) { + image = getThumbnailImage(forUrl: url) + } + + guard let image = image else { + return (image: nil, url: nil, resolution: nil) + } + + let resizedImage = resizeImage( + image: image, + targetSize: FilesConstants.previewSize + ) + let imageURL = try? storageKit.getTempUrl( + for: resizedImage, + name: FilesConstants.previewTag + url.lastPathComponent + ) + + return (image: resizedImage, url: imageURL, resolution: image.size) + } + + func getThumbnailImage(forUrl url: URL) -> UIImage? { + let asset: AVAsset = AVAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + + do { + let thumbnailImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil) + + let image = UIImage(cgImage: thumbnailImage) + return image + } catch { + return nil + } + } +} + +private let imagePrefix = "image" diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift new file mode 100644 index 000000000..d310fed80 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -0,0 +1,76 @@ +// +// DocumentInteractionService.swift +// +// +// Created by Stanislav Jelezoglo on 14.03.2024. +// + +import UIKit +import CommonKit +import SwiftUI +import WebKit +import QuickLook + +public final class DocumentInteractionService: NSObject { + private var urls: [URL] = [] + private var needToDelete = false + + public func openFile(files: [FileResult]) { + self.urls = [] + files.forEach { file in + let name = file.name ?? "UNKWNOW" + let ext = file.extenstion ?? "" + + let fullName = name.contains(ext) + ? name + : "\(name).\(ext)" + + var copyURL = URL(fileURLWithPath: file.url.deletingLastPathComponent().path) + copyURL.appendPathComponent(fullName) + + if FileManager.default.fileExists(atPath: copyURL.path) { + try? FileManager.default.removeItem(at: copyURL) + } + + if let data = file.data { + try? data.write(to: copyURL, options: [.atomic, .completeFileProtection]) + } else { + try? FileManager.default.copyItem(at: file.url, to: copyURL) + } + + self.urls.append(copyURL) + } + + needToDelete = true + } + + public func openFile(url: URL) { + self.urls = [url] + needToDelete = false + } +} + +extension DocumentInteractionService: QLPreviewControllerDelegate, QLPreviewControllerDataSource { + public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + urls.count + } + + public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + QLPreviewItemEq(url: urls[index]) + } + + public func previewControllerDidDismiss(_ controller: QLPreviewController) { + guard needToDelete else { return } + urls.forEach { url in + try? FileManager.default.removeItem(at: url) + } + } +} + +final class QLPreviewItemEq: NSObject, QLPreviewItem { + let previewItemURL: URL? + + init(url: URL) { + previewItemURL = url + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift new file mode 100644 index 000000000..afca35821 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -0,0 +1,41 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// + +import UIKit +import CommonKit +import MobileCoreServices +import AVFoundation + +public final class DocumentPickerService: NSObject, FilePickerServiceProtocol { + private var helper: FilesPickerProtocol + + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } +} + +extension DocumentPickerService: UIDocumentPickerDelegate { + public func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + let files = urls.compactMap { + try? helper.getFileResult(for: $0) + } + + do { + try helper.validateFiles(files) + onPreparedDataCallback?(.success(files)) + } catch { + onPreparedDataCallback?(.failure(error)) + } + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift new file mode 100644 index 000000000..b7485e10b --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift @@ -0,0 +1,90 @@ +// +// DropInteractionService.swift +// +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// + +import Foundation +import CommonKit +import UIKit +import UniformTypeIdentifiers + +@MainActor +public final class DropInteractionService: NSObject, FilePickerServiceProtocol { + private var helper: FilesPickerProtocol + + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onSessionCallback: ((Bool) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } +} + +extension DropInteractionService: UIDropInteractionDelegate { + public func dropInteraction( + _ interaction: UIDropInteraction, + canHandle session: UIDropSession + ) -> Bool { + true + } + + public func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidEnter session: UIDropSession + ) { + onSessionCallback?(true) + } + + public func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidExit session: UIDropSession + ) { + onSessionCallback?(false) + } + + public func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidUpdate session: UIDropSession + ) -> UIDropProposal { + UIDropProposal(operation: .copy) + } + + public func dropInteraction( + _ interaction: UIDropInteraction, + performDrop session: UIDropSession + ) { + onPreparingDataCallback?() + + let providers = session.items.map { $0.itemProvider } + Task { + await process(itemProviders: providers) + } + } +} + +private extension DropInteractionService { + func process(itemProviders: [NSItemProvider]) async { + var files: [FileResult] = [] + + for itemProvider in itemProviders { + guard let url = try? await helper.getUrl(for: itemProvider), + let file = try? helper.getFileResult(for: url) + else { + continue + } + + files.append(file) + } + + do { + try helper.validateFiles(files) + onPreparedDataCallback?(.success(files)) + } catch { + onPreparedDataCallback?(.failure(error)) + } + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift new file mode 100644 index 000000000..73f30ab1d --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -0,0 +1,166 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 11.02.2024. +// + +import CommonKit +import UIKit +import Photos +import PhotosUI + +@MainActor +public final class MediaPickerService: NSObject, FilePickerServiceProtocol { + private var helper: FilesPickerProtocol + + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + public var preSelectedFiles: [FileResult] = [] + + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } +} + +extension MediaPickerService: PHPickerViewControllerDelegate { + public func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + picker.dismiss(animated: true, completion: { [weak self] in + self?.onPreparingDataCallback?() + + Task { + await self?.processResults(results) + } + }) + } +} + +private extension MediaPickerService { + func processResults(_ results: [PHPickerResult]) async { + do { + var dataArray: [FileResult] = [] + + for result in results { + let itemProvider = result.itemProvider + if isConforms(to: .image, itemProvider.registeredTypeIdentifiers) { + let url = try await helper.getUrlConforms( + to: .image, + for: itemProvider + ) + + let preview = try getPhoto( + from: url, + name: itemProvider.suggestedName ?? .empty + ) + + let fileSize = try helper.getFileSize(from: url) + + let resizedPreview = helper.resizeImage( + image: preview, + targetSize: FilesConstants.previewSize + ) + + let previewUrl = try? helper.getUrl( + for: resizedPreview, + name: FilesConstants.previewTag + url.lastPathComponent + ) + let mimeType = helper.getMimeType(for: url) + + dataArray.append( + .init( + assetId: result.assetIdentifier, + url: url, + type: .image, + preview: resizedPreview, + previewUrl: previewUrl, + previewExtension: helper.previewExtension, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: url.pathExtension, + resolution: preview.size, + mimeType: mimeType + ) + ) + } else if isConforms(to: .movie, itemProvider.registeredTypeIdentifiers) { + let url = try await helper.getUrlConforms( + to: .movie, + for: itemProvider + ) + + let fileSize = try helper.getFileSize(from: url) + let originalSize = helper.getOriginalSize(for: url) + let duration = helper.getVideoDuration(from: url) + let mimeType = helper.getMimeType(for: url) + + let thumbnailImage = try? await helper.getThumbnailImage( + forUrl: url, + originalSize: originalSize + ) + + let previewUrl = try? helper.getUrl( + for: thumbnailImage, + name: FilesConstants.previewTag + url.lastPathComponent + ) + + dataArray.append( + .init( + assetId: result.assetIdentifier, + url: url, + type: .video, + preview: thumbnailImage, + previewUrl: previewUrl, + previewExtension: helper.previewExtension, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: url.pathExtension, + resolution: originalSize, + duration: duration, + mimeType: mimeType + ) + ) + } else { + if let file = preSelectedFiles.first(where: { + $0.assetId == result.assetIdentifier + }) { + dataArray.append(file) + } else { + throw FilePickersError.cantSelectFile(itemProvider.suggestedName ?? .empty) + } + } + } + + try helper.validateFiles(dataArray) + onPreparedDataCallback?(.success(dataArray)) + } catch { + onPreparedDataCallback?(.failure(error)) + } + + preSelectedFiles.removeAll() + } + + func getPhoto(from url: URL, name: String) throws -> UIImage { + guard let image = UIImage(contentsOfFile: url.path) else { + throw FilePickersError.cantSelectFile(name) + } + + return image + } + + func isConforms(to type: UTType, _ registeredTypeIdentifiers: [String]) -> Bool { + for identifier in registeredTypeIdentifiers { + guard !identifier.contains("private") else { + continue + } + + if let uiType = UTType(identifier), uiType.conforms(to: type) { + return true + } + } + + return false + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift new file mode 100644 index 000000000..5c59b2b7e --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 11.02.2024. +// + +import UIKit +import CommonKit + +@MainActor +protocol FilePickerServiceProtocol { + var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? { get set } + var onPreparingDataCallback: (() -> Void)? { get set } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift new file mode 100644 index 000000000..06ca65156 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift @@ -0,0 +1,46 @@ +// +// FilesPickerProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 20.05.2024. +// + +import UIKit +import CommonKit +import QuickLook + +public protocol FilesPickerProtocol { + var previewExtension: String { + get + } + + func getFileSize(from url: URL) throws -> Int64 + func getUrl(for image: UIImage?, name: String) throws -> URL + func validateFiles(_ files: [FileResult]) throws + func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage + func getOriginalSize(for url: URL) -> CGSize? + func getThumbnailImage( + forUrl url: URL, + originalSize: CGSize? + ) async throws -> UIImage? + func getFileResult(for url: URL) throws -> FileResult + + @MainActor + func getUrlConforms( + to type: UTType, + for itemProvider: NSItemProvider + ) async throws -> URL + + @MainActor + func getUrl(for itemProvider: NSItemProvider) async throws -> URL + + @MainActor + func getFileURL( + by type: String, + itemProvider: NSItemProvider + ) async throws -> URL + + func getFileResult(for image: UIImage) throws -> FileResult + func getVideoDuration(from url: URL) -> Float64? + func getMimeType(for url: URL) -> String? +} diff --git a/FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift b/FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift new file mode 100644 index 000000000..586837470 --- /dev/null +++ b/FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FilesPickerKit + +final class FilesPickerKitTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/FilesStorageKit/.gitignore b/FilesStorageKit/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FilesStorageKit/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/FilesStorageKit/Package.swift b/FilesStorageKit/Package.swift new file mode 100644 index 000000000..588daaeb2 --- /dev/null +++ b/FilesStorageKit/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "FilesStorageKit", + platforms: [ + .iOS(.v15) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "FilesStorageKit", + targets: ["FilesStorageKit"]), + ], + dependencies: [ + .package(path: "../CommonKit") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "FilesStorageKit", + dependencies: ["CommonKit"]), + .testTarget( + name: "FilesStorageKitTests", + dependencies: ["FilesStorageKit"]), + ] +) diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift new file mode 100644 index 000000000..bf5944508 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -0,0 +1,431 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit +import Combine + +public typealias FileStorageServiceResult = Result + +public final class FilesStorageKit: FilesStorageProtocol { + public struct File { + public let id: String + public let isEncrypted: Bool + public let url: URL + public let fileType: FileType + public let isPreview: Bool + } + + @Atomic private var cachedFiles: [String: File] = [:] + private var cachedImages: NSCache = NSCache() + private let maxCachedFilesToLoad = 100 + private let encryptedFileExtension = "encFhj" + private let previewFileExtension = "prvAIFE" + + public init() { + try? loadCache() + } + + public func getPreview(for id: String) -> UIImage? { + guard !id.isEmpty else { return nil } + + if let image = cachedImages.object(forKey: id as NSString) { + return image + } + + return nil + } + + public func cacheImageToMemoryIfNeeded(id: String, data: Data) -> UIImage? { + guard let image = UIImage(data: data), + cachedImages.object(forKey: id as NSString) == nil + else { + return nil + } + + cachedImages.setObject(image, forKey: id as NSString) + return image + } + + public func isCachedInMemory(_ id: String) -> Bool { + guard !id.isEmpty else { return false } + + return cachedImages.object(forKey: id as NSString) != nil + } + + public func isCachedLocally(_ id: String) -> Bool { + cachedFiles[id] != nil + } + + public func getFile(with id: String) -> FileStorageServiceResult { + guard let file = cachedFiles[id] else { + return .failure(.fileNotFound) + } + + return .success(file) + } + + public func getFileURL(with id: String) -> FileStorageServiceResult { + getFile(with: id).flatMap { .success($0.url) } + } + + public func cacheFile( + id: String, + fileExtension: String, + url: URL?, + decodedData: Data, + encodedData: Data, + ownerId: String, + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) throws { + try saveFileLocally( + with: id, + fileExtension: fileExtension, + data: saveEncrypted ? encodedData : decodedData, + localUrl: url, + ownerId: ownerId, + recipientId: recipientId, + isEncrypted: saveEncrypted, + fileType: fileType, + isPreview: isPreview + ) + + guard fileType == .image, isPreview else { return } + cacheFileToMemory(data: decodedData, id: id) + + if let url = url { + cacheFileToMemory(data: decodedData, id: url.absoluteString) + } + } + + public func cacheTemporaryFile( + url: URL, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) { + cacheTemporaryFile( + with: url, + isEncrypted: isEncrypted, + fileType: fileType, + isPreview: isPreview + ) + } + + public func getCacheSize() -> FileStorageServiceResult { + guard let url = try? FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + else { return .failure(.fileNotFound) } + + return folderSize(at: url) + } + + public func clearCache() throws { + let cacheUrl = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + if FileManager.default.fileExists( + atPath: cacheUrl.path + ) { + try FileManager.default.removeItem(at: cacheUrl) + } + + try clearTempCache() + + cachedImages.removeAllObjects() + cachedFiles.removeAll() + } + + public func removeTempFiles(at urls: [URL]) { + urls.forEach { url in + guard FileManager.default.fileExists( + atPath: url.path + ) else { return } + + try? FileManager.default.removeItem(at: url) + } + } + + public func clearTempCache() throws { + let tempCacheUrl = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(tempCachePath) + + guard FileManager.default.fileExists( + atPath: tempCacheUrl.path + ) else { return } + + try FileManager.default.removeItem(at: tempCacheUrl) + } + + public func getTempUrl(for image: UIImage?, name: String) throws -> URL { + guard let data = image?.jpegData(compressionQuality: FilesConstants.previewCompressQuality) else { + throw FileValidationError.fileNotFound + } + + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(tempCachePath) + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(name) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + return fileURL + } + + public func copyFileToTempCache(from url: URL) throws -> URL { + defer { + url.stopAccessingSecurityScopedResource() + } + + _ = url.startAccessingSecurityScopedResource() + + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(tempCachePath) + + try FileManager.default.createDirectory( + at: folder, + withIntermediateDirectories: true + ) + + let targetURL = folder.appendingPathComponent(String.random(length: 6) + url.lastPathComponent) + + guard targetURL != url else { return url } + + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + return targetURL + } + + public func getFileSize(from fileURL: URL) -> FileStorageServiceResult { + defer { + fileURL.stopAccessingSecurityScopedResource() + } + + _ = fileURL.startAccessingSecurityScopedResource() + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + + guard let fileSize = fileAttributes[.size] as? Int64 else { + throw FileValidationError.fileNotFound + } + + return .success(fileSize) + } catch { + return .failure(.unknownError(error)) + } + } +} + +private extension FilesStorageKit { + func loadCache() throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + let files = getAllFiles(in: folder) + + var previewFiles: [File] = [] + + files.forEach { url in + let result = fileNameAndExtension(from: url) + let isEncrypted = result.extensions.contains(encryptedFileExtension) + let isPreview = result.extensions.contains(previewFileExtension) + + let file = File( + id: result.name, + isEncrypted: isEncrypted, + url: url, + fileType: FileType(raw: result.extensions.first ?? .empty) ?? .other, + isPreview: isPreview + ) + cachedFiles[result.name] = file + + if isPreview, !isEncrypted { + previewFiles.append(file) + } + } + + previewFiles.prefix(maxCachedFilesToLoad).forEach { file in + if let data = UIImage(contentsOfFile: file.url.path) { + cachedImages.setObject(data, forKey: file.id as NSString) + } + } + } + + func getAllFiles(in directoryURL: URL) -> [URL] { + var fileURLs: [URL] = [] + + let fileManager = FileManager.default + let enumerator = fileManager.enumerator(at: directoryURL, includingPropertiesForKeys: nil) + + while let fileURL = enumerator?.nextObject() as? URL { + var isDirectory: ObjCBool = false + let fileExist = fileManager.fileExists( + atPath: fileURL.path, + isDirectory: &isDirectory + ) + + if fileExist && !isDirectory.boolValue { + fileURLs.append(fileURL) + } else if fileExist && isDirectory.boolValue { + fileURLs.append(contentsOf: getAllFiles(in: fileURL)) + } + } + + return Array(Set(fileURLs)) + } + + func cacheTemporaryFile( + with url: URL, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) { + let file = File( + id: url.absoluteString, + isEncrypted: isEncrypted, + url: url, + fileType: fileType, + isPreview: isPreview + ) + $cachedFiles.mutate { $0[file.id] = file } + + if fileType == .image, + isPreview, + let uiImage = UIImage(contentsOfFile: url.path) { + cachedImages.setObject(uiImage, forKey: file.id as NSString) + } + } + + func cacheFileToMemory(data: Data, id: String) { + guard let uiImage = UIImage(data: data) else { return } + + cachedImages.setObject(uiImage, forKey: id as NSString) + } + + func saveFileLocally( + with id: String, + fileExtension: String, + data: Data? = nil, + localUrl: URL? = nil, + ownerId: String, + recipientId: String, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("\(cachePath)/\(ownerId)/\(recipientId)") + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let mainExtension = !fileExtension.isEmpty + ? ".\(fileExtension)" + : .empty + + let additionalExtension = isPreview + ? ".\(previewFileExtension)" + : .empty + + let fileName = isEncrypted + ? "\(id)\(mainExtension)\(additionalExtension).\(encryptedFileExtension)" + : "\(id)\(mainExtension)\(additionalExtension)" + + let fileURL = folder.appendingPathComponent(fileName) + let file = File( + id: id, + isEncrypted: isEncrypted, + url: fileURL, + fileType: fileType, + isPreview: isPreview + ) + + if let data = data { + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + } + + if let url = localUrl { + try FileManager.default.removeItem(at: url) + $cachedFiles.mutate { $0[url.absoluteString] = file } + } + + $cachedFiles.mutate { $0[id] = file } + } + + func folderSize(at url: URL) -> FileStorageServiceResult { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: url.path) else { + return .failure(.fileNotFound) + } + + guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { + return .failure(.fileNotFound) + } + + var folderSize: Int64 = 0 + + for case let fileURL as URL in enumerator { + do { + let attributes = try fileManager.attributesOfItem(atPath: fileURL.path) + if let fileSize = attributes[.size] as? Int64 { + folderSize += fileSize + } + } catch { } + } + + return .success(folderSize) + } + + func fileNameAndExtension(from url: URL) -> (name: String, extensions: [String]) { + let filename = url.lastPathComponent + let nameComponents = filename.components(separatedBy: ".") + + guard nameComponents.count > 1, + let name = nameComponents.first + else { + return (filename.replacingOccurrences(of: ".", with: ""), []) + } + + return (name, Array(nameComponents.dropFirst())) + } +} + +private let cachePath = "downloads" +private let tempCachePath = "downloads/cache" diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift new file mode 100644 index 000000000..8141b216c --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift @@ -0,0 +1,57 @@ +// +// FilesStorageProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 21.05.2024. +// + +import UIKit +import CommonKit + +public protocol FilesStorageProtocol { + func cacheImageToMemoryIfNeeded(id: String, data: Data) -> UIImage? + + func getPreview(for id: String) -> UIImage? + + func isCachedLocally(_ id: String) -> Bool + + func isCachedInMemory(_ id: String) -> Bool + + func getFileURL(with id: String) -> FileStorageServiceResult + + func getFile(with id: String) -> FileStorageServiceResult + + func cacheTemporaryFile( + url: URL, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) + + func cacheFile( + id: String, + fileExtension: String, + url: URL?, + decodedData: Data, + encodedData: Data, + ownerId: String, + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) throws + + func getCacheSize() -> FileStorageServiceResult + + func clearCache() throws + + func clearTempCache() throws + + func removeTempFiles(at urls: [URL]) + + func getTempUrl(for image: UIImage?, name: String) throws -> URL + + func copyFileToTempCache(from url: URL) throws -> URL + + func getFileSize(from fileURL: URL) -> FileStorageServiceResult +} diff --git a/FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift b/FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift new file mode 100644 index 000000000..988a30f98 --- /dev/null +++ b/FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FilesStorageKit + +final class FilesStorageKitTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift index 684af775d..18e9fb1a1 100644 --- a/NotificationServiceExtension/NotificationService.swift +++ b/NotificationServiceExtension/NotificationService.swift @@ -240,6 +240,37 @@ class NotificationService: UNNotificationServiceExtension { ) } + // rich file reply + if let data = message.data(using: String.Encoding.utf8), + let richContent = RichMessageTools.richContent(from: data), + let replyMessage = richContent[RichContentKeys.reply.replyMessage] as? [String: Any], + replyMessage[RichContentKeys.file.files] is [[String: Any]] { + + let text = FilePresentationHelper.getFilePresentationText(richContent) + content = NotificationContent( + title: partnerName ?? partnerAddress, + subtitle: nil, + body: MarkdownParser().parse(text).string, + attachments: nil, + categoryIdentifier: AdamantNotificationCategories.message + ) + } + + // rich file + if let data = message.data(using: String.Encoding.utf8), + let richContent = RichMessageTools.richContent(from: data), + richContent[RichContentKeys.file.files] is [[String: Any]] { + + let text = FilePresentationHelper.getFilePresentationText(richContent) + content = NotificationContent( + title: partnerName ?? partnerAddress, + subtitle: nil, + body: MarkdownParser().parse(text).string, + attachments: nil, + categoryIdentifier: AdamantNotificationCategories.message + ) + } + guard let content = content else { break } diff --git a/pckg/AdvancedContextMenuKit/Sources/AdvancedContextMenuKit/AdvancedContextMenuManager.swift b/pckg/AdvancedContextMenuKit/Sources/AdvancedContextMenuKit/AdvancedContextMenuManager.swift index 027fed314..0c16f475c 100644 --- a/pckg/AdvancedContextMenuKit/Sources/AdvancedContextMenuKit/AdvancedContextMenuManager.swift +++ b/pckg/AdvancedContextMenuKit/Sources/AdvancedContextMenuKit/AdvancedContextMenuManager.swift @@ -23,18 +23,6 @@ public final class AdvancedContextMenuManager: NSObject { public var didPresentMenuAction: ((_ messageId: String) -> Void)? public var didDismissMenuAction: ((_ messageId: String) -> Void)? - var isiOSAppOnMac: Bool = { -#if targetEnvironment(macCatalyst) - return true -#else - if #available(iOS 14.0, *) { - return ProcessInfo.processInfo.isiOSAppOnMac - } else { - return false - } -#endif - }() - // MARK: Public public func presentMenu( @@ -53,7 +41,7 @@ public final class AdvancedContextMenuManager: NSObject { animationInDuration: animationOutDuration ) - guard !isiOSAppOnMac else { + guard !isMacOS else { presentOverlayForMac( contentView: containerCopyView, contentViewSize: arg.size,