From 4538a86de983a68592966a6fd2d83c6432e34448 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 26 Feb 2024 12:23:09 +0200 Subject: [PATCH 001/123] File transfer UI locally --- Adamant.xcodeproj/project.pbxproj | 83 ++++ Adamant.xcworkspace/contents.xcworkspacedata | 3 + Adamant/Debug.entitlements | 10 + ...essageTransaction+CoreDataProperties.swift | 18 + Adamant/Modules/Chat/ChatFactory.swift | 1 + .../Chat/View/ChatViewController.swift | 87 +++- .../Modules/Chat/View/Helpers/ChatFile.swift | 24 ++ .../Chat/View/Managers/ChatAction.swift | 1 + .../View/Managers/ChatDataSourceManager.swift | 62 ++- .../View/Managers/ChatDialogManager.swift | 22 ++ .../FixedTextMessageSizeCalculator.swift | 8 + .../Subviews/ChatMedia/ChatMediaCell.swift | 60 +++ .../ChatMediaContainerView+Model.swift | 33 ++ .../Container/ChatMediaContainerView.swift | 76 ++++ .../Content/Cell/ChatFileTableViewCell.swift | 156 ++++++++ .../Content/ChatMediaContnentView+Model.swift | 24 ++ .../Content/ChatMediaContnentView.swift | 105 +++++ .../FilesToolbarCollectionViewCell.swift | 79 ++++ .../FilesToolBarView/FilesToolbarView.swift | 172 ++++++++ .../Chat/ViewModel/ChatMessageFactory.swift | 53 +++ .../Chat/ViewModel/ChatViewModel.swift | 138 +++++++ .../Chat/ViewModel/Models/ChatDialog.swift | 1 + .../Chat/ViewModel/Models/ChatMessage.swift | 3 + Adamant/Release.entitlements | 10 + Adamant/ServiceProtocols/DialogService.swift | 12 + Adamant/Services/AdamantDialogService.swift | 6 +- .../DataProviders/AdamantChatsProvider.swift | 22 +- .../downloadIcon.imageset/Contents.json | 23 ++ .../downloadIcon.imageset/downloadIcon.png | Bin 0 -> 1638 bytes .../downloadIcon.imageset/downloadIcon@2x.png | Bin 0 -> 3142 bytes .../downloadIcon.imageset/downloadIcon@3x.png | Bin 0 -> 4874 bytes .../file-jpg-box.imageset/Contents.json | 21 + .../file-jpg-box.imageset/file-jpg-box.png | Bin 0 -> 4131 bytes .../CommonKit/Models/RichAdditionalType.swift | 1 + .../CommonKit/Models/RichMessage.swift | 97 +++++ FilesStorageKit/.gitignore | 8 + FilesStorageKit/Package.swift | 35 ++ .../FilesStorageKit/FilesStorageKit.swift | 102 +++++ .../FilesStorageKit/Helpers/Constants.swift | 46 +++ .../FilesStorageKit/Models/FileResult.swift | 23 ++ .../Models/FileValidationError.swift | 39 ++ .../Protocols/ApiManagerProtocol.swift | 13 + .../Protocols/FilePickerProtocol.swift | 16 + .../NetworkFileManagerProtocol.swift | 17 + .../API Managers/BaseApiManager.swift | 27 ++ .../Services/DocumentPickerService.swift | 60 +++ .../Services/MediaPickerService.swift | 374 ++++++++++++++++++ .../Services/NetworkFileManager.swift | 30 ++ .../FilesStorageKitTests.swift | 12 + 49 files changed, 2174 insertions(+), 39 deletions(-) create mode 100644 Adamant/Modules/Chat/View/Helpers/ChatFile.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@3x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/file-jpg-box.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/file-jpg-box.imageset/file-jpg-box.png create mode 100644 FilesStorageKit/.gitignore create mode 100644 FilesStorageKit/Package.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift create mode 100644 FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 5924ddad1..399b07676 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -9,6 +9,16 @@ /* Begin PBXBuildFile section */ 269E13522B594B2D008D1CA7 /* AccountFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269E13512B594B2D008D1CA7 /* AccountFooterView.swift */; }; 3A20D93B2AE7F316005475A6 /* AdamantTransactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A20D93A2AE7F316005475A6 /* AdamantTransactionDetails.swift */; }; + 3A299C652B83678F00B54C61 /* FilesStorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A299C642B83678F00B54C61 /* FilesStorageKit */; }; + 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 /* ChatFileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7A2B85EABB00B54C61 /* ChatFileTableViewCell.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 */; }; @@ -656,6 +666,15 @@ 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 = ""; }; + 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 /* ChatFileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileTableViewCell.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 = ""; }; @@ -1231,6 +1250,7 @@ files = ( A5AC8DFF262E0B030053A7E2 /* SipHash in Frameworks */, 3A8875EF27BBF38D00436195 /* Parchment in Frameworks */, + 3A299C652B83678F00B54C61 /* FilesStorageKit in Frameworks */, 3C06931576393125C61FB8F6 /* Pods_Adamant.framework in Frameworks */, A50AEB0C262C81E300B37C22 /* QRCodeReader in Frameworks */, 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, @@ -1315,6 +1335,52 @@ path = Models; sourceTree = ""; }; + 3A299C672B838A7800B54C61 /* ChatMedia */ = { + isa = PBXGroup; + children = ( + 3A299C682B838AA600B54C61 /* ChatMediaCell.swift */, + 3A299C6F2B83901600B54C61 /* Container */, + 3A299C6E2B83901000B54C61 /* Content */, + ); + path = ChatMedia; + sourceTree = ""; + }; + 3A299C6E2B83901000B54C61 /* Content */ = { + isa = PBXGroup; + children = ( + 3A299C792B85EAA900B54C61 /* Cell */, + 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 /* Cell */ = { + isa = PBXGroup; + children = ( + 3A299C7A2B85EABB00B54C61 /* ChatFileTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; 3A41938D2A580C3B006A6B22 /* RichTransactionReactService */ = { isa = PBXGroup; children = ( @@ -1385,6 +1451,7 @@ children = ( 41330F7529F1509400CB587C /* AdamantCellAnimation.swift */, 416380E02A51765F00F90E6D /* ChatReactionsView.swift */, + 3A299C7C2B85F98700B54C61 /* ChatFile.swift */, ); path = Helpers; sourceTree = ""; @@ -1710,6 +1777,8 @@ 93996A9829682690008D080B /* Subviews */ = { isa = PBXGroup; children = ( + 3A299C742B84CE1400B54C61 /* FilesToolBarView */, + 3A299C672B838A7800B54C61 /* ChatMedia */, 41A1995029D42C160031AD75 /* ChatBaseMessage */, 413AD21A29CDDD750025F255 /* ChatReply */, 9377FBE0296C2AB700C9211B /* ChatTransaction */, @@ -2602,6 +2671,7 @@ 4177E5E02A52DA7100C089FE /* AdvancedContextMenuKit */, 9342F6C12A6A35E300A9B39F /* CommonKit */, 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, + 3A299C642B83678F00B54C61 /* FilesStorageKit */, ); productName = Adamant; productReference = E913C8EE1FFFA51D001A83F7 /* Adamant.app */; @@ -3030,6 +3100,7 @@ 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 */, @@ -3046,6 +3117,7 @@ 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 */, @@ -3102,6 +3174,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 */, @@ -3118,6 +3192,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 */, @@ -3186,6 +3261,7 @@ E9E7CDB32002B9FB00DFC4DB /* LoginFactory.swift in Sources */, E941CCDE20E7B70200C96220 /* WalletCollectionViewCell.swift in Sources */, 4186B33A294200F4006594A3 /* DashWalletService+DynamicConstants.swift in Sources */, + 3A299C7B2B85EABB00B54C61 /* ChatFileTableViewCell.swift in Sources */, 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */, E9AA8BFA212C166600F9249F /* EthWalletService+Send.swift in Sources */, 411743042A39B257008CD98A /* ContributeViewModel.swift in Sources */, @@ -3233,6 +3309,7 @@ E913C8F21FFFA51D001A83F7 /* AppDelegate.swift in Sources */, 648CE3A222999CE70070A2CC /* BTCRawTransaction.swift in Sources */, 648DD79E2236A0B500B811FD /* DogeTransactionsViewController.swift in Sources */, + 3A299C6B2B838F2300B54C61 /* ChatMediaContainerView.swift in Sources */, 64B5736F2209B892005DC968 /* BtcTransactionDetailsViewController.swift in Sources */, 938F7D612955C92B001915CA /* ChatDataSourceManager.swift in Sources */, E96D64C82295C44400CA5587 /* Data+utilites.swift in Sources */, @@ -3308,6 +3385,7 @@ E9A174B32057EC47003667CD /* BackgroundFetchService.swift in Sources */, 649D6BE821B95DB7009E727B /* LskWalletService+RichMessageProvider.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 */, @@ -3315,6 +3393,7 @@ 93A18C892AAEAE7700D0AB98 /* WalletFactory.swift in Sources */, E923222621135F9000A7E5AF /* EthAccount.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 */, @@ -4176,6 +4255,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 3A299C642B83678F00B54C61 /* 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..879d2496f 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,9 @@ + + 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/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift index 1b84cc8b5..23bdd8d34 100644 --- a/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift +++ b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift @@ -40,4 +40,22 @@ extension RichMessageTransaction { return nil } + + func getRichValue(for key: String) -> T? { + if let value = richContent?[key] as? T { + return value + } + + if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: String], + let value = content[key] as? T { + return value + } + + if let content = richContent?[RichContentKeys.file.files] as? [String: Any], + let value = content[key] as? T { + return value + } + + return nil + } } diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 873a31db5..68bb471b6 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -11,6 +11,7 @@ import MessageKit import InputBarAccessoryView import Combine import Swinject +import FilesStorageKit @MainActor struct ChatFactory { diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 066b48033..799934fb2 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -12,6 +12,7 @@ import Combine import UIKit import SnapKit import CommonKit +import FilesStorageKit @MainActor final class ChatViewController: MessagesViewController { @@ -44,6 +45,9 @@ 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 var sendTransaction: SendTransaction // swiftlint:disable unused_setter_value override var messageInputBar: InputBarAccessoryView { @@ -84,10 +88,12 @@ 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.map { sendTransaction($0, viewModel.replyMessage?.id) } +// self?.viewModel.clearReplyMessage() + self?.viewModel.presentActionMenu() } } @@ -105,6 +111,7 @@ final class ChatViewController: MessagesViewController { configureHeader() configureLayout() configureReplyView() + configureFilesToolbarView() configureGestures() setupObservers() viewModel.loadFirstMessagesIfNeeded() @@ -327,6 +334,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, @@ -363,6 +374,15 @@ private extension ChatViewController { viewModel.didTapPartnerQR .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() + } + .store(in: &subscriptions) } } @@ -422,6 +442,20 @@ private extension ChatViewController { } } + func configureFilesToolbarView() { + filesToolbarView.snp.makeConstraints { make in + make.height.equalTo(70) + } + + filesToolbarView.closeAction = { [weak self] in + self?.viewModel.filesPicked = nil + } + + filesToolbarView.updatedDataAction = { [weak self] data in + self?.viewModel.updateFiles(data) + } + } + 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. @@ -521,6 +555,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 @@ -630,21 +665,61 @@ 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 + ) + } + + messageInputBar.inputTextView.becomeFirstResponder() + } + + replyView.update(with: message) + } + + func closeReplyView() { + replyView.removeFromSuperview() + messageInputBar.invalidateIntrinsicContentSize() + messageInputBar.layoutContainerViewIfNeeded() + } + + func processFileToolbarView(_ data: [FileResult]?) { + guard let data = data, !data.isEmpty else { + closeFileToolbarView() + return + } + + 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 + ) }) messageInputBar.inputTextView.becomeFirstResponder() } - replyView.update(with: message) + filesToolbarView.update(data) } - func closeReplyView() { - replyView.removeFromSuperview() + func closeFileToolbarView() { + filesToolbarView.removeFromSuperview() messageInputBar.invalidateIntrinsicContentSize() messageInputBar.layoutContainerViewIfNeeded() } diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift new file mode 100644 index 000000000..ad47d0ccf --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -0,0 +1,24 @@ +// +// ChatFile.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit + +struct ChatFile: Equatable, Hashable { + var file: RichMessageFile.File + var previewData: Data + var isDownloading: Bool + var isCached: Bool + + static let `default` = Self( + file: .init([:]), + previewData: Data(), + isDownloading: false, + isCached: false + ) +} diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index 03aeeb705..a9d8acead 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -20,4 +20,5 @@ enum ChatAction { case remove(id: String) case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) + case processFile(file: ChatFile) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 026a24291..77cee44e7 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -121,28 +121,50 @@ 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 + ) + + 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 + } - return model.value + cell.containerMediaView.actionHandler = { [weak self] in self?.handleAction($0) } + 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() } } @@ -169,6 +191,8 @@ private extension ChatDataSourceManager { viewModel.reactAction(id, emoji: emoji) case let .presentMenu(arg): viewModel.presentMenu(arg: arg) + case let .processFile(file: file): + viewModel.processFile(file: file) } } } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift index cc4126ef6..0cf4eaa80 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 } diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index 33c4e5038..b88e42c1d 100644 --- a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift +++ b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift @@ -79,6 +79,14 @@ + additionalHeight } + if case let .file(model) = getMessages()[indexPath.section].fullModel.content { + let contentViewHeight: CGFloat = model.value.height() + messageContainerSize.width = 260 + messageContainerSize.height = contentViewHeight + + messageInsets.vertical + + additionalHeight + } + return messageContainerSize } 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..ad5a44056 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -0,0 +1,60 @@ +// +// 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 { + //containerView.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.frame = messageContainerView.frame + containerMediaView.layoutIfNeeded() + } +} + +private extension ChatMediaCell { + func configure() { + contentView.addSubview(containerMediaView) + containerMediaView.frame = messageContainerView.frame + } +} 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..31206ef6e --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -0,0 +1,33 @@ +// +// ChatMediaContainerView+Model.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +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 + + static let `default` = Self( + id: "", + isFromCurrentSender: true, + reactions: nil, + content: .default, + address: "", + opponentAddress: "" + ) + + func makeReplyContent() -> NSAttributedString { + return ChatMessageFactory.markdownParser.parse("File") + } + } +} 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..60ddd7ba4 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -0,0 +1,76 @@ +// +// 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 lazy var contentView = ChatMediaContentView() + + // MARK: Proprieties + + var subscription: AnyCancellable? + + var model: Model = .default { + didSet { update() } + } + + var actionHandler: (ChatAction) -> Void = { _ in } { + didSet { contentView.actionHandler = actionHandler } + } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension ChatMediaContainerView { + func configure() { + addSubview(swipeView) + swipeView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + addSubview(contentView) + contentView.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(12) + } + + swipeView.swipeStateAction = { [actionHandler] state in + actionHandler(.swipeState(state: state)) + } + // chatMenuManager.setup(for: contentView) + } + + func update() { + contentView.model = model.content + + swipeView.didSwipeAction = { [actionHandler, model] in + actionHandler(.reply(message: model)) + } + } +} + +extension ChatMediaContainerView.Model { + func height() -> CGFloat { + content.height() + } +} diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift new file mode 100644 index 000000000..fae10c5dd --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift @@ -0,0 +1,156 @@ +// +// ChatFileTableViewCell.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import CommonKit + +class ChatFileTableViewCell: UITableViewCell { + private lazy var iconImageView: UIImageView = UIImageView() + private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) + + private lazy var spinner: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.isHidden = true + view.color = .black + 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: .adamant.textColor) + + private lazy var vStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .leading + stack.axis = .vertical + stack.spacing = stackSpacing + stack.backgroundColor = .clear + + stack.addArrangedSubview(nameLabel) + stack.addArrangedSubview(sizeLabel) + return stack + }() + + private lazy var tapBtn: UIButton = { + let btn = UIButton() + btn.addTarget(self, action: #selector(tapBtnAction), for: .touchUpInside) + return btn + }() + + var model: ChatFile = .default { + didSet { + guard oldValue != model else { return } + update() + } + } + + var buttonActionHandler: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = nil + contentView.backgroundColor = nil + + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override func layoutSubviews() { + super.layoutSubviews() + + iconImageView.layer.cornerRadius = 5 + } + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + + @objc func tapBtnAction() { + buttonActionHandler?() + } +} + +private extension ChatFileTableViewCell { + func configure() { + contentView.addSubview(horizontalStack) + horizontalStack.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + iconImageView.snp.makeConstraints { make in + make.size.equalTo(imageSize) + } + + contentView.addSubview(spinner) + spinner.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + } + + contentView.addSubview(downloadImageView) + downloadImageView.snp.makeConstraints { make in + make.center.equalTo(iconImageView) + make.size.equalTo(imageSize / 1.3) + } + + contentView.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 + } + + func update() { + iconImageView.image = UIImage(data: model.previewData) + downloadImageView.isHidden = model.isCached || model.isDownloading + + if model.isDownloading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + + let fileType = model.file.file_type ?? "" + let fileName = model.file.file_name ?? "UNKNWON" + + nameLabel.text = "\(fileName.uppercased()).\(fileType.uppercased())" + sizeLabel.text = "\(model.file.file_size) kb" + } +} + +private let nameFont = UIFont.systemFont(ofSize: 15) +private let sizeFont = UIFont.systemFont(ofSize: 13) +private let imageSize: CGFloat = 90 +private let stackSpacing: CGFloat = 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..bc423fc74 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -0,0 +1,24 @@ +// +// 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 files: [ChatFile] + var isHidden: Bool + + static let `default` = Self( + id: "", + files: [], + isHidden: false + ) + } +} 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..3d3ae079a --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -0,0 +1,105 @@ +// +// ChatMediaContnentView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 14.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SnapKit +import UIKit +import CommonKit + +final class ChatMediaContentView: UIView { + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(ChatFileTableViewCell.self, forCellReuseIdentifier: "cell") + tableView.delegate = self + tableView.backgroundColor = .clear + return tableView + }() + + private lazy var dataSource = TransactionsDiffableDataSource(tableView: tableView, cellProvider: makeCell) + + var model: Model = .default { + didSet { + guard oldValue != model else { return } + update() + } + } + + var actionHandler: (ChatAction) -> Void = { _ in } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +private extension ChatMediaContentView { + func configure() { + addSubview(tableView) + tableView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + } + + func update() { + let list = model.files + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.zero]) + snapshot.appendItems(list) + snapshot.reconfigureItems(list) + dataSource.apply(snapshot, animatingDifferences: false) + } + + func makeCell( + tableView: UITableView, + indexPath: IndexPath, + model: ChatFile + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! ChatFileTableViewCell + cell.model = model + cell.backgroundView?.backgroundColor = .clear + cell.backgroundColor = .clear + cell.contentView.backgroundColor = .clear + cell.buttonActionHandler = { [actionHandler, model] in + print("did select\(indexPath.row)") + actionHandler(.processFile(file: model)) + } + return cell + } +} + +extension ChatMediaContentView: UITableViewDelegate { + func tableView( + _ tableView: UITableView, + heightForRowAt indexPath: IndexPath + ) -> CGFloat { + imageSize + } + + func tableView( + _ tableView: UITableView, + didSelectRowAt indexPath: IndexPath + ) { + print("did select\(indexPath.row)") + } +} + +extension ChatMediaContentView.Model { + func height() -> CGFloat { + imageSize * CGFloat(files.count) + } +} + +private let nameFont = UIFont.systemFont(ofSize: 15) +private let sizeFont = UIFont.systemFont(ofSize: 13) +private let imageSize: CGFloat = 90 +private typealias TransactionsDiffableDataSource = UITableViewDiffableDataSource +private let cellIdentifier = "cell" 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..ac1674085 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -0,0 +1,79 @@ +// +// FilesToolbarCollectionViewCell.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 20.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import FilesStorageKit + +final class FilesToolbarCollectionViewCell: UICollectionViewCell { + private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) + + lazy var containerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 5 + + view.addSubview(imageView) + view.addSubview(removeBtn) + + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 5 + + imageView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + removeBtn.snp.makeConstraints { make in + make.top.equalToSuperview().offset(-15) + make.trailing.equalToSuperview().offset(15) + } + return view + }() + + private lazy var removeBtn: UIButton = { + let btn = UIButton() + btn.setImage( + UIImage(systemName: "xmark.app.fill")?.withTintColor(.adamant.alert), + for: .normal + ) + btn.addTarget(self, action: #selector(didTapRemoveBtn), for: .touchUpInside) + + btn.snp.makeConstraints { make in + make.size.equalTo(40) + } + return btn + }() + + var buttonActionHandler: ((Int) -> Void)? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + addSubview(containerView) + containerView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + } + + @objc private func didTapRemoveBtn() { + buttonActionHandler?(removeBtn.tag) + } + + func update(_ file: FileResult, tag: Int) { + imageView.image = file.preview ?? .asset(named: "file-jpg-box") + removeBtn.tag = tag + } +} 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..5d9ab8c78 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -0,0 +1,172 @@ +// +// FilesToolbarView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 17.02.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import UIKit +import SnapKit +import FilesStorageKit + +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: "cell") + collectionView.backgroundColor = .clear + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + return collectionView + }() + + private lazy var containerView: UIView = { + let view = UIView() + let colorView = UIView() + colorView.backgroundColor = .adamant.active + + view.addSubview(colorView) + view.addSubview(collectionView) + + colorView.snp.makeConstraints { + $0.top.leading.bottom.equalToSuperview() + $0.width.equalTo(2) + } + collectionView.snp.makeConstraints { + $0.top.bottom.trailing.equalToSuperview() + $0.leading.equalTo(colorView.snp.trailing).offset(5) + } + return view + }() + + private lazy var iconView: UIView = { + let view = UIView() + view.addSubview(iconIV) + iconIV.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + view.snp.makeConstraints { make in + make.width.equalTo(27) + } + return view + }() + + private var iconIV: UIImageView = { + let iv = UIImageView( + image: UIImage( + systemName: "square.and.arrow.up" + )?.withTintColor(.adamant.active) + ) + + iv.tintColor = .adamant.active + iv.snp.makeConstraints { make in + make.height.equalTo(30) + make.width.equalTo(27) + } + + return iv + }() + + 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: [iconView, containerView, closeBtn]) + stack.axis = .horizontal + stack.spacing = horizontalStackSpacing + return stack + }() + + // MARK: Proprieties + + private var data: [FileResult] = [] + var closeAction: (() -> Void)? + var updatedDataAction: (([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.top.bottom.equalToSuperview().inset(verticalInsets) + $0.leading.trailing.equalToSuperview().inset(15) + } + } + + // 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() + } +} + +extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + data.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: "cell", + 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 + } +} + +private let horizontalStackSpacing: CGFloat = 25 +private let verticalInsets: CGFloat = 8 diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index cbf4c0cc0..fb0e18fed 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 @@ -134,6 +135,15 @@ private extension ChatMessageFactory { ) } + if transaction.additionalType == .file, + !transaction.isTransferReply() { + return makeFileContent( + transaction, + isFromCurrentSender: isFromCurrentSender, + backgroundColor: backgroundColor + ) + } + return makeContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -281,6 +291,49 @@ private extension ChatMessageFactory { ))) } + func makeFileContent( + _ transaction: RichMessageTransaction, + isFromCurrentSender: Bool, + backgroundColor: ChatMessageBackgroundColor + ) -> ChatMessage.Content { + let id = transaction.chatMessageId ?? "" + + let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." + let decodedMessageMarkDown = Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() + let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] + + let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set + + let address = transaction.isOutgoing + ? transaction.senderAddress + : transaction.recipientAddress + + let opponentAddress = transaction.isOutgoing + ? transaction.recipientAddress + : transaction.senderAddress + + return .file(.init(value: .init( + id: id, + isFromCurrentSender: isFromCurrentSender, + reactions: reactions, + content: .init( + id: id, + files: files.map { ChatFile.init( + file: RichMessageFile.File.init($0), + previewData: FilesStorageKit.shared.getPreview( + for: $0[RichContentKeys.file.file_id] as? String ?? "", + type: $0[RichContentKeys.file.file_type] as? String ?? "" + ), + isDownloading: false, + isCached: FilesStorageKit.shared.isCached($0[RichContentKeys.file.file_id] as? String ?? "") + )}, + isHidden: false + ), + address: address, + opponentAddress: opponentAddress + ))) + } + func makeContent( _ transaction: TransferTransaction, isFromCurrentSender: Bool, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index a1a97580f..a2b7a46db 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -13,6 +13,7 @@ import UIKit import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker +import FilesStorageKit @MainActor final class ChatViewModel: NSObject { @@ -77,6 +78,8 @@ final class ChatViewModel: NSObject { let updateChatRead = ObservableSender() let commitVibro = ObservableSender() let layoutIfNeeded = ObservableSender() + let presentFilePicker = ObservableSender() + let presentSendTokensVC = ObservableSender() @ObservableValue private(set) var isHeaderLoading = false @ObservableValue private(set) var fullscreenLoading = false @@ -91,6 +94,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 { @@ -120,6 +124,10 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } + private var downloadingFilesID: [String] = [] { + didSet { updateDownloadingFiles(&messages) } + } + init( chatsProvider: ChatsProvider, markdownParser: MarkdownParser, @@ -224,6 +232,11 @@ final class ChatViewModel: NSObject { } func sendMessage(text: String) { + if filesPicked?.count ?? .zero > .zero { + sendFile(text: text) + return + } + guard let partnerAddress = chatroom?.partner?.address else { return } guard chatroom?.partner?.isDummy != true else { @@ -263,6 +276,47 @@ final class ChatViewModel: NSObject { }.stored(in: tasksStorage) } + func sendFile(text: String) { + guard let partnerAddress = chatroom?.partner?.address, + let files = filesPicked + else { return } + + guard chatroom?.partner?.isDummy != true else { + dialog.send(.dummy(partnerAddress)) + return + } + + Task { + let files: [RichMessageFile.File] = files.compactMap { + RichMessageFile.File.init( + file_id: "https://i.ibb.co/YXcWnC4/IMG-5-FFA7-DBAE0-E7-1.jpg\(Int.random(in: 0...1000))", + file_type: "jpg", + file_size: $0.size, + preview_id: "https://i.ibb.co/Jqvd61W/IMG-5-FFA7-DBAE0-E7-1.jpg", + file_name: $0.name + ) + } + let message: AdamantMessage = .richMessage( + payload: RichMessageFile(files: files, storage: "imgbb", comment: text) + ) + + guard await validateSendingMessage(message: message) else { return } + + replyMessage = nil + filesPicked = nil + + do { + _ = try await chatsProvider.sendMessage( + message, + recipientId: partnerAddress, + from: chatroom + ) + } catch { + await handleMessageSendingError(error: error, sentText: "text") + } + }.stored(in: tasksStorage) + } + func forceUpdateTransactionStatus(id: String) { Task { guard @@ -594,6 +648,51 @@ final class ChatViewModel: NSObject { return true } + + func processFile(file: ChatFile) { + downloadingFilesID.append(file.file.file_id) + } + + func presentActionMenu() { + dialog.send(.actionMenu) + } + + func didSelectMenuAction(_ action: ShareType) { + if case(.sendTokens) = action { + presentSendTokensVC.send() + return + } + + Task { + do { + var result: [FileResult] = [] + // dialog.send(.progress(true)) + + if case(.uploadFile) = action { + result = try await FilesStorageKit.shared.presentDocumentPicker() + } + if case(.uploadMedia) = action { + result = try await FilesStorageKit.shared.presentImagePicker() + } + + presentFilePicker.send(action) +// if let image = result.first?.preview { +// FilesStorageKit.shared.cacheImage(id: "1", image: image) +// } + + dialog.send(.progress(false)) + filesPicked = result + print("data=\(result.count)") + if let data = result.first?.preview { + // try await FilesStorageKit.shared.uploadFile(data, type: .) + } + } catch { + dialog.send(.progress(false)) + dialog.send(.alert(error.localizedDescription)) + print("error=\(error)") + } + } + } } extension ChatViewModel { @@ -637,6 +736,10 @@ extension ChatViewModel { dialog.send(.renameAlert) } + + func updateFiles(_ data: [FileResult]) { + filesPicked = data + } } extension ChatViewModel: NSFetchedResultsControllerDelegate { @@ -920,6 +1023,17 @@ private extension ChatViewModel { } } + func updateDownloadingFiles(_ messages: inout [ChatMessage]) { + messages.indices.forEach { index in + messages[index].getFiles().forEach { file in + messages[index].setDownloading( + for: file.file.file_id, + value: downloadingFilesID.contains(file.file.file_id) + ) + } + } + } + func isNewReaction(old: [ChatTransaction], new: [ChatTransaction]) -> Bool { guard let processedDate = old.getMostRecentElementDate(), @@ -940,6 +1054,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 } } @@ -957,9 +1073,31 @@ 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.files + } + + mutating func setDownloading(for fileId: String, value: Bool) { + guard case let .file(fileModel) = content else { return } + var model = fileModel.value + + guard let index = model.content.files.firstIndex( + where: { $0.file.file_id == fileId } + ) else { return } + + model.content.files[index].isDownloading = value + + 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..c1e889fef 100644 --- a/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift +++ b/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift @@ -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/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/DialogService.swift b/Adamant/ServiceProtocols/DialogService.swift index b1ac46f21..a61c42a52 100644 --- a/Adamant/ServiceProtocols/DialogService.swift +++ b/Adamant/ServiceProtocols/DialogService.swift @@ -44,6 +44,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 { @@ -58,6 +61,15 @@ enum ShareType { case .saveToPhotolibrary: return String.adamant.alert.saveToPhotolibrary + + case .sendTokens: + return "Send tokens" + + case .uploadMedia: + return "Upload media" + + case .uploadFile: + return "Upload file" } } } diff --git a/Adamant/Services/AdamantDialogService.swift b/Adamant/Services/AdamantDialogService.swift index e0f833ccd..3fc83c5dc 100644 --- a/Adamant/Services/AdamantDialogService.swift +++ b/Adamant/Services/AdamantDialogService.swift @@ -432,10 +432,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/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 575269f31..245831a32 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -852,17 +852,17 @@ extension AdamantChatsProvider { ) } - let transaction = try await sendMessageToServer( - senderId: loggedAccount.address, - recipientId: recipientId, - transaction: transactionLocaly, - type: message.chatType, - keypair: keypair, - context: context, - from: chatroom - ) - - return transaction +// let transaction = try await sendMessageToServer( +// senderId: loggedAccount.address, +// recipientId: recipientId, +// transaction: transactionLocaly, +// type: message.chatType, +// keypair: keypair, +// context: context, +// from: chatroom +// ) + + return transactionLocaly } private func sendTextMessageLocaly( diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/Contents.json new file mode 100644 index 000000000..a066d942c --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/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/downloadIcon.imageset/downloadIcon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..cb622db702c8cc1aa4eee4e7338a9cfc00716336 GIT binary patch literal 1638 zcmV-s2ATPZP)Io(>5C*ba1rtqg%VIq3n&7D?t(H5$|rJ+ zOCe}(z7EO?g;Bu)$Qe*5=z$#ILI|3o)u?c6G8G>wl+&n~CMyI{@SMWD;1i9CX+j8^ zrf(^1o_wYeF}8(#L!k`LO_L5P#|+65wkpck=BX@5`hBtxEjGHCh>GusPtVB)s5kTu zF$gs}jxi|&xuQq&)kNii?}%{|zPGXQi>Zo3G$1OLXfZ)<`Hu`VVJ0F3GQOwzZla1n zlptCVDYfRx>Iy+btVjQ$MF(|&yi!X_9U+kM11(0rC=2SMmXxYOL`F8Xq*M`tT(M3f zBM&77Q~X*|bcLt{E;*wZ)h*VF6kQ?f)cksdW-M9R3#8+0mnjlzW_K@Ze1qNUW} zv8)g^NhnkUQG#ZxaGIq|EGuVajVt(k^DjJZyv;-h$GEa z^DM_7#D!ocSEkmR2Z?<;iyjxUN`fn8v8X%NIr2<|@S-El6)(CHA-w3w9$j8Ylf{g- z>h=meXr-MuCAgz4(ZLK6!VD3@3+4s;;qdp< z)6<{G6355KF9w6bZ~cD%2%kxOctn>MSJkq(b`TY`wzhVl9-p6|zuev3eUtYd-F;bS zXJ-dHJ3B|}@y*T6FB=;hzY-7b5Ft(udrYI-?LPTyMa7{4$q-2ypx5iYAU@nf$Rjb~ z(i;LI9hX+s3s}C}eKmNuq@!8&6VoX+~6ybpkFwNoK z2m!iMJdpvWDI+cfmRKhDIAx`HBm>P=YJ>o`$vsYy6pv(pLHt2d$OrOd!759NXEMN( zF<{z`6C66@&5=y#eQj=TzG}DIy{oIMpFNX-+g|kJPoxP^r&SB~L09+5v{l+B2Mkc| zNU#l}V`dI9YuCyDB-mz<_GFe2nEOD2EmETcFNk_@3bdTIRyG%sNEVX2Sy4!vr1W4uv|uU;6NSbvD!A+kK@1kXIOP~rKE_mNo}3&x!li2p zF)QTe9wumtZlmJ-T1$>&g{7!0M?Xyqf&k3; kihiQ9A7kYcF+ct2Gd}LXd*kJx6951J07*qoM6N<$f(FU+e*gdg literal 0 HcmV?d00001 diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a5c1fa19d06d2d2c4ee06066a7f7d05633985961 GIT binary patch literal 3142 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 n>Y{Cqb [String: Any] { + var contentDict: [String : Any] = [ + RichContentKeys.file.file_id: file_id, + RichContentKeys.file.file_size: file_size + ] + + if let file_type = file_type, !file_type.isEmpty { + contentDict[RichContentKeys.file.file_type] = file_type + } + + if let preview_id = preview_id, !preview_id.isEmpty { + contentDict[RichContentKeys.file.preview_id] = preview_id + } + + if let file_name = file_name, !file_name.isEmpty { + contentDict[RichContentKeys.file.file_name] = file_name + } + + return contentDict + } + } + + public var type: String + public var additionalType: RichAdditionalType + public var files: [File] + public var storage: String + public var comment: String? + + public enum CodingKeys: String, CodingKey { + case files, storage, comment + } + + public init(files: [File], storage: String, 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 + ] + + if let comment = comment, !comment.isEmpty { + contentDict[RichContentKeys.file.comment] = comment + } + return contentDict + } +} + // MARK: - RichMessageReply public struct RichMessageReply: RichMessage { 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..70b85c03b --- /dev/null +++ b/FilesStorageKit/Package.swift @@ -0,0 +1,35 @@ +// 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..508b7e0c8 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -0,0 +1,102 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit + +public final class FilesStorageKit { + public static let shared = FilesStorageKit() + + private let window = TransparentWindow(frame: UIScreen.main.bounds) + private let mediaPicker: FilePickerProtocol + private let documentPicker: FilePickerProtocol + private var cachedImages: [String: UIImage] = [:] + + private let networkFileManager: NetworkFileManagerProtocol = NetworkFileManager() + + public init() { + mediaPicker = MediaPickerService() + documentPicker = DocumentPickerService() + } + + @MainActor + public func presentImagePicker() async throws -> [FileResult] { + try await withUnsafeThrowingContinuation { continuation in + mediaPicker.startPicker( + window: window + ) { [weak self] data in + do { + try self?.validateFiles(data.map { $0.url }) + } catch { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: data) + } + } + } + + @MainActor + public func presentDocumentPicker() async throws -> [FileResult] { + try await withUnsafeThrowingContinuation { continuation in + documentPicker.startPicker( + window: window + ) { [weak self] data in + do { + try self?.validateFiles(data.map { $0.url }) + } catch { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: data) + } + } + } + + public func cacheImage(id: String, image: UIImage) { + cachedImages[id] = image + } + + public func getCachedImage(id: String) -> UIImage? { + cachedImages[id] + } + + public func getPreview(for id: String, type: String) -> Data { + guard let data = cachedImages[id]?.jpegData(compressionQuality: 1.0) + else { + return UIImage.asset(named: "file-jpg-box")?.jpegData(compressionQuality: 1.0) ?? Data() + } + + return data + } + + public func isCached(_ id: String) -> Bool { + cachedImages[id] != nil + } + + public func uploadFile(_ data: Data, type: NetworkFileProtocolType) async throws -> String { + try await networkFileManager.uploadFiles(data, type: type) + } +} + +private extension FilesStorageKit { + func validateFiles(_ fileURLs: [URL]) throws { + guard fileURLs.count <= Constants.maxFilesCount else { + throw FileValidationError.tooManyFiles + } + + for fileURL in fileURLs { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + + guard let fileSize = fileAttributes[.size] as? Int64 else { + throw FileValidationError.fileNotFound + } + + guard fileSize <= Constants.maxFileSize else { + throw FileValidationError.fileSizeExceedsLimit + } + } + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift b/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift new file mode 100644 index 000000000..a7803976a --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift @@ -0,0 +1,46 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// + +import Foundation +import UIKit + +final class Constants { + static let maxFilesCount = 15 + static let maxFileSize: Int64 = 8 * 1024 * 1024 +} + +extension UIApplication { + func topViewController() -> UIViewController? { + var topViewController: UIViewController? = nil + if #available(iOS 13, *) { + for scene in connectedScenes { + if let windowScene = scene as? UIWindowScene { + for window in windowScene.windows { + if window.isKeyWindow { + topViewController = window.rootViewController + } + } + } + } + } else { + topViewController = keyWindow?.rootViewController + } + while true { + if let presented = topViewController?.presentedViewController { + topViewController = presented + } else if let navController = topViewController as? UINavigationController { + topViewController = navController.topViewController + } else if let tabBarController = topViewController as? UITabBarController { + topViewController = tabBarController.selectedViewController + } else { + // Handle any other third party container in `else if` if required + break + } + } + return topViewController + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift new file mode 100644 index 000000000..31432a400 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift @@ -0,0 +1,23 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 19.02.2024. +// + +import Foundation +import UIKit + +public enum FileType { + case image + case video + case other +} + +public struct FileResult { + public let url: URL + public let type: FileType + public let preview: UIImage? + public let size: Int64 + public let name: String? +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift new file mode 100644 index 000000000..abf694296 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift @@ -0,0 +1,39 @@ +// +// FileValidationError.swift +// +// +// Created by Stanislav Jelezoglo on 12.02.2024. +// + +import Foundation + +public enum FileValidationError: Error, LocalizedError { + case tooManyFiles + case fileSizeExceedsLimit + case fileNotFound + + public var errorDescription: String { + switch self { + case .tooManyFiles: + return "too Many Files" + case .fileSizeExceedsLimit: + return "file Size Exceeds Limit" + case .fileNotFound: + return "file Not Found" + } + } +} + +public enum FileManagerError: Error, LocalizedError { + case cantDownloadFile + case cantUploadFile + + public var errorDescription: String { + switch self { + case .cantDownloadFile: + return "cant Download File" + case .cantUploadFile: + return "cant Upload File" + } + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift new file mode 100644 index 000000000..380480ea6 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift @@ -0,0 +1,13 @@ +// +// ApiManagerProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 26.02.2024. +// + +import Foundation + +protocol ApiManagerProtocol { + func uploadFile(data: Data) async throws -> String + func downloadFile(id: String) async throws -> Data +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift new file mode 100644 index 000000000..ead4152af --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 11.02.2024. +// + +import Foundation +import UIKit + +protocol FilePickerProtocol { + func startPicker( + window: UIWindow, + completion: (([FileResult]) -> Void)? + ) +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift new file mode 100644 index 000000000..f2ea7daf1 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift @@ -0,0 +1,17 @@ +// +// NetworkFileManagerProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 20.02.2024. +// + +import Foundation + +public enum NetworkFileProtocolType: String { + case base +} + +protocol NetworkFileManagerProtocol { + func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String + func downloadFile(_ id: String, type: String) async throws -> Data +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift new file mode 100644 index 000000000..b8d3ba2d9 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift @@ -0,0 +1,27 @@ +// +// BaseApiManager.swift +// +// +// Created by Stanislav Jelezoglo on 20.02.2024. +// + +import Foundation + +final class BaseApiManager: ApiManagerProtocol { + //private var uploadcare: Uploadcare + +// init() { +// self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") +// } + + func uploadFile(data: Data) async throws -> String { + "" +// var fileForUploading = uploadcare.file(fromData: data) +// try await fileForUploading.upload(withName: "test", store: .auto) +// return fileForUploading.fileId + } + + func downloadFile(id: String) async throws -> Data { + Data() + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift new file mode 100644 index 000000000..af0cf9330 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift @@ -0,0 +1,60 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 21.02.2024. +// + +import Foundation +import UIKit + +final class DocumentPickerService: NSObject, FilePickerProtocol { + let documentPicker = UIDocumentPickerViewController( + forOpeningContentTypes: [.data, .content], + asCopy: false + ) + + private var onPreparedDataCallback: (([FileResult]) -> Void)? + + func startPicker( + window: UIWindow, + completion: (([FileResult]) -> Void)? + ) { + onPreparedDataCallback = completion + + documentPicker.allowsMultipleSelection = true + documentPicker.delegate = self + UIApplication.shared.topViewController()?.present(documentPicker, animated: true) + } +} + +extension DocumentPickerService: UIDocumentPickerDelegate { + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + let files = urls.compactMap { + FileResult.init( + url: $0, + type: .other, + preview: nil, + size: (try? getFileSize(from: $0)) ?? .zero, + name: $0.lastPathComponent + ) + } + + onPreparedDataCallback?(files) + } +} + +private extension DocumentPickerService { + func getFileSize(from fileURL: URL) throws -> Int64 { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + + guard let fileSize = fileAttributes[.size] as? Int64 else { + throw FileValidationError.fileNotFound + } + + return fileSize + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift new file mode 100644 index 000000000..42ec2aa71 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift @@ -0,0 +1,374 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 11.02.2024. +// + +import Foundation +import UIKit +import Photos +import PhotosUI + +final class MediaPickerService: NSObject, FilePickerProtocol { + private var onPreparedDataCallback: (([FileResult]) -> Void)? + + func startPicker( + window: UIWindow, + completion: (([FileResult]) -> Void)? + ) { + onPreparedDataCallback = completion + + var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) + phPickerConfig.selectionLimit = Constants.maxFilesCount + phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) + + let phPickerVC = PHPickerViewController(configuration: phPickerConfig) + phPickerVC.delegate = self + UIApplication.shared.topViewController()?.present(phPickerVC, animated: true) + } +} + +extension MediaPickerService: PHPickerViewControllerDelegate { + func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + picker.dismiss(animated: true, completion: .none) + Task { + await processResults(results) + } + } +} + +private extension MediaPickerService { + func processResults(_ results: [PHPickerResult]) async { + var dataArray: [FileResult] = [] + + for result in results { + let itemProvider = result.itemProvider + + guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, + let utType = UTType(typeIdentifier) + else { continue } + + if utType.conforms(to: .image) { + guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), + let preview = try? await getPhoto(from: itemProvider), + let fileSize = try? getFileSize(from: url) + else { continue } + + dataArray.append( + .init( + url: url, + type: .image, + preview: preview, + size: fileSize, + name: itemProvider.suggestedName + ) + ) + } + + if utType.conforms(to: .movie) { + guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), + let fileSize = try? getFileSize(from: url) + else { continue } + + let preview = getThumbnailImage(forUrl: url) + + dataArray.append(.init( + url: url, + type: .video, + preview: preview, + size: fileSize, + name: itemProvider.suggestedName) + ) + } + } + + onPreparedDataCallback?(dataArray) + } + + func getFileSize(from fileURL: URL) throws -> Int64 { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + + guard let fileSize = fileAttributes[.size] as? Int64 else { + throw FileValidationError.fileNotFound + } + + return fileSize + } + + func getPhoto(from itemProvider: NSItemProvider) async throws -> UIImage { + let objectType: NSItemProviderReading.Type = UIImage.self + + guard itemProvider.canLoadObject(ofClass: objectType) else { + throw FileValidationError.tooManyFiles + } + + return try await withUnsafeThrowingContinuation { continuation in + itemProvider.loadObject(ofClass: objectType) { object, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let image = object as? UIImage else { + continuation.resume(throwing: FileValidationError.tooManyFiles) + return + } + + continuation.resume(returning: image) + } + } + } + + func getUrl( + from itemProvider: NSItemProvider, + typeIdentifier: String + ) async throws -> URL { + try await withUnsafeThrowingContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let url = url else { + continuation.resume(throwing: FileValidationError.tooManyFiles) + return + } + + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { return } + + do { + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + continuation.resume(returning: targetURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func getThumbnailImage(forUrl url: URL) -> UIImage? { + let asset: AVAsset = AVAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + + do { + let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) + return UIImage(cgImage: thumbnailImage) + } catch let error { + print("error in thumbail=", error) + return nil + } + } +} + +//private extension MediaPickerService { +// func fetchData(for assets: [PHAsset]) { +// var dataArray: [FileResult] = [] +// let dispatchGroup = DispatchGroup() +// +// for asset in assets { +// dispatchGroup.enter() +// +// if asset.mediaType == .image { +// requestImage(asset: asset) { data in +// defer { dispatchGroup.leave() } +// +// guard let data = data else { return } +// dataArray.append(.init( +// data: data, +// type: .image, +// preview: UIImage(data: data)) +// ) +// } +// } +// +// if asset.mediaType == .video { +// requestVideo(asset: asset) { data, imgData in +// defer { dispatchGroup.leave() } +// +// guard let data = data, let imgData = imgData else { return } +// dataArray.append( +// .init( +// data: data, +// type: .video, +// preview: UIImage(data: imgData)) +// ) +// } +// } +// } +// +// dispatchGroup.notify(queue: DispatchQueue.main) { +// self.onPreparedDataCallback?(dataArray) +// } +// } +// +// func requestImage( +// asset: PHAsset, +// completion: ((Data?) -> Void)?, +// tryNumber: Int = 1 +// ) { +// requestImageData(asset: asset) { [weak self, asset] image in +// if image == nil && tryNumber < 4 { +// self?.requestImage( +// asset: asset, +// completion: completion, +// tryNumber: tryNumber + 1 +// ) +// return +// } +// +// completion?(image) +// } +// } +// +// func requestImageData( +// asset: PHAsset, +// completion: ((Data?) -> Void)? +// ) { +// let imgManager = PHImageManager.default() +// +// let options = PHImageRequestOptions() +// options.isSynchronous = false +// options.deliveryMode = .opportunistic +// options.isNetworkAccessAllowed = true +// options.resizeMode = .exact +// +// imgManager.requestImage( +// for: asset, +// targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), +// contentMode: .default, +// options: options +// ) { image, info in +// if info?[PHImageResultIsDegradedKey] as? Bool == true { +// return +// } +// completion?(image?.jpegData(compressionQuality: 0.6)) +// } +// } +// +// func requestVideo( +// asset: PHAsset, +// completion: ((Data?, Data?) -> Void)? +// ) { +// let options = PHVideoRequestOptions() +// options.deliveryMode = .fastFormat +// options.isNetworkAccessAllowed = true +// +// PHCachingImageManager().requestAVAsset( +// forVideo: asset, +// options: options +// ) { [weak self] (newAsset, _, _) in +// guard let newAsset = newAsset as? AVURLAsset +// else { +// return +// } +// +// let videoData = try? Data(contentsOf: newAsset.url) +// +// self?.requestImage(asset: asset) { data in +// completion?(videoData, data) +// } +// } +// } +//} + +//private extension MediaPickerService { +// func fetchData(for assets: [PHAsset]) async { +// var dataArray: [FileResult] = [] +// +// for asset in assets { +// if asset.mediaType == .image, +// let imageData = await requestImageData(asset: asset) { +// let previewImage = UIImage(data: imageData) +// dataArray.append(.init( +// data: imageData, +// type: .image, +// preview: previewImage) +// ) +// } +// +// if asset.mediaType == .video, +// let (videoData, imageData) = await requestVideo(asset: asset) { +// let previewImage = UIImage(data: imageData) +// dataArray.append(.init( +// data: videoData, +// type: .video, +// preview: previewImage) +// ) +// } +// } +// +// onPreparedDataCallback?(dataArray) +// } +// +// func requestImageData(asset: PHAsset) async -> Data? { +// let imgManager = PHImageManager.default() +// +// let options = PHImageRequestOptions() +// options.isSynchronous = false +// options.deliveryMode = .opportunistic +// options.isNetworkAccessAllowed = true +// options.resizeMode = .exact +// +// return await withCheckedContinuation { continuation in +// imgManager.requestImage( +// for: asset, +// targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), +// contentMode: .default, +// options: options +// ) { image, info in +// if info?[PHImageResultIsDegradedKey] as? Bool == true { +// return +// } +// +// if let imageData = image?.jpegData(compressionQuality: 0.6) { +// continuation.resume(returning: imageData) +// } else { +// continuation.resume(returning: nil) +// } +// } +// } +// } +// +// func requestVideo(asset: PHAsset) async -> (Data, Data)? { +// guard let videoData = await requestVideoData(asset: asset), +// let imageData = await requestImageData(asset: asset) +// else { +// return nil +// } +// +// return (videoData, imageData) +// } +// +// func requestVideoData(asset: PHAsset) async -> Data? { +// let options = PHVideoRequestOptions() +// options.deliveryMode = .fastFormat +// options.isNetworkAccessAllowed = true +// +// return await withCheckedContinuation { continuation in +// PHCachingImageManager().requestAVAsset( +// forVideo: asset, +// options: options +// ) { (newAsset, _, _) in +// guard let newAsset = newAsset as? AVURLAsset +// else { +// return +// } +// +// let videoData = try? Data(contentsOf: newAsset.url) +// +// continuation.resume(returning: videoData) +// } +// } +// } +//} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift new file mode 100644 index 000000000..865da0ddb --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift @@ -0,0 +1,30 @@ +// +// NetworkFileManager.swift +// +// +// Created by Stanislav Jelezoglo on 26.02.2024. +// + +import Foundation + +final class NetworkFileManager: NetworkFileManagerProtocol { + private let baseApi: ApiManagerProtocol = BaseApiManager() + + func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String { + switch type { + case .base: + return try await baseApi.uploadFile(data: data) + } + } + + func downloadFile(_ id: String, type: String) async throws -> Data { + guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { + throw FileManagerError.cantDownloadFile + } + + switch netwrokProtocol { + case .base: + return try await baseApi.downloadFile(id: id) + } + } +} 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 + } +} From b6a7ed54510dd238fcd680fabb54b32050f9f91c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 29 Feb 2024 12:20:06 +0200 Subject: [PATCH 002/123] [trello.com/c/nh7HisCi] feat: send files to server --- .../xcshareddata/swiftpm/Package.resolved | 9 + Adamant/App/AppDelegate.swift | 3 + .../Modules/Chat/View/Helpers/ChatFile.swift | 6 +- .../Content/Cell/ChatFileTableViewCell.swift | 6 +- .../Chat/ViewModel/ChatMessageFactory.swift | 44 +++-- .../ViewModel/ChatMessagesListFactory.swift | 12 +- .../Chat/ViewModel/ChatViewModel.swift | 172 ++++++++++++++++-- .../DataProviders/ChatsProvider.swift | 14 ++ .../AdamantChatTransactionService.swift | 44 ++++- .../DataProviders/AdamantChatsProvider.swift | 111 ++++++++++- .../Shared.xcassets/files/Contents.json | 6 + .../downloadIcon.imageset/Contents.json | 0 .../downloadIcon.imageset/downloadIcon.png | Bin .../downloadIcon.imageset/downloadIcon@2x.png | Bin .../downloadIcon.imageset/downloadIcon@3x.png | Bin .../file-default-box.imageset/Contents.json | 21 +++ .../file-default-box.imageset/default.png | Bin 0 -> 929 bytes .../file-jpg-box.imageset/Contents.json | 0 .../file-jpg-box.imageset/file-jpg-box.png | Bin FilesStorageKit/Package.swift | 4 +- .../FilesStorageKit/FilesStorageKit.swift | 65 ++++++- .../FilesStorageKit/Models/FileResult.swift | 1 + .../Models/NetworkFileProtocolType.swift | 12 ++ .../NetworkFileManagerProtocol.swift | 4 - .../API Managers/BaseApiManager.swift | 27 --- .../API Managers/UploadCareApiManager.swift | 29 +++ .../Services/NetworkFileManager.swift | 10 +- .../{ => Pickers}/DocumentPickerService.swift | 3 +- .../{ => Pickers}/MediaPickerService.swift | 18 +- 29 files changed, 521 insertions(+), 100 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/Contents.json rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/downloadIcon.imageset/Contents.json (100%) rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/downloadIcon.imageset/downloadIcon.png (100%) rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/downloadIcon.imageset/downloadIcon@2x.png (100%) rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/downloadIcon.imageset/downloadIcon@3x.png (100%) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/default.png rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/file-jpg-box.imageset/Contents.json (100%) rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/{ => files}/file-jpg-box.imageset/file-jpg-box.png (100%) create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift rename FilesStorageKit/Sources/FilesStorageKit/Services/{ => Pickers}/DocumentPickerService.swift (94%) rename FilesStorageKit/Sources/FilesStorageKit/Services/{ => Pickers}/MediaPickerService.swift (96%) diff --git a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved index 88a2fda6f..6252fdfc6 100644 --- a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -325,6 +325,15 @@ "version": "2.7.1" } }, + { + "package": "Uploadcare", + "repositoryURL": "https://github.com/uploadcare/uploadcare-swift.git", + "state": { + "branch": "master", + "revision": "3ab0706a726abcb9c935347add80f855c2a08f12", + "version": null + } + }, { "package": "Web3swift", "repositoryURL": "https://github.com/skywinder/web3swift.git", diff --git a/Adamant/App/AppDelegate.swift b/Adamant/App/AppDelegate.swift index 64b21f67a..f6a45625b 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 { @@ -160,6 +161,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: 4. Setup dialog service dialogService.setup(window: window) + _ = FilesStorageKit.shared.isCached("") + // MARK: 5. Show login let login = screensFactory.makeLogin() let welcomeIsShown = UserDefaults.standard.bool(forKey: StoreKey.application.welcomeScreensIsShown) diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index ad47d0ccf..800d5fbb4 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -13,12 +13,16 @@ struct ChatFile: Equatable, Hashable { var file: RichMessageFile.File var previewData: Data var isDownloading: Bool + var isUploading: Bool var isCached: Bool + var storage: String static let `default` = Self( file: .init([:]), previewData: Data(), isDownloading: false, - isCached: false + isUploading: false, + isCached: false, + storage: .empty ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift index fae10c5dd..1bd5a2b09 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift @@ -134,9 +134,9 @@ private extension ChatFileTableViewCell { func update() { iconImageView.image = UIImage(data: model.previewData) - downloadImageView.isHidden = model.isCached || model.isDownloading + downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading - if model.isDownloading { + if model.isDownloading || model.isUploading { spinner.startAnimating() } else { spinner.stopAnimating() @@ -152,5 +152,5 @@ private extension ChatFileTableViewCell { private let nameFont = UIFont.systemFont(ofSize: 15) private let sizeFont = UIFont.systemFont(ofSize: 13) -private let imageSize: CGFloat = 90 +private let imageSize: CGFloat = 70 private let stackSpacing: CGFloat = 12 diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index fb0e18fed..7f68bded7 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -72,7 +72,8 @@ struct ChatMessageFactory { expireDate: inout Date?, currentSender: SenderType, dateHeaderOn: Bool, - topSpinnerOn: Bool + topSpinnerOn: Bool, + uploadingFilesIDs: [String] ) -> ChatMessage { let sentDate = transaction.sentDate ?? .now let senderModel = ChatSender(transaction: transaction) @@ -96,7 +97,8 @@ struct ChatMessageFactory { content: makeContent( transaction, isFromCurrentSender: currentSender.senderId == senderModel.senderId, - backgroundColor: backgroundColor + backgroundColor: backgroundColor, + uploadingFilesIDs: uploadingFilesIDs ), backgroundColor: backgroundColor, bottomString: makeBottomString( @@ -116,7 +118,8 @@ private extension ChatMessageFactory { func makeContent( _ transaction: ChatTransaction, isFromCurrentSender: Bool, - backgroundColor: ChatMessageBackgroundColor + backgroundColor: ChatMessageBackgroundColor, + uploadingFilesIDs: [String] ) -> ChatMessage.Content { switch transaction { case let transaction as MessageTransaction: @@ -137,10 +140,12 @@ private extension ChatMessageFactory { if transaction.additionalType == .file, !transaction.isTransferReply() { + print("makeFileContent") return makeFileContent( transaction, isFromCurrentSender: isFromCurrentSender, - backgroundColor: backgroundColor + backgroundColor: backgroundColor, + uploadingFilesIDs: uploadingFilesIDs ) } @@ -294,13 +299,16 @@ private extension ChatMessageFactory { func makeFileContent( _ transaction: RichMessageTransaction, isFromCurrentSender: Bool, - backgroundColor: ChatMessageBackgroundColor + backgroundColor: ChatMessageBackgroundColor, + uploadingFilesIDs: [String] ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" - let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." + let decodedMessage = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty let decodedMessageMarkDown = Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() + let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] + let storage: String = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set @@ -312,21 +320,27 @@ private extension ChatMessageFactory { ? transaction.recipientAddress : transaction.senderAddress + let chatFiles = files.map { + ChatFile.init( + file: RichMessageFile.File.init($0), + previewData: FilesStorageKit.shared.getPreview( + for: $0[RichContentKeys.file.file_id] as? String ?? .empty, + type: $0[RichContentKeys.file.file_type] as? String ?? .empty + ), + isDownloading: false, + isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), + isCached: FilesStorageKit.shared.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), + storage: storage + ) + } + return .file(.init(value: .init( id: id, isFromCurrentSender: isFromCurrentSender, reactions: reactions, content: .init( id: id, - files: files.map { ChatFile.init( - file: RichMessageFile.File.init($0), - previewData: FilesStorageKit.shared.getPreview( - for: $0[RichContentKeys.file.file_id] as? String ?? "", - type: $0[RichContentKeys.file.file_type] as? String ?? "" - ), - isDownloading: false, - isCached: FilesStorageKit.shared.isCached($0[RichContentKeys.file.file_id] as? String ?? "") - )}, + files: chatFiles, isHidden: false ), address: address, diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift index f00be5a6e..e656e505d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift @@ -22,7 +22,8 @@ actor ChatMessagesListFactory { transactions: [ChatTransaction], sender: ChatSender, isNeedToLoadMoreMessages: Bool, - expirationTimestamp minExpTimestamp: inout TimeInterval? + expirationTimestamp minExpTimestamp: inout TimeInterval?, + uploadingFilesIDs: [String] ) -> [ChatMessage] { assert(!Thread.isMainThread, "Do not process messages on main thread") @@ -44,7 +45,8 @@ actor ChatMessagesListFactory { transactions: transactionsWithoutReact ), topSpinnerOn: isNeedToLoadMoreMessages && index == .zero, - willExpireAfter: &expTimestamp + willExpireAfter: &expTimestamp, + uploadingFilesIDs: uploadingFilesIDs ) if let timestamp = expTimestamp, timestamp < minExpTimestamp ?? .greatestFiniteMagnitude { @@ -62,7 +64,8 @@ private extension ChatMessagesListFactory { sender: SenderType, dateHeaderOn: Bool, topSpinnerOn: Bool, - willExpireAfter: inout TimeInterval? + willExpireAfter: inout TimeInterval?, + uploadingFilesIDs: [String] ) -> ChatMessage { var expireDate: Date? let message = chatMessageFactory.makeMessage( @@ -70,7 +73,8 @@ private extension ChatMessagesListFactory { expireDate: &expireDate, currentSender: sender, dateHeaderOn: dateHeaderOn, - topSpinnerOn: topSpinnerOn + topSpinnerOn: topSpinnerOn, + uploadingFilesIDs: uploadingFilesIDs ) willExpireAfter = expireDate?.timeIntervalSince1970 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index a2b7a46db..628a7ce7f 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -128,6 +128,10 @@ final class ChatViewModel: NSObject { didSet { updateDownloadingFiles(&messages) } } + private var uploadingFilesIDs: [String] = [] { + didSet { updateUploadingFiles(&messages) } + } + init( chatsProvider: ChatsProvider, markdownParser: MarkdownParser, @@ -287,32 +291,71 @@ final class ChatViewModel: NSObject { } Task { - let files: [RichMessageFile.File] = files.compactMap { + var richFiles: [RichMessageFile.File] = files.compactMap { RichMessageFile.File.init( - file_id: "https://i.ibb.co/YXcWnC4/IMG-5-FFA7-DBAE0-E7-1.jpg\(Int.random(in: 0...1000))", - file_type: "jpg", + file_id: $0.url.absoluteString, + file_type: $0.extenstion, file_size: $0.size, - preview_id: "https://i.ibb.co/Jqvd61W/IMG-5-FFA7-DBAE0-E7-1.jpg", + preview_id: nil, file_name: $0.name ) } - let message: AdamantMessage = .richMessage( - payload: RichMessageFile(files: files, storage: "imgbb", comment: text) + + let messageLocally: AdamantMessage = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) ) - guard await validateSendingMessage(message: message) else { return } + guard await validateSendingMessage(message: messageLocally) else { return } replyMessage = nil filesPicked = nil do { - _ = try await chatsProvider.sendMessage( + let txLocally = try await chatsProvider.sendFileMessageLocally( + messageLocally, + recipientId: partnerAddress, + from: chatroom + ) + + richFiles.forEach { file in + uploadingFilesIDs.append(file.file_id) + } + + for file in files { + let id = try await FilesStorageKit.shared.uploadFile(file) + + let oldId = file.url.absoluteString + uploadingFilesIDs.removeAll(where: { $0 == oldId }) + updateUploadingFileId(&messages, oldId: oldId, newId: id) + + if let index = richFiles.firstIndex( + where: { $0.file_id == oldId } + ) { + richFiles[index].file_id = id + } + } + + let message: AdamantMessage = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + + _ = try await chatsProvider.sendFileMessage( message, recipientId: partnerAddress, + transactionLocaly: txLocally.0, + context: txLocally.1, from: chatroom ) } catch { - await handleMessageSendingError(error: error, sentText: "text") + await handleMessageSendingError(error: error, sentText: text) } }.stored(in: tasksStorage) } @@ -650,7 +693,29 @@ final class ChatViewModel: NSObject { } func processFile(file: ChatFile) { - downloadingFilesID.append(file.file.file_id) + print("processFile=\(file)") + Task { + if !file.isCached { + defer { + downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) + } + downloadingFilesID.append(file.file.file_id) + + do { + _ = try await FilesStorageKit.shared.cacheFile( + id: file.file.file_id, + storage: file.storage, + fileType: file.file.file_type ?? "" + ) + + updatePreviewForFile(&messages, id: file.file.file_id) + } catch { + dialog.send(.alert(error.localizedDescription)) + } + return + } + // + } } func presentActionMenu() { @@ -676,16 +741,10 @@ final class ChatViewModel: NSObject { } presentFilePicker.send(action) -// if let image = result.first?.preview { -// FilesStorageKit.shared.cacheImage(id: "1", image: image) -// } dialog.send(.progress(false)) filesPicked = result print("data=\(result.count)") - if let data = result.first?.preview { - // try await FilesStorageKit.shared.uploadFile(data, type: .) - } } catch { dialog.send(.progress(false)) dialog.send(.alert(error.localizedDescription)) @@ -814,7 +873,8 @@ private extension ChatViewModel { transactions: chatTransactions, sender: sender, isNeedToLoadMoreMessages: isNeedToLoadMoreMessages, - expirationTimestamp: &expirationTimestamp + expirationTimestamp: &expirationTimestamp, + uploadingFilesIDs: uploadingFilesIDs ) await setupNewMessages( @@ -1034,6 +1094,36 @@ private extension ChatViewModel { } } + func updateUploadingFiles(_ messages: inout [ChatMessage]) { + messages.indices.forEach { index in + messages[index].getFiles().forEach { file in + messages[index].setUploading( + for: file.file.file_id, + value: uploadingFilesIDs.contains(file.file.file_id) + ) + } + } + } + + func updateUploadingFileId( + _ messages: inout [ChatMessage], + oldId: String, + newId: String + ) { + messages.indices.forEach { index in + messages[index].updateID(old: oldId, new: newId) + } + } + + func updatePreviewForFile( + _ messages: inout [ChatMessage], + id: String + ) { + messages.indices.forEach { index in + messages[index].updatePreview(for: id) + } + } + func isNewReaction(old: [ChatTransaction], new: [ChatTransaction]) -> Bool { guard let processedDate = old.getMostRecentElementDate(), @@ -1098,6 +1188,54 @@ private extension ChatMessage { content = .file(.init(value: model)) } + + mutating func setUploading(for fileId: String, value: Bool) { + guard case let .file(fileModel) = content else { return } + var model = fileModel.value + + guard let index = model.content.files.firstIndex( + where: { $0.file.file_id == fileId } + ) else { return } + + model.content.files[index].isUploading = value + + content = .file(.init(value: model)) + } + + mutating func updateID(old oldId: String, new newId: String) { + guard case let .file(fileModel) = content else { return } + var model = fileModel.value + + guard let index = model.content.files.firstIndex( + where: { $0.file.file_id == oldId } + ) else { return } + + model.content.files[index].file.file_id = newId + model.content.files[index].previewData = FilesStorageKit.shared.getPreview( + for: newId, + type: model.content.files[index].file.file_type ?? "" + ) + model.content.files[index].isCached = FilesStorageKit.shared.isCached(newId) + + content = .file(.init(value: model)) + } + + mutating func updatePreview(for id: String) { + guard case let .file(fileModel) = content else { return } + var model = fileModel.value + + guard let index = model.content.files.firstIndex( + where: { $0.file.file_id == id } + ) else { return } + + model.content.files[index].previewData = FilesStorageKit.shared.getPreview( + for: id, + type: model.content.files[index].file.file_type ?? "" + ) + model.content.files[index].isCached = FilesStorageKit.shared.isCached(id) + + content = .file(.init(value: model)) + } } private extension Sequence where Element == ChatTransaction { diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index 9a6532da3..e8675c984 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -217,6 +217,20 @@ 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 -> (RichMessageTransaction, NSManagedObjectContext) + + func sendFileMessage( + _ message: AdamantMessage, + recipientId: String, + transactionLocaly: RichMessageTransaction, + context: NSManagedObjectContext, + from chatroom: Chatroom? + ) async throws -> ChatTransaction + // MARK: - Delete local message func cancelMessage(_ message: ChatTransaction) async throws func isMessageDeleted(id: String) -> Bool 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 245831a32..58790cbea 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -852,19 +852,110 @@ extension AdamantChatsProvider { ) } -// let transaction = try await sendMessageToServer( -// senderId: loggedAccount.address, -// recipientId: recipientId, -// transaction: transactionLocaly, -// type: message.chatType, -// keypair: keypair, -// context: context, -// from: chatroom -// ) + let transaction = try await sendMessageToServer( + senderId: loggedAccount.address, + recipientId: recipientId, + transaction: transactionLocaly, + type: message.chatType, + keypair: keypair, + context: context, + from: chatroom + ) return transactionLocaly } + func sendFileMessageLocally( + _ message: AdamantMessage, + recipientId: String, + from chatroom: Chatroom? + ) async throws -> (RichMessageTransaction, NSManagedObjectContext) { + 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, context) + } + + func sendFileMessage( + _ message: AdamantMessage, + recipientId: String, + transactionLocaly: RichMessageTransaction, + context: NSManagedObjectContext, + 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 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 + } + private func sendTextMessageLocaly( text: String, isMarkdown: Bool, @@ -919,7 +1010,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) 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/downloadIcon.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/Contents.json similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/Contents.json rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/Contents.json diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon.png similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon.png rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon.png diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@2x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@2x.png similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@2x.png rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@2x.png diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@3x.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@3x.png similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/downloadIcon.imageset/downloadIcon@3x.png rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/downloadIcon.imageset/downloadIcon@3x.png diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json new file mode 100644 index 000000000..343d59471 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "default.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/file-default-box.imageset/default.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/default.png new file mode 100644 index 0000000000000000000000000000000000000000..8d219f70193c0b82ce2722b51d0a58fac926a37f GIT binary patch literal 929 zcmV;S177@zP)C0001HP)t-s00011 zuJ&KE_F1s@WVQDieAryE_G7g7Te0?AvG!ZB_FJ*`Te0?5uJ&ZL_B@v6V6^sJvi4c9 z_G7j7R00bEU5aa|vkPYxbK|TNkxd0U80#J|( zKtUz|3F}+{3X}jh8F1VZFk4Wt@(jS11PGQq0>_tgRH8+|P@n^_igUc+eRRup0QD&p zZH;=JSZptG?FB7|;+#x?Y7Yw6RCT;KT)Uk|0|*E^z~3_f0?i&24#5JuHs3FdU<*2c z>U%%{!-BCpcmbdj05CUaz>b2*DWnGMlzX5DDE2@PIGh2RJ<#s}06X0t+|LL6JJkTi zDJXZK`rI8Ukp5591DJg{o<;%yJ}5|Y1U!W8@w7eF`Lgsseb%V~)!rX=%<#*AJxBv! zKKGTsmV^L)6|bbw0ra^yfB_6(00S7n00uCC4B*``7{~=6V%%ic$GaRB1AI5#d%W5< z3wuoIJNR=RT?pzS+(8@nL;yUjiQ`){1`+GFDh{_8ghRe1$N+vAH*T`sRz{!Larg-# zKomE9%2a?p1~7mDz9HaA0C@6@0SsUO0~kP;(j&LK1M>mgy_?*D2cIwx3IIl@^!?_+G70CcTwk|&)2q^Oa@u&Lut|0N_nf@KnEEe&z zk6^JFzyJo&0zx@X_?9~mVm)@lL_nBSl1aYdJU z1FdEheKar?6ImJ4IkjY41i<9J1k(2i3X+T_$^i@kis%91h5#XH1c3LDFz*7uDkRKG z0N8|t*@%Af8It8y`lHv7B+qa1pZ%^9 URL? { + cachedFiles[id] + } + public func getPreview(for id: String, type: String) -> Data { guard let data = cachedImages[id]?.jpegData(compressionQuality: 1.0) else { - return UIImage.asset(named: "file-jpg-box")?.jpegData(compressionQuality: 1.0) ?? Data() + return UIImage.asset(named: "file-default-box")?.jpegData(compressionQuality: 1.0) ?? Data() } return data } public func isCached(_ id: String) -> Bool { - cachedImages[id] != nil + cachedImages[id] != nil || cachedFiles[id] != nil + } + + public func uploadFile(_ file: FileResult) async throws -> String { + _ = file.url.startAccessingSecurityScopedResource() + + let data = try Data(contentsOf: file.url) + + if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { + cacheImage(id: file.url.absoluteString, image: UIImage(data: data) ?? UIImage()) + } + + let id = try await networkFileManager.uploadFiles(data, type: .uploadCareApi) + + if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { + cacheImage(id: id, image: UIImage(data: data) ?? UIImage()) + } + + file.url.stopAccessingSecurityScopedResource() + return id } - public func uploadFile(_ data: Data, type: NetworkFileProtocolType) async throws -> String { - try await networkFileManager.uploadFiles(data, type: type) + public func cacheFile( + id: String, + storage: String, + fileType: String? + ) async throws -> Data { + let data = try await networkFileManager.downloadFile(id, type: storage) + + if imageExtensions.contains(fileType?.lowercased() ?? defaultFileType) { + cacheImage(id: id, image: UIImage(data: data) ?? UIImage()) + } else { + try cacheFile(id: id, data: data) + } + + return data } } @@ -99,4 +136,24 @@ private extension FilesStorageKit { } } } + + func cacheFile(id: String, data: Data) throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(id) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + cachedFiles[id] = fileURL + } } + +private let defaultFileType = "" +private let cachePath = "downloads" diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift index 31432a400..402b83c51 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift @@ -20,4 +20,5 @@ public struct FileResult { public let preview: UIImage? public let size: Int64 public let name: String? + public let extenstion: String? } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift new file mode 100644 index 000000000..747f19573 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift @@ -0,0 +1,12 @@ +// +// NetworkFileProtocolType.swift +// +// +// Created by Stanislav Jelezoglo on 26.02.2024. +// + +import Foundation + +public enum NetworkFileProtocolType: String { + case uploadCareApi +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift index f2ea7daf1..6352ac35f 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift @@ -7,10 +7,6 @@ import Foundation -public enum NetworkFileProtocolType: String { - case base -} - protocol NetworkFileManagerProtocol { func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String func downloadFile(_ id: String, type: String) async throws -> Data diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift deleted file mode 100644 index b8d3ba2d9..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/BaseApiManager.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// BaseApiManager.swift -// -// -// Created by Stanislav Jelezoglo on 20.02.2024. -// - -import Foundation - -final class BaseApiManager: ApiManagerProtocol { - //private var uploadcare: Uploadcare - -// init() { -// self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") -// } - - func uploadFile(data: Data) async throws -> String { - "" -// var fileForUploading = uploadcare.file(fromData: data) -// try await fileForUploading.upload(withName: "test", store: .auto) -// return fileForUploading.fileId - } - - func downloadFile(id: String) async throws -> Data { - Data() - } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift new file mode 100644 index 000000000..2edade542 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift @@ -0,0 +1,29 @@ +// +// BaseApiManager.swift +// +// +// Created by Stanislav Jelezoglo on 20.02.2024. +// + +import Foundation +import Uploadcare + +final class UploadCareApiManager: ApiManagerProtocol { + private var uploadcare: Uploadcare + + init() { + self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") + } + + func uploadFile(data: Data) async throws -> String { + let fileForUploading = uploadcare.file(fromData: data) + try await fileForUploading.upload(withName: String.random(length: 6), store: .auto) + return fileForUploading.fileId + } + + func downloadFile(id: String) async throws -> Data { + let request = URLRequest(url: URL(string: "https://ucarecdn.com/\(id)/")!) + let (data, _) = try await URLSession.shared.data(for: request) + return data + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift index 865da0ddb..506637e42 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift @@ -8,12 +8,12 @@ import Foundation final class NetworkFileManager: NetworkFileManagerProtocol { - private let baseApi: ApiManagerProtocol = BaseApiManager() + private let uploadCareApi: ApiManagerProtocol = UploadCareApiManager() func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String { switch type { - case .base: - return try await baseApi.uploadFile(data: data) + case .uploadCareApi: + return try await uploadCareApi.uploadFile(data: data) } } @@ -23,8 +23,8 @@ final class NetworkFileManager: NetworkFileManagerProtocol { } switch netwrokProtocol { - case .base: - return try await baseApi.downloadFile(id: id) + case .uploadCareApi: + return try await uploadCareApi.downloadFile(id: id) } } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift similarity index 94% rename from FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift rename to FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift index af0cf9330..29ec1ae07 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/DocumentPickerService.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift @@ -39,7 +39,8 @@ extension DocumentPickerService: UIDocumentPickerDelegate { type: .other, preview: nil, size: (try? getFileSize(from: $0)) ?? .zero, - name: $0.lastPathComponent + name: $0.lastPathComponent, + extenstion: $0.pathExtension ) } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift similarity index 96% rename from FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift rename to FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift index 42ec2aa71..ec93f4a91 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/MediaPickerService.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift @@ -64,7 +64,8 @@ private extension MediaPickerService { type: .image, preview: preview, size: fileSize, - name: itemProvider.suggestedName + name: itemProvider.suggestedName, + extenstion: "JPG" ) ) } @@ -76,12 +77,15 @@ private extension MediaPickerService { let preview = getThumbnailImage(forUrl: url) - dataArray.append(.init( - url: url, - type: .video, - preview: preview, - size: fileSize, - name: itemProvider.suggestedName) + dataArray.append( + .init( + url: url, + type: .video, + preview: preview, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: "JPG" + ) ) } } From f71458f679e7b7e594b5530e69611b9d5fccd3e6 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 29 Feb 2024 12:57:25 +0200 Subject: [PATCH 003/123] [trello.com/c/nh7HisCi] feat: check some extensions of files --- .../Modules/Chat/View/Helpers/ChatFile.swift | 5 +- .../Content/Cell/ChatFileTableViewCell.swift | 6 ++- .../files/file-image-box.imageset/249.jpg | Bin 0 -> 103412 bytes .../file-image-box.imageset/Contents.json | 21 ++++++++ .../files/file-pdf-box.imageset/Contents.json | 21 ++++++++ .../files/file-pdf-box.imageset/pdf-74.jpg | Bin 0 -> 5321 bytes .../files/file-zip-box.imageset/Contents.json | 21 ++++++++ .../files/file-zip-box.imageset/zip-128.jpg | Bin 0 -> 7021 bytes .../FilesStorageKit/FilesStorageKit.swift | 47 ++++++++++-------- 9 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/249.jpg create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/pdf-74.jpg create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/zip-128.jpg diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 800d5fbb4..2c14a6003 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -8,10 +8,11 @@ import Foundation import CommonKit +import UIKit struct ChatFile: Equatable, Hashable { var file: RichMessageFile.File - var previewData: Data + var previewData: UIImage? var isDownloading: Bool var isUploading: Bool var isCached: Bool @@ -19,7 +20,7 @@ struct ChatFile: Equatable, Hashable { static let `default` = Self( file: .init([:]), - previewData: Data(), + previewData: nil, isDownloading: false, isUploading: false, isCached: false, diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift index 1bd5a2b09..eeaa232ba 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift @@ -133,7 +133,7 @@ private extension ChatFileTableViewCell { } func update() { - iconImageView.image = UIImage(data: model.previewData) + iconImageView.image = model.previewData downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading if model.isDownloading || model.isUploading { @@ -145,7 +145,9 @@ private extension ChatFileTableViewCell { let fileType = model.file.file_type ?? "" let fileName = model.file.file_name ?? "UNKNWON" - nameLabel.text = "\(fileName.uppercased()).\(fileType.uppercased())" + nameLabel.text = fileName.contains(fileType) + ? fileName + : "\(fileName.uppercased()).\(fileType.uppercased())" sizeLabel.text = "\(model.file.file_size) kb" } } diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/249.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/249.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f0b551f6061c2251fb1a34e56f9e6c3450c1737 GIT binary patch literal 103412 zcmcG$30zZW+CF>|Aj9ggZ&-H!&HW)}W0{blC z0}SM&gMPCjSRQpv+DVThemrOO{LSZI``G&5-m$^J07D}~d<=XPf|cbiS2y;t&w~#a zRPLlV_s@S4b@|UpMpdHc5zzQ_@>p!q(|G>&r4>Tj0nssU&wP$K>Br7|P zmRNc$BO~V-O%PWqpk`CkMlwgzG^#euaGPpLv2=H7QdSyAp{+>cO1(2xZK~2ZX`FA{ z@MuR{M^`p$@SgZy^i9!)z%*W?*8oXYY!EtWYki%W7Imktne-#M;p!K z*$&ibn;D))L`4UIvmS67^PpkoDcP#n<-k*>wCM zZ)Q&nQm!GVH+}NMEnolHcO)wlTt&@BuFBD6EX0B6I>oABb(9||iWE&{u=7_1i-P&c zV;SrW5p}Gmj%s_sj%PoROA!Rw4swTLY=`VQs|gzWv^bXRaq?50ccylL+)0@;n(Aoo ziYgnT9VJ+^FcY&P-7Zk-ol{hDTXa)u{Qg9Mc5Qu0w+Dbt7fDaz0USB!v*+E%RmXpQ z{pEKvAa3xlG)^{!cDGsutAm5_7wzvZvcXA=DHOUMhP}oXye81)=pvT{(jJ+VtW`(_ z(k|H~EzUcOCK$Y1iXmWmQYQvKifemVL29@_Jy4t>;1-QdF}>VLzAJUEDVbv9oi)|I z#S_~OO!`l_-wq4w1%FuyUlfaMzT&situnA zRV*oFrIa|xPO8{DP1Odkm!@QA78ceS%oPTMR>STRMW$zf?zPmI@U%&zPRbxPR~=bC zcpvi-EVMIwmnp*J2l4|&Mvce(u6dU4^786;J612|H>QLyBvXXaM^P0aj9`;l`NN9i z0Ql$rQh)LDm|rYCP(45#@Ia;C0ovAvATfJAdu%oK1DsXPs$f2Y&7js&0pZ|E5#YBfZ6_k3XwG9J#A;(GMq~4h$nNI`QOl>5yhO?N(9pGhh=Gpm+{K#W@oV&!eg)akF z%Dd_!{dM%M?Gv4<+Blx8#~t$`^S-kqY4s*AawgqZ!+^kY#>>?8MOIN2jL^!ccgH)_ z?=nJi{u#(O+3WWKO!0%Tcxrpt`$+Uq?HgN zb_P3-Gat#$VAxS>hIUlaYB=i*MqEF`d4R3ba@mn>z+egeUP}#x$nZ!;WOSw+qsiWq) zPQUzuvJ~h5{^2_cN=P_^U93u`c-jHcb(R!84Gjwqt zGqAQbS9v-KO;e zq3DQ%sK5nd@PqGs`H*cj8rvZ|9@SQ3mo-KOzOu_=)b4maf4MqF{3S1CERK^A-AG$M z2GPqJ^6KF+&j;NUI(w(g#5ZOEuqH7=2TAj~)Y$Q0uN!78y(Fq6Pq}ee!9}{tKOQ*U z8Sa+y(B9hkU>=m7lMRgE+1dTOA$U!1{^sikh!t@tWkAz_iXlgzgZ7+7cBF{PpfXn2 z4BA-D00I|p$oAqr(N=DzhUYxkFe*@Nv_w}mENbjCH5`eNxSSF=D%7r5VA!HjrGdnzyJ0% zs!Rig4zdlU+5mn;$=5Hk1rlfH%tN_h)@Z<2;4+Ov*8cQdITV@PeyUEjuppji_B3!c zDKm$5wq(WnGuvo&H0>o&$9ZWi3?pV;jxCBPa8C9t-D!DP7IK%R-W@ny*?NsT5ny<0 zkGnz<)|)q7ROs{WyN~Nh0Hb#(* z+npXa8O-KP1RC$O;@b!vLs)8$^j&t|+x;lk&|Lk@4{G1+Z0g=U?^b{L*^u89Uge|+ zM>vwQy>U$C%s_qx(v|_rK$W+FB`MHfQIv@bYds0&{&SdvKmTxY}?brg4W4&bH@N07!gJ(A6=K~;8L&x1+dHu6pDWU-0L!P>-q!b5tx zbPwoayr(rL==7u1-A(Vk^VLHXqE!w`a$SaKO4P&9GtVWUU5X$e4Jhzdva$}9_s%r6 zXl*pTz4#`}Uu&B#*Dh42C@1U0_hKx?U0fTMb^4H?(N&9Om(VfHZ@Nl=86EK(9-dy= z89vCi;-^H~hkMuvftsT#!g2D0`ZdyO6@RmG{ndk6vVnNWV?M428*z;Y+FM3%t?dB# zq>T!lDrdgUjtV8J3wqmufPDXD?ad>sX4Sf;{c@UIu#pkA%x}N!Po~Wc(;WUi6T0yov&?_RoSIzE}6`;VRT|gjm$m zLl^tp9e`LUkW>f^K%EIh7C7Mq6+a!lOZ-<@N$Xh2_Fwspr{D=^0a5+vK+)2G9S_S| zY7495wbxZdFU#(4O#|@?5RZ`-Bur*pAfcWqF+LFG_b|qjUs80(+xO!lm+Hj2-x-PNtSSH0! z54X@l{mVusOH!I$r6pb?l^p2w7?o_B_=&KN(;ZuY?k1ZlaAaU^yF#YJYDQ8#x);Ok46UN7f}!k53x$uOSRo-}l^$^$jSo>?9?Li; zLX)d8*%0*IGQlc@h1oEWDc07KH4ANBfdaC282S?^GdeD|)q$S5r1h)gX4h3XFDO}o z(Gm>1Dd@hBK~fm7(hipuG^m|i<7^L%A3SRq!IDFkDza0=*`j1cSVu<*^l&=sv5;Up zlkGuO*2d>~e+s%gCEI|Bfoe*&baxU>q^3#-@4~!98kitp1?09wKi46j7PwqaS)Oi+ z!n(WY33Z%qog6CK(x6hfbd`=_;wKtE%rpukbSVv0q`0bk|7D`RHhqdaH!Wkxh1Ibw z*7lk8DdX-ClGBZ@g)r&tJd+=8$hCa0=h-752vki_Bv5L)cy?Ssg)W<{ZDA!y0|y2E z-~eq$7bu}j03}ws>kYULdBlteR!@zYzmc{pkd+QAP8I<@)vzShhLL(Z44f_{-@Th` zu017EgHpCv~vM(LV4S>nQH#sSUY z?UT+H1UjwJ3mC$iZjnJJdAUyHtnR9mO%IRTbX5t>JDcl$OPL-t`0>>lLV^TU!Q{x} z5kM4Xx{re%@iW--+0@KoO-t(_=NS#=7LXsECmd+n$j%6*teJbkHY-!JRiN3Lb$u)@ z9QwlkNDONc-CpdX>#|P=rbkcoNEV=m8HGVNW*Vi{V&(^es~)sCU3GA6BxRN67eenm=S? znLKgYaAP*f=};JSgnX;mU=Q7Nl{FPe!Z>_SHB5d^d^EBIXjn}El0xE9nuw^)DJ<=6qu~`?I!4H2E=Y{)O=J&R z%xqIKG?Wo_n?dIe^1DUZOH~hXnN~-BTU)-h~g|3PZu?4(3wg zf+9r@J3=5SVQsHCQpAXBpK5a+Ww-Y*8}NN&_83#UV=J~5iX$8A%nz(pt*dx)@v5(0 z1?M?J96crU8n~dc%7!`>I<3I{Ff%-Et*U1`v29u(1RY=q8NnWC-Wd`;+wMO8@>_2_ zjw_{#6N|^<7GTz2*UZkC1BPB3h`(qmTZ_)tMbHB0ISW}G!z+x)@Il!Z^KFrwqXX1A zkZSh2MUTe0c59j))l9F0nG!QyX40=m!-V&mSDdVD`dVXKazJQcG?8Pus`3(B}4=89^uF}o)`qDR)c zl9x{5iI-AV1{Z57n(UA-`{@{_3#M-sC|b2EVR7a5dPPR%7_oxnH<&dZyvuJ@~K16sluGA|_e=9xHxGliqN+p@OPf2B9g6vUB= zw+gt8{&O|j6z?Q>joDg2`N9ah+~lO)vPrp(bzXPTkv-XQCG~}QDPcp#9#!GtgTm63 zaBaQ2?zGC-YkW^Nd;9s{zkZ7<1DOJx9#QwQg7fxR4|7R#XfJI;S|mf)=V=EN|j`&HB(e+DW%#>7&lz`1VEQ;jCoy4xE3Z_qaMaNZES?6`umv&dd z5@5JTl~`EXS>Gruk`^8m>I{Ez@vDc67tJ6W^nJtkNlK&Uw%xuNBW^ju8eQzr@~&UM zBj$<+{xGmKI8lL_HHSzrETE@2i^bOO*0*Lc+h?*H?T9^g zUNhMZiHtjfsc$gaj)A|S@mA6RpD;O+;w5@?XceX-i5CoLX~_*p=JkBffGRVv3&4_G_e>O`xRB@ z1@U>e!HvaPjiNWuUK=e?j^tS{^@Q6?!1qysP?)(IS6Tv#+uazG5cy!1rwfFC@qoC`#I5n(I-6~i>dC*%K znX~K=BmRQet4xg%r621H+^9YhBmOvpTl>OSbc2xc8W2YqT*$OYLvn5V(4NRmW{C_t z8tVrL^(+zQ6+U1R9@~Ao`*bIcdylE)@Ig*kd>B8>y|w78KMuXvd@SeAW--8vCX*SXwY7sP!74D9ySupDe|eBiLiKQl4wD|`?aZH`2%+lElvX`;F7~Csytr^+Vm#Y3wfrb{{*#6aG+iV( zx;-zC(cmEJYJhT2QX-KUHVTZR-`KX6H3WOnZu+%y1Ah}$nrHByHBu<|+9DVn9U6_F z1=m{DPG2!mBTMlr6X!c}0v7czx=+S|<#{&)H|{P;t)+rsbzF2K8Tzb%=5%GCxcjCE zyFedBg4oJUdFbGc5dtIRgg>$=SfkfTf8)hVuy`(H9AJfeI0nBRv?+7eZu;udf3JQ} zdTc&(eMiVW1+4f6F{qLA0huG>^Ayy8KG8)4c?aYtsOpk93l3bqT-P-csWcm0doIiZ z77d0Jx1(sXW9YJhz61S440U{|~`lTOP`nOXJjD)-ig zvw+_!PBe$A2lB6D|8_jcWeZ56o&c_Runx;*A=0|$t_5=2a94JOxL(&B83s9XQ)F}j zLK@9&Nm0(Q07eNlMn-s|0-`$Un_>-vK`Mqx$FalCB~k7wC{G}FwTgPJxTY$+Gd7%$ zN<~iC`Ix_b@mCQ0j1~lU{?x6xa39he?gFVwWs`B$g4RyvN{&TFL`r)>_Dp#iua+Fi z3s-{>Z3Gszte0h^-IPhFXOIb%wwM(kGvCH;FoeZcbDh*QFC9(_qDC)%XH~%O4}CVdmRr|6 zw{~$`Gu&`ZET%fR*iX{Dv$9!mgC}sgTEy#}=;|8eUNBO2nsp7v%SQ)iC!+v1J+43q z1Gre|jPT8BR}g09F^3(;42-cCF8l0~9az-)cip}T1n_N;i>JDI zA+dOBZlSJ~pRqrzO#wImG#x8>KK?w7S8uP5^Tc$pm8R_Vch;)vxFErTE61=XG+BfI zgATBnm-tDXYL=o5+{u_ng}JM>+DSMgs-*GFynVcV?1hZO(^t%08@}4H-=}&0#QuG) z7q`647+$B=h@X`PQ6XhuB`1=@oBPA5jgOK#dlox%%scTDdAwutrOqZ?C_oA1kbUT| z--MF+q^|&*ZeQ&~3kC!a$ziNsI9Y$pZGRBzd%_OHs@;9Ir2L|J{Xs5d3-T|B4tcO9 zoz!xLxgiC|HteUKWM&|`&sTbT}fekkm^A@0Uy{XJ_XStz} zHIigJUXBqxr?4vo>rVoz?uMM+sh@sSh1(T5o_OtC_1yZ7eMll>9guW~E7r}Sm5DX+ zGm5vEqRFQOYQ(IBL4@M){vL|7peL)=YX%9zjC6F^6C&nd4_zj>w=5h=o3SRzN#?tO zS((WI%I+D~2jaunW+fym{tJ)YeT+RC&Ne*z?AKnmT62STgstT!A`F`r>gKb9>^0D0 z>`Q*y(XqmKpc!0B>0Rl3y*Uf+%lFVoy;?kLK#cPO>@Y$wm}`JyY_Qu)HAfO|qgz#$ z21(2(v!<|dsZ~IU7tMoQk~6^v2*-yF3LC1-W7Ri*|I&M<3qke(U=(1*GpX5}X5F5j zqlG1^giP%L?c6YQH4`k#y~9~{kT|^r@p3&%9M}j-6kI*15Q+^C)brDf$0r*M+?+CR z6aCz^5+TFg`C7GuwQcAK&WJcCsy$Y`x3`@bdD)?JA&7bdqy~Yzoi#D`7u`4Rwep6j z4Hs=vIvY4o*cvr&HWjgNydmr-1;wie|@NVA!x_G*4)Im!%w(a2msKs zT)4Fvr!1vpaLU=QXEz2M3c8S8S2eitWz`0Bkz4biwe8RgHxSVECy>MrTx|Dl>pVH` z@(QojzHP3IF6>?1;<&88KAvCF${4Puaj$i^E;!p&-Fk6012>UeDr!`QFpeT5Y1H_XxT+QKpL48;AvHsBuc3uMu3OOwOF()_WV;Nza zXJj8%ny?;ba|QJ#YN~-Bks7<(4KSAytM064EghH{R(mW@2IPdtd(@6gp-$8`dc~^e zUVh`fe(i~-gXeG!pqakenj7b`^pUVq(xNtr5I$sOLB z-E*Mrc}c|eB696)2U)gFdNV~sW%i8sh}+ZK zxLk|T&H&8hdGm~Vl1)YYRtL6ibAq6k6_fNe$9K}`qyk>k%DY8{HP&xNVu3H#jAIA85ZV0vc`DwVpn+)%~641tdFVodVK zXsH`BNRd3Z9^IWn8zHdA$pqF72Eg(4Y10y`bU&;yLUD|+wAO2 zqoF@S71TBp$pop*bOIUPxSTdU9V~m$G45E;7yDt84NN*wAUpO0;ThH$yL05B3Bu_W zDD40J>nHc~d(Wkxa6!qAqi-KH`@X2U&1d_$}F!SOA9Ov-4E&$P^Er%gSQ*G#W&Av7P6(+Gb%Gvlt^vaM`eSj9oxy zgbE~f_b$k{7T2@&*${TWi6J)wdyxmtdiZSL@btiGwdWNAHR7;OiP^V5S6APDk`GeP z@sQMm=NN0bL5wCdn8NkQ*csufXJ*HE@bJfVEzBD4^psa~<=jzAS16sBsU>N%u3-*z z0-azwHg(J|)O?7_Wcr~sGOYekb_iDAZDMvRGBA01Q6WL;!H<^tT6uFLGug$&*aWR<5`m`Vt_uE){GTa)x_$e|;Ub-fQvrD-K`h zTyW{?-}Ln%NGg{t=k`G}5qB(=q57CFipvq}`r)~n{LBk>`=VP8$1&#ZoNbX9=uF`O z8#;bQlE^V@xjA1UbQO(cd!+!SGnsC7RH0#Hthp>>x7TB1!>K?aYiYON15`z+-$m*s zKF!~Yi-!2dbS^@CUAA;Vi(E?0TzBM)YO}7_8L6ZKD+VTB%=FjJL2&2E&bT;O`HI5G z_^D;t5D}UM*^G@Fq;y%eB_(Tm&W|5e#_yhP>@HC|c$WGBkyP6!=T5i(>FVF#?Y(hC z^;+T*$UcK(3%tBf0cN{DZWZV%MRn1frMR{SgR9~NPD^B~MM6R~s|Pt6iy-X;N|I?K=RRR~EGcu@3s&E}+Rg0ht9yi1$@iJ{H7nnw zp08cL0LVI`$=};_5C8_ILdyp8t-Mm|2^)uKzK9ABFfroTUC~_6bfU(aeN5 ztsX-P#~eW+MhBF{s9nR|4O1^mzDUN0M;bUAoJFy=(fI<;OG)bOZ79H8v4rQ)G6NX*WCtn*$0(_^m2u1eK*mwxuOIf?TRywjjx9|t3*&HO27HJx(?at(lr8ygG*l@oWcz~ z2x8G>y3$C-7iq_w2h)!YE@q;|_O1c{4!Ra27T~T${4wf^J9&<9Dz09RSxWp?*#Ixr zup887a_GQLk?iinebf>|jkjzX9!)B~cKw6jfAz(MTeye8(51Gy43$AN`oIKpOxxx!2xbMV8kBGIs|d6Dn6#$!>E zj_x9JfO8#nJXn-t=pWACDLJ4{iTv`9>iRo-k<_ON$j5Lv2#JfQ5en}CejWriBOE=G z5$zyqUDoOX1MgsMbZ3kRQm}v!Q_Js@w;7&+i1(W#TJADjk%FHy>?$nh%-bK6Lb6qs zZi4 zz64|pYvTqu?!Esm2m}z#5K7P#=+*=@igS6dp&N<{OW_>h(CR; zcYPJb^Ei+Ml=`?J*d4;5D?wIHbDV@O7zv27q*`Op^t=sS;S1J(h4?U zhv$n0lIW{$W;6`R)@XswTrQQ*{ zhAXf%Jc-C5#86XV=J&ueG_Fddwtj!-iEr=BKKk9>rpNuOpCwf5fqMwW^iW$KS~N%( zX~DToW@L3l`2aUcDJ{;@Y;^@b2nf+zNr@yW2`>skF|e~{}fqy=Bw4uQuQ)%&uZ{43S=r!Jbl*^`0V`5xz;x4SpSY?XE{+$ zkh>C`T@49xOzsL6;uJyjBjl78Vgwr9(Se*oPo6}^3Hd{Q#FRc0QBHje_1#tVgI9jN z_u^}NZwXOAPdsUQJaT&@ulF2xBNt`q(r6Q&De7i$FFRZ%t14ViV;>s;0fps~CkMtZXLtc5VHgk1iAs&70Ckh_|u&Y zM`%%4N*ZSF7axU%U^dwi1ywW6GT-HKdpm;f%1M9LNJH=!G3VZ2zR>h|6zqiJGW!)| zOLgbm;{-^Po;unPYUU*#L{T zl45ixBd4%2x)Y5?a2y^68=HN5O^((^c)eQn@~^h61NS^0L1cRo3|)PkP~E(^DKY5W zp|)IzBdr_!DobZJ;QyxDC22&29MUB+mS#yur4uoqgllUaB_ZylVaY{?go2!S;;v&y z>0=W|oqRhYeWYc6@vm0C{K9`D#2gv<=t9$zPxE^pMaI;1mP=60qI*o`qgL7|Uk^njc)Y%oQG4vtcH;*C`Nk*5F_g&K4PePW2cMXH7a&3wF|5 zUawkjG^o z{OWJtfIPs?qZ6Mtb&UWI-0aB@(%nX0#py?|mP?y`I|P-@DPKjo5+0cGUx24S4b#{P z5;O@>B#A?45@ryEa^w;uiwRxF)O(c~e3U>*umWE9thV(WpE}C)Rhs#I{OXxEKl;Vj z0Gs1DZ1f)9E_jmHI|23~)HZF=%&jFfZK*||m9g7+;e(4}n7pP#G_9KHSSDsBH%8ti zAT!|@DUYx%ls6kWIv8RQR0BH-HgW7|uyBm&lpRs#8BtPq@2w9@A&}=zeEPcGoxL%* zhs96w)cs2rwj=JjwXahf#6#X)&;y7O4bN!xHp25TX@MBofL@L{8)E*@_elV(Xsa=l zZ{r%u4Y8e(k}-l&gmu}jkodCr){ZMY@>s2tEUxs=@824`&%gbJ4}=rkn-7TnEa8wQ zZ!drGDLMu<`E)L>i@?r#+NeFUl-JLLhl}AMjLbAb9?adCk~9qSG@v7q09#kc)U8Ir zAsDTM2@jt*x{ib&jWFx4y8FPBX+3Nh@iBDUb@zTg^p4(Azz;xob_x1~Lz$PMCwgSYa$Rv2DAUOmnYqgXjMw=F~hDU((x0n8*}u>1-I+=xIsn`vi+ z8QKb|ms~w5re$nt!6{nA&WU48CQVRQHgxw-=c+g33C=~1&hMRA9&PI0$nWihV}>#> zNZVx7mP;FTv1tQid0e(8ybB5LZN$<@my9s6oijlWT>{301O>rR$R>$V1hyiI5EZ~K z#AuEk+HlT$WL^yiqBiPcY=9s}J%lIktaQ04XBh=a*;|MDyXab)k? zp`&ZLy~XDufRUodc+NEg4Fe6FjkG{h%-PifyG_0g6aj4O3UVokE+p3F))LDos5|jQ z0;g!$!LXSe=~DxCnio*G$=|V_eeoAOoHr5A=js{v){&m3Ga6lx{;2g`PcZ8(nOl#} zJy<)Z7Av|U{9D@=73Kb9XG4%_K!>a~NWux&5>b5(RB3 zjjdtx+ML%UOTA0ACn6XzVW-4PC_fj2ZpHLWQy)5c|kg4r@DD=bghXp`*tL` zCBk1(PFCJl-b_M5<*sQ!{B$t083GH&5`nBjNFBi;1m=rmsGy4d(FNTq3hmMb7lbCnhr-7RX9n|vV)h{pjtN~$03VBL(ivO1>EV%` z%#L~IVrLA>jB~5AC-6SV#L0qaN>XAGgnTeLDjVqRk$0baxf7BnLahKi-9m!B$x5(vLF(TUEZ=HC2T09hX&3;C zXbKLtBuXTJFOys&ItA0gFU5b1PL3>y;wG1LTb%+(k6m^6l;h#jf}CAPe`)cnmk}?aN(yeq8y3V%K>yH}WrC+s z*DJo+lS>VXL2Ncj5_d>2pM(*lv;KLR2AtAah37 zIBW*b79Mbiif%!=1cGm4+d>KyI5S~}7?5M8Eo2Nxt+9Y#L-EI^ex`**Ee_^>eb>gW zn%5jI{e1S#gvTSRPZLA%x&s>AewunjlXp-Hvo$>>KTZI03gXYLqoeP7YKT-xR#Zv- zmGDR&HkVcoPMk}V_+#7XAlMuu)16_ZMN&s+_i-&uiBZ;TmQki zKa>E{C7L`^4wga*K-Rk2rdgMp7AP$fh#=3bzaWZmG&m%F=T#r`oglZ4_#O3~_fgt3 z%|e;g!wQGf@~95X)|-o)`P^J;5U+vFu;bPYKqIqsX$~G_B3+kc666L+78u=8PUt9u zl~Y$J?#$EVc{2I7kwH?kkull86YCf=$?3c5d*#5+$qD9;D<8gu9IQ{B*Y@hGz|w^n zl&?$ryww}K4l#!O1zrZZB3p#>Njj0xDBvg}mLNfbm6mLIgg=GUF-|rnj~SD% z;$Gg_aMk=*w%-6nZSuwJSGT{|@^v1}$&sz-_%s1kqPHW1Tzr5nHjk7Jlth3nbp#1{ z7!s+O-QLUuTuZjX+AMDJT!(=OT5_?ZCatjW2?o``xd2RMUNi1E*h-lRQ3RR_0O zcjtE~iJ;6v+CD!*0|#{B;;W|(II?*RPg~g8vcnQcr=2Y<{ed+Cn3uddO+(>UI^txQ z!&*z&eNk3QVgfNB7#8+ubTZS(QFPcF9DTU<)Gv)+{|x#>+-RH}-*JVW7*_CNpxkyT z^P%j*x-=eG6kt4wfvEsk{IQ$Qze~DN+Xrz!r}+4$6I2TFbPaR6hOyj3aABu zFKvj5L?)0g$-`xbPjSzJSjRWEJ-qmi`vn)hV92M$U}!0-HPFA|Hsh_M-pjxmdW0zz zlVnAA+oC$se=f~`t3j~BOg`tKbv8`HiGu)+HpaY-=9-;}EnLGiu5DWJ;R1T3ghFyU zl;RY7*elx0QS^rA{XfB610@k^jZscLc>=M8ehMl*euN1m0IC@kg*9mC^7#yV9vl8d zn#XQ$#M0YOJKKsKz+$i()mwcjf;D;4Bq1LoN6Jld4G0n%)?swjx1nYVa@Yjo`^k=9 z-b4bQw~hLy^$==S#ZY(g77_xBAhqe`1T$p>StSK@Rh9cYkDfddvgQwbRUn_swAcxz zg6JrsFgUFu z(Lh$AECOS{PbG$@0|eQY$L>_ZACizxvI^u|v2>Unq+xUr4f%^1gJ75`bkHQ8&aN#+ z$w?$gVem8GLsxssDaA4T^K+$7Kiazk2?o+Y9P&@WE*UDQMH$d8Z@PMv4=Y{j<~)Wi zKO8R&4Qc*hr@!;IVEJtWuGm3{B>|Sf*Fwy!!<1wIPiUGji|JYdnSaW_2dgn>%UIR1 z)xsq&fOWv<=TCn2(O#Fj@x6G513C4&H%v4$`k`Sy0!f9p>&^OP!pqW@k_9M*N?#}s zl#ZLMaxmFSK9{cWHwadhV}=M?WMnmLM(}*3B*8KYyJ~b;Vqoi@Ioaqn-5IX-f!g#< z%=>FkU1o3OAqK&tU7cE)fckWhy@-e!N-x)YryJ>ZdK-$%&R{y{`Q03`8&S@yK$5~ z9(lmOA9Dz`?5HroJP?TNxxHIz=OCw=quC-`Hbk2p9-KSZkm>C>_`qub@6Fu$qWcTq4X8@6 z77w+e2NwE#;bsqNDtkBattgFvE?|OL2MJaTaDk&M-%G*F-^Ui6w_(n74B|x{c1ktrEEHx0({f_~x_;6uA$!VWC zi62lB$P!f}%ad8G_szff{l!}YukCfY)sHt_xnFdR_nt#F3Uwkd(}&5g-eO4WAQWt} zazG&kE>Zd}-i!(EV?jnA32uj9`5fL!gO}w5LM*Zy6flBiqRX|X3@?dH>|Wf>7q)`M zAYQ0@`|7*zymsUd`H7E?jDp>;B=wT=i!JA1o`3KLn(W_zn0F(SQXSFp#~l!E|lX(bstxBb_BkF!)2g+-pv;C(Z3nMU~d8 zDVdFa6Odo3%&A|w-hM+m~tbtq3v4)4bFwkzWoC(179m9DV^R~ zWH~J5SU|C|Fzavj*Fw;-x#B34)c|wb18_f;SZze8fj^9~@ohTbcghd~4tyd+P{aKm zE_)+;*Z8$R^ljYE z$(-^42SB2I)AIJhlh+dGSD%0u$072s2dqM%;cgeW9?F&~4?U=WBMr~;N%-USNmM|{ z%9{{u#HYY6&S`=03vyw$h_=hI5+4kU#zYcxIVqa&9aNvf-g(t1X&WayWnvLh90E|& zF!d{+Pjdg(bmoh%_cqmy+y*j#kMSB>&gOyW?S~NFGg^UAP^*oi-el^#0Ht~Z+z;zY zxa)5wZ4&x($rQ`;zo$cKL56M8&-M=&noe4Y>HjCH=CI>ZFzj%|y?(832?}I-b zCY?-52gGA2vC(EyLyB^g8W^1=C)pm5@8kynoq`Ucqyv6M{GjO#002LzUf8Ou4SXZ@ z+y6eF_vE#``_XR4Z=bw&-t|-Def^Dlv_=588&eVdn&;GQxm55zC?NIrU539+B{ihW zZ?Y0t0c`oAcB``y_<%^P=32siKd-1Mh`o`GQL+!w2eXq*`Aw)Ye4(PKy@iu%69~b> z0IvyEUcD-_VaoGYFMjuzS7B*?#}PaWZoK|GSXo5N!RKQU7Jzg%&>HRB+90~&Te1M1 zDJUWEhGjmz4nR2Ria|hOpudqBsRI%}APjc3r@QTw>?CG8`KO}Iu*>xNL*R#l?c7#f z!`b?<$)<1>4Z#6Wk$T8locYaXUpB#g6xCnw9SY31c8GOuY(7tLq;sh^2p(N7ZZZQk z-i}Z}jge9)sLTKwuFu_uSTv-!pJb7)ODw<%1UUu;CrNFTEHdvimjnCA%o$0|1-lP~ zL*8%aWLRg;98eRStA(pGm7PKQgN*gYTi^EH5JAbCx{c}{Vy3U)sU=vc#vkKReYF?L z-JBXB6CUS)RnJKQg{;>FT!7?5TDCW2`mmtE!eDV%UJS^vAQ4iWLMQpB%t%p_UZzvv z6E-QTDO?RogsXiU8vUlb!&P}+4}HNcdF~S@{0&of${)E~YX%gtaH4f^*O%}A!#EQTQTg-rt zz+qQXsG7DbynHIQ+wZc>Tp>CvJJ^cy1SJcr9+HDq+4P&T~{lRnae?J)$ z2pGYL_z$e%<)&Rt-tVSH-V6G*<7~|zUsykI?`6*UPmjEGyyeRq|211RyZc7aZl`l?e$${>o25^H93@}I(^dQQg*~~c6ZC4%Z>y^I>JZy9B0emaKvf;8+6#Cf8{uP z2EB5;o|}yyb~QPnS8yJ*13$ngiTY2ZrOxp%_h`PiR)(DK&4=PybhYv0=b`~}Fo{HD)eV?MpN`OX`! zefFoMnG`j@dnqHJ0YB05{%uR7D3#yvc&|DuyT0B~!gk*7->%{5zx{j|bk+Z|{ox1S zeF)c_FaE(BKRo@BT1(=89S$iGBUN_Y^Z3T(gq(RY4|fyIfM}$2nhL-$9v2Ee;DP5`LvYrt@huC`1g18fCE=e{o7|h#_dnH@_*3m`?vo8 zsQ7P+{mUDh39DE4e{$TvT>leZ{@*t};3sJDA20c_n*R&4eDC|8bn;^u&iqFee!>MP zk^ecY_9uMfKVFjZBewaEm;Zzd{=c!!PjcmtbWBnI1SS6SB|joD{!dclKV9^bOTmBE z?)epO;awO}--Yd%z+V~s=oLIP@CxzyCqFnR5dQf-!GQ3JTiE}$6}TG~owl}i4MQg# z6}8ri5N?E22`ka3xgiRLLtkw4_9qE0zxY{n(5^F^?TX`dKf|K`WjpN}7FIy<@Ss;3 zT6%bf7IoCZ;DwZ8Mks~i#sV$6`_`%p&+Et*u9d$?NBN^iW$W)COZiRed#wgE!Wr9ZbS$5YeCmF=(XT$?#9;t zzPT0}gUv{oyKyUiCal?K~J;C3q&mpWkUa+JY;kMR=i%$YOYM_OEBH+#1d6H5=Y z(JOwufyu$dP!sqx7@ct=N=(@9-iDS8S8ymr>BhEX1~3>KJNTdE=Kj{j@dh)f|9JyQ z4Edk8P)it!UP%nmpWyL=iYA<5`HOP15eJE<>{MEuz1sFKX$0Q z@%NTf819v!5PK`6VWDxWRj9JovP0QNfo;bdYoCt>jrK2hZ;t1r*AT4xmklWh|G*i@ zd_#y%QKuot*cCK{mpr!VX>Q?87|}OlPRKb-g$BVSy2UJfz8{p17I}*8&Oy;nLW>A{dl~ zffYP%;>_XLUv_M)t^GL&{0L*};|+Ep-Si>Z&dkB;_#9~lUXek*X(=iVkbL7RX1XAR@RKlCn&cbMXOM@Qdf!{o%4OB)cK zmwdl><+UP6Vpkl_KL|G@&i5m>BKyH^cW=A0U9k$DlyaTyu9TMEe(5(0xv=G-vGl13 zphoV44Fy2IK~zsenjoCG7LtJ_0NfmkvQJ6rrt}5>qEPYm&*$DOZMn3z265Q+jsc4B z_sI-q>Nfxz!X<7fvaN(o@eJ*#=wn3N@rnV*C;qsUmx~<#IZg?|xE9>+{k1s`ZWQnp zXyR=v1rUdAGX!4Y9Zsy_u}@@deRe0&VSmhiX3#DOB2EGYhy#A?cTfl)N6N59umb;r zF0p8!!?C=i!d;XdtRrup?CR=jX=&+d=>jYucp>e-S7+1HNGmiHKuMpLKzXfJv95(a zc04XG@#%?AdwYA|F@RAv2XXwTd%p)g?q@t>Ai2?44CLYprXy%{n3q^&x&MQ_yu9Bw zxj3RMM{MMH4fzzO?oA#8Jgam6vJp^ zYM3Ndy!>BZzq1vB4dK25Rb##kFoe*3fHkD-Df zY(bR=4RVu16xPZR1^j>_@DouP>)yw(<6ODOfIOY}9gNM;&d(!dVSJZ_C>YUFS~9~ z0UWY%BPJ#~2+ErW#m$WkKd3+#hju!G7Rmiwkn?QG?z??g{_aqG7YGA}Mc!-HUu?oQ z+4P+Bx^Z_>LHB#_RM)A3}{&! z1;3WXp`KygRTcar+f-+f;Jv>gP*tyj4?l%i~An<_iFArK9utF-N@z1?>{ zU>D>CKIA(vVn;C-dM9Jlk6lvs`DT1{{uA7%-w&0*i|Q!m!9_gYj^HD)x4!&r;mwmk zTsY^fT}SVmp62mF6b=gb`l_$bH>vBd066q5n2Q~J8%Zz)MV{FQl>e^nyWL+y&-2b! z@GB_Utp9rK!lnN6-|R2>>yE83^c8@g8w?wwutU-dk|O6~myDj)eg2sZfHWJvZPwzF zfa{Lin5ctcZLa+Jr5y&4W3YWb+OGwzLKJXKD8FT5t@9#JUl zz>ySugjff8o&3Y5-zIJ^{umml1Q!>VUpVl1TwL*JJ4@w7|BFTiZ~6cIMZlZBYdiD* zF!mmRQ2+nq_=_kwAaN09kd6Unq&6Y9P;R9E{wkc6{OM)ioDZ5kmmtI%9%=^EUtcKA1|~!U1Cr;~ zse^u64lI`J1SwG8N#%m8wKbmv?yri6{Gx#7;SxFp_DVLljAssVuzy=)xO@y~20rRW z3DPX+#+=-od};j-OWqlGHnR8_;2c(78=5^|$PrjPw>G!hJP5BeRb|8mYTF;MXcB04 z0xS?fYi3sr3$-ci)5Ts`AF$t;39$gKVeg`s+@~ygn5i@Ms|O-~G3A2=@T@uLj9hMR zZgcY!Kx@wq8@+=ufCC`W7QzA&=QM@DuVaBvBUp3i z0Q!}>A8J}{9kAIhu3sexg`#saxJPqxiwZPO*-u4t*1{DfU8|um2DS}=NAP^l7|Y+M1M$cg4p5>?7DKGivGdm&{q{5doYsA#B+QL^iC| zM*1~^&}KAj+91IF5%}7=%caY+0E5W0m;=`8bE_4D@b!n~eqJ0M{|O1=05}b|`3Xhy zpi}lQ4;#I|2nq_iMcdj!(Xj3S;anKKxp|Peqb2Y3Th4mm*gNV#>8Juoz&*^`bL)Wb znn#+OJ6Zsr0Rn`8I(;{92wi#>G`DexrVuO8Uu}^b5a5%~I+Xz!8sGr1X40(TdHlJw zt1CBjLO%O|(g=7XokN=U&Y2{8#q=oV@z39?*~lB4Qmk0|InTU~>Uy z6xW=}wrWzFE`WVzN^I|QL|9l@D4G*ihB=72LbyY)Cr0hn+X}itPKfPI1V`OE6QCPV z7kiPERCX|?iLN9)@x_(}xeWkYg-Ve;I0%iLbaZH#;ZXFf`Pt1YlK-O5+v^;Y#tuyO z0;>Q*by&bL0h?xQP3d#IWwAoVnfBD07L4rt9kTfAP=57rGvvL02SC;AXT%5hqd{WsNYCmk8S!2@`3#CK7gfl z&wxD_3_2>}<1H|aHAocv+c-KAa&vDT&_UQuvGwD(kJjhhJveVlcg!_t18U_$(7?rA zCwKGg1rN^AA%G#_1J)kkAQaBL7sZ)Ymi`t&6d5K)G_c4ng|3*;my*E0`zJ5!wQoHjjj~m< zV8KDnMkhh{-d`Z=egg}h>wE0+bpU}+OvO&Aof>U~CH*g9vFtzq6E;>~qwb&Br;0JE zDWr+MP3!W)BL7AX8el#NfJjM8em3e`hrp=5xr!10ShcNnoxp$5pr=S|3a1&!J#-z3 za$3!yQVj#^?~suPn%3jRq8=qzgu;zW_gfbOn)yy2T2`c3u!LIU)5 zyJbJoRS^}3QGM|T(ceBH-bTcTx2Gsv_c(^Ahwc2|*89tnPDIRpBFvchJGwxBWBT0| zZ--G5L53J0x`Mv>o!UQ5@F!Z}i226>pv8!WPAOh3>v+T$f$Lu7L#=a`-}YN!AOZaA z@e}Ts#!{{SU0yo&tC9dW;<+ke;CA6}nc+nDFUNWkFa(+epsIxb6x%2h#<+~c06|fy z+8A*zQn1IdgTDp-XN>)dHSkGCltr=vK=gnh8clm)RBr;?{{J)tOqT@c(Gj7Rz5XBa zf=fpCP^2i2*G{+JZ2ZmJKMe5K7Z@{aj$;UV%d=(u#NY3#DMXAiIMDzwXTlD73{m%6 zlXZW2%->C?byk6yK?CRtz-z8SG5|%ovN+QK(fNO3!2Uu3L?4CW>4L4JEw+-5*B2$W z{OR(O9NbU!Ch-`bSg}#s|0MAf{4dQSG0H)7QUFnyW`+seCczoTgZ?Q-3=?k!bpsIL ze|u?t%P&kYSL?6m^bb9doqr-91Xoh=@YzqW89>NkvTpv2%;%eb4h{eP@dx8C)Osio zti@I+c>6;73lNZq183=9Z^Q43aQhs52KX=BUU$azb_?nsIfkje{x2{2+5CwNfW~2t zV1WMNJvdSA>j?f|@0u_*Sn59*;4|bJPEp4N&jxg>jNxe#eXzW z46Ymh&p`Yu*54E1paYuBIQKV0zuOYOXk~#$JH}}}bAiZ#dyv380EfN)cjlvKWsQFs zclXgj)&b#n|3A(Dxbn}hFcE)vu%-_abjA$n@0fpv5S=85qkkMNKYRZFq(yZ8)6s-3 z`uaPaqd4#mD1#{eoiO$$2LGLf|FoU{;Gx6*{q-M3yUXv(AYOm9*ApQfM&wuB@cy6P z^VheZB7$Mh>5nyGR2dje(u7kA9qk{VxxL;W=I}2%{}G?-Dexarx;|RdS)4=B6?Hd@ z&f+@0|9%I#74@@=bo?*Th+rJ~Ct;#dK6qI2=iKz~Nz`B#IZFu9$V5CQ2>dJ*`5XRs z!zc$5-@YTV>!@etqA~q}OGYs7{~xcpTFC8y;|9;NUV>O;-+Kr`rD`%_#91zwfHMbn z6kh*);fEj{82UNK4(5MYyVGGk!>(@#@|Um5cu;U=DNu zyK!8c{lJbQM&};413MxcKYLa}#H$cp5YkS3m4f-cX*3XGQsBC4RE2bz2^=V7fg(_G=#@`D0-LM0?XTd0=F|cy3PJvG1(A})L z13SXTjgI@V_l`oTny}Kp!|+hdZ@6C#9(FlRhDvt8&Uf{VpM8OHuYi647cAgxEh6*6 z)F~Je`eTe=pxn%0&uY&g)N&xO$!~3>sQ=XRg&eSjHbyM#ed=H!9mBubwHf@6DWN-x zHu&uY9DMeKjX<{$y%9DJe|v9akA)b(37YgentyXHbp}wrd;%UK&i;p$-#q`Cbd7`WHmCZ113TPo}HH7DCJmLdaj@t*Ao`m3@IQ?0>6+cNNzDp94JL|1T0- zGT?dWeGWx^E}H~FF?pyul;MU$m*1{L@ay`SHE0bQfQkYce!2cHZL@1Ie*;dBms+Q= z>u%sK*ps_yc&hh;Jq?`wrBdqE5r%s!`1oy7{ksCSM6p&;acgcSHX3-QjVuS0AE zA*}lSGe!dj2!x>j?##x*&AbNxmU;cyQA1$ZXS4=Uz2^r0)h!lkK}WZ`9o2g`lugba z{q3W(&?Lwog^J*7gZiEtkX`jqrs%@}f<3k>R} zG6@R7GORyi0zV_N2IXD6%Ovlg2e`-26?7H(NAwmfF;m;;c&P?!&>)GbH)~&`qqfXE zDmRd@YP$W#c)`i#yiUuQnv_(poNDlRX&&v_<&rTub3CPH`2h{+h^b~WvHHLogse8F zM$EIlv!}98Rk<@3sA=Gz8PyzJcx9Kd1|{3K$X%%kOueA`48MIh1d+@sUH)12S37dyV+;hjEiHLoycRG{`! zPHPQPUNxugnSa@CM?AO&iSH)XkJp5Q8SGW@F|{ui)O`n+_kbBJc+T^teSJj%)nUbA zOl{=UP5n;$M!7Rl0pT=Ze2Co~JfTz*nd;v_Z3kS~zsUzQfh09|%*Unj!q=(q?M=;@Rq-{5LA8=*HE2hivjB>TN?imL<<%N<VRdDoJY`TLH@!^;Je2>Tg7Xb%%id#MO+cd)tmRs5s!$fZa9Pt~Cfu;P05b3y9gS zijGyevo)@sT?7AJG`oht^9rxJRUMEMeL&K>Y)KOZPX2bs!ZzCQC;Th00KNu2u5w>A zpL8PPiP0BtM6N-fUamnLzCQLddqL{dSDb*LbgB7#{&*6%FDDNPwaRH}?x@r#eac`(7|>x*BIAJ-6AT*(7HuMsh{-7JF9 z%Uy!OM~2{m1-%`O1|Cuvqsa)(jrKx)OVXu9&Gc+aq$Ura) zDD*Hio~utjMK4{sS-l<3^*22QimI?{Z5i)mfeRz_+yTYAc8Zg<$-m8-8)#vO)2-Q85vqc$~8D7=lh)wH-g6y_p2Kf^S`xNx^{n=1(cd z$3a1BCH8n6qt>wg9N&iZ92;eqyav@-oRSK3vXFs!LEEKfjXTNp?`~Y0&6x#J-@pG?^E=1Gb?3F{ai1er#0@r-o zc61F2jXqzbGwn({a+!t3y>ehwZK-T7E!uAtxc{NuS^loz13x8C^h&7*OebqfXDZ*^{AlBiF|{QYu#WbUrsW8hFoFEd zasjKrhe`I&bJae7pt91AtU+7Xpt}JXvk5<{Mr#H$S!o9^-^EYs^Un@MyNnkWm@gZ( z<9~eZkv3|z8(TJ7$`M(zh}^be!Ij3~XkV|kY$@{2zTxoJ9Q<_bw-41IosML7$$jqI zL{*th$f?eqO^epTZ(WVZofSx1g9w%%ih_@X)A;-+Z<^U%o3qHO7X22l7<|N;wrknF z!v~u&EAXf~d_n&mctB+Q85761{wnPDYRbYM{yC8@xlf7Jyg|zn;7lC|t!+&HZKDOl z0%_NULIZBkCQRjZ1gj-u^VmmMnKf0^%<+-}&1#0rdf^3|4zq^(;9unL)ydl(OtUgE zANVp_W96^kS8zftC14fgUdDx19ctw~+hPtF*1m)-yF{>phoY*P*C6NB{qm03Ig3^A z*(J6Q_Or)w!RR_5Kz+yFpf##yV;h^P*G#KU1@ii!NILyHgSTM>LJ0BsSX72x$#<3!6ca8VSmr34? zn$Px+v9w(~zc$;2*TEoS4OEpm-bXcq{b{SrTHuKbeVWFAr9jqoF#2z-K0PWoEbt`Y zaeWTJc-^i7_*1}R+O7!x#+uJyz!9G;zr1M;5?nP8@i$uJ9+z8Apfb*--mm#$KQ#__ z8c40a$Xkt{f0S?EAni)qvi!YU-qG$uO!bR3C?sHPneAf%#?i7(w8}#Mw?e zQfJE~IT9o}uIJ>;e^{%TK0N4Skic}x4;#|If1k^Cf zPJsPXzlKP>B8n6a(xf#;eCb{!{u#(b<2sp1sgC*bY_?T$Rrdji_cY4vgBO6Unw(8P4Os zUJYRU%4Wj1gV>nN?rTvNr~SEk$B6GFjXm4`g$e)0sJIzEUcP(T-H4d|vNQ@tqSVDp z;J^HAQEPm2unDy+Kic$A?WNJQv9_|vhZHZ66_;AXxhxda7Fk3#4@56Y=P?~uQ2U(S zntGNSj5{gOLkeq9gSSFMQfm6$!)wsUVEN!n9=rA<&i4Zz+5adXXl{N{G*P^7-@e&Q zF2`uJ{P5TpP!`2+US**P$Z5+r9xnWHO-^5JMfzsZ@aXA?!+iLGOZfLgr*%cT=yv8X z_;Gf^ktTAO&vegSiAMo1Sejt-`*$ZT7fpDB%HXDn!PK+e{c_7G)y@q-1u&FA-RV)C zqL-jZh+cyPKGP_2G{slo07DQvtg<6n_yH?EK0bdwKKzP^z#n9axP4jP69`>xamN;~cwRP9CJ3UGzpLT86X}oxj zzgO>DqQP0uv4W>+rWD*08;xelRWc>(EMAq<_V(3-Rh~`b(HmJevl)A9a}R9F7a7c1 zY^qR*4A?-PQ^~U*KRG&lR;0rHTwl-g&OT;xgj#^@_P7k+xR&P;7CUF^o-;*Wv}in$ z?c*C|)>OaqegFF^%~uPY%(ce7AI?ba5vK%w`YiAy21lM6e;*j4sWdC%^|zpnS+rxzr7(yh3Hy{*38s16(jcVb2~-u5dG>#Hf&iKiSVn{98&(A zGjgQQEAg}dNkt*Y|88IAsJQ_5$CBpGM_b;$ex%|VRRZdB!&YMV!P2m8eauh#p3a}S z*OLu4QZjBJSlpfD(;TZ)_0ZAW)v>KTf2yF>XO|3K`sKW? zV|5+nzSq<18(oW=-z_dqdTL#5Kt`3?>Pg^P{BMle?K*lQ`=H#0;tI}dT~v0acVQ9? z;Kpuj4RU>hAH8lB*za25C)1HpqB(ate9X5tBjkfkxXiIT^;fiQ^HdI$-x}0b@H)BU zIARckd=LfhZ9l>HZdA}XW4~L*rKQr~ek<*DB5Zc940 zNyPsETkJubb_^;k-##{R4Fbs#B=S{!!P#v5$>i9!ZIL_ zKLouA8$0DD&z{=pNZCo+jd)KJbHo2|sR^1K}%vsDflH0+*8vSs2~$}I0?$;F^_hfzdX$cu+Il#g_3`&`2dgv61Wj2FzwwTx!iToaP7VA7-+bW5?Dwji`>Kl>Cdf=7uWM_NW~1k}v%aSuTh@H_ zUSM^9gi3gTD!q|IlstxvmS1gYPDtgg9->FKS`4%~sxecpO4dnR_gUuLC~J|_Y~WBqg4UZ6q%m4V^12x0y(qFk%w zR&n3u@U8uvG-DHiOThu5Ef#7X9rI^3t8_<8DfsN4U>DY**P!kiLiB^J#CuZ3;vzS8 zBYpR4BddH8FAr`PncRME;37k6Ry9@P>(iqBq^d2`J;SLm9PLKwIyeoq<)*cr2)d67 zgi1yAl^qx5Rea^&`C-QNT?5$1iUhQ-&S{t{6OB-Xmrs+Q(xF$Qfr(-GZ+xKG|B>)R zoM>?God{)HhfImt1;^!xNW`JRR~*9+RB_>#M`Iskqev~=;wiY2Z_%9F?60k>cPUH_ZK z$k8l@0+gwurY53n{kOJye|%u};xK2)bbOqs+Ek1bfthdYy}cb9EoM2{)|b{el0mbb{Sa18X%YXS(a9Z2aob%-bJYo2{ z4sSed4)7(Gi|&~qQ&eYn(Z{ZJaN0uvU0$hkwucQ&%IL7lzNNOZHTWc0^iXSNPTU{N z+%)91^8w?di`?=@K+ht9x*IIN<)%<@WQr?|(p&Oa=M2q$x9m%5*Q5F$FXG%IznU!` z4?B6TFq^`ak~rW7IyU+UEDMDLzDu3i02q$^F@*dTxBGH>WdygKSj3$umqw20O~DyH zilhOOU94(bUT5lo7!0azKj<5#eCw+GmC8>G79yPN z%CaLRYCE3i-P<0`3How?)xvnaKnRjq2XuD(_?o0%dw%HqWd}hq1 z59g8EF>M(2@;tx%p3!hwKG-Vr0 zEcTWjf9L(S4q>MR2+R}#ieSbZKDfvfXUG-moKk$^>QbD?vTd&eT0J)N%6QpDo0#iI zYt)*yG=BNwsAx#;*9K6iyMlBuFeARZ5853lle58j<45=Vd6MpXKWU?#RTU)<)sCp7 zJrnpoyQQMpDSgM{6#zv7YkR*B69{Zj;lcP3QW|%^zdYeyJT@|^lH<{K=+Ld@`}SXN zh~Pe_E6>luYJp!Q!QYUep$Wx7y9QmPG9t!w_MJQ1goxVdU-DY7P;q(qcE5A3{=w4v zu%Cdp{=f-BJHc*F2;)x=9G*txAPzsnezxG&d0-q78ji2IqC}A)KEaW%{(3~9>^C6T zo*ZHbWHeT0%QlR%p137zEOpqi=ahHYcPV_q^p@q8tSlQ`Sxx0nieTkx{on+9U~dp) zN@En-AkBL|EG_Y>i<aHxdTt~P8tE2dpJd`%v>r5iotj?zJYt{O4cLZM(Q_AQX|rH34ZOqDNv zu?7X+U>h@+&yddCGkNll)|ATt^!$3{_ab4kr|4uE1R)m)8Mq13+v@W9u-EjJueE52 z*bfJ0&pzSQ>{IP^-+w~<3UD}zCq4F8ieb?8wWnhc56}>#j=T{(rh&5g45lD^UN{_{ znnO-)>dIDlwZk_ij1_iP%9mdW3k#s(@PZ%%h*_Pf8B%A?9IY@k+?sTbX~E|3@NvoG zz8gM1{4jf_+lf(2h71Fz{sM;G$r6SYgtmBWI0be|8Kua^8S(}kYTDfCewgq^-dgCB zo{uGW)%NW~eE^9g_X_+1o(w|~odE16h_R5d3{rtq#G3E5TA>apqj%hW`{hXea>Tw0 zZT-&Z3rI2HWI8aA=hxpV7&vOd|MR*R%3_d!_5^Oc7cSxZ@Sd-7er?nA(vPCut;WJ1 z5`0LwIfg_!(>U_TFIf1q97O`_L{zYWyfwr>0106$G)zOHO6?h!0?PQxxT6_H=lqRw zUR`>1Y;_&HwCQi~X%q;AGRO)#(aL?1;iheVEZ5DOhm}uVJ`t|vR&=?`9)pTx+vg(B z$wgvq*mO45)b6UhwPj zKgol+>9eO;TUmRcQsYP5D#QV$y)O-(SnWGK)9Iq*ZV}}6K=*xD{5Q;|b!hMoqT@gR zMp-ABo&-&x^hw$%W$By64^rDvL0}f6J^Ij-dh9)pHtg1Aq`bV+PafRqVe&VUn2jk6 z6Y4e?p16*ntvfOe(4iZ3W0WEeKl&zZJ@vqsM_-gZ<#mhXFCKmd%O4C=3?>XV5YI>E zEP2PT(N&yEHI7!IrNY^K2aPrcSLk=Nv%L^i6UMy$g#vnQD1G6k57Xn4Z{sQm+9XVjuQF4*7Uz^;9BM(~`xXC!EwiT1xZ7t+S+4{M6` zubh#4KC$P9xpoagmp@?*S{FM>*v1ruIe59_ zDlaNy+j;4Lw^qe&%r_$Dqfs}C1hXB+XhTnI_FZgLZdHc!!ZwEj6c78(H|oTYdeafY zT3r(^T3^+*Wl^l!dL>T*5t7)d7~Ne;X@=FPD)3_v87gZ%6S<)2ywY8~{IiYmlym!Z zH|*RJPW&*iqY<~qa2k!ouRj*iN5Z?}o&HLBS{>gVhVK>etgZK+?4nd<7>tA_6?UxpPlAxN8)Ox zbuo^{2i^Gb&DG1!{C>sj`e^?HtV!--u(BVhnda)ryExxB;@NH`noAJ}YYE7YkdY>3 z>Oc`^U)ppDAKk}A868^9qA<5(3(X2d88>-}ew z*HNH|3^iv!flQ{HmC~E;s|l;&>G^K7e5_euU>Guz%f_pGmkY=exCRam9fgkl(D+502^5H1&9=lfTI=zvC zBX(Dd(c*|k-QqZM4BLv*q9cwRO^AEAgpcq2evF1Q+p>w3#)vi?!vRCqzkb?PlDw;V zuxHO~%`feMc?7ZfxOF21ZXx$2p`KEfLl?eTpQYgywRL*iD!m|-`$Sd@Wg`D$dcREk zbKFF1tn`+@5G;w)e{s=*IyXUnT{;yBvc;21TFrBvI?wNH$*YggQP!aIeeKQCAJvCX zU)Vr**}kE6GNTcwmcTc1!u((XXm(01QYx2l(eq&G>scY~l~tEgi!;+0LR6Y~k5A!f zvau3{_;DSa;%%pmu~vs5DO3w8Hj^A#dI4}u?eBd&b?<%pxaoQK>GLQ;i>g&tX1B~0 zY3RTNxtrL^Df%;gE@M_Dfuh@WMJTvrH)`){R+hM=%$n!tj5Twrp68??7VqU`%AH@$oTg?mr%FIA&axKBl{IVwuG)c_@&n z>h89)Ab|zk3OEgvi9w3O*%S2yn3+s*KhL!79XgAYMYJJPlzMAmhVvE=Y^e0sZp@dkC!Mq5M8!b5 zp-ZF-D;w}G>QJ{3jzNinE5^u>TgX7UiPm}T2AlM%x?d8xQ8eC-LDy8oe1?v$CPTgY zI242QlW}WP8^q;PJDei#p%|1v286bZp_1P|euv>jChfLAyiShele&m{ltP9I2o;Dxkw2<$a2qk*d{yP7Hf2xv zDQd~$k~43bYj)Woj!))%5(aOYS{U^mG&qF`xQR4ANwDNJ-wZK9Kkmh(OAueNnm8bDNO?x>9J`cK&X?>Qw^!L%pMlfWS=)NU3XW($ z4}uY75vOFc7dCH>iv@76mhxBDA-4(UO?f4WoIK*$VXLDza-m_o*;zt)&hXqJ{VTWY zG335@3Xayl`WQvjJm(|1Z@8YAblxR5hxWu$`l8^W?Aw>~!gB_5cbnzAwI^ywO)NY) z;3RS-F23$Ww+u)nYY+yP?AMd6ShDZPZ9>P%>-qi<(ES_yXY3W9&rtrv}z@B-frkRz)Q`fg|PY%E8CFsGU=xDLCqwct) zX{G}I<<&_~#AOVXp*V0`H@Tmhd=8WfPe|-3se$!0?Q5K?CUzdaSr3kO?yD1JH1<}_ zi^?1MqWwitCJq^8#+MJRCi_2mn1c{&L6KWziZPdsllmHK{rovkWmaRyL+@}rO&5N# zIZL<7J9=kEY~v7Qg;{i_;y23fxYu<@9QqG z76^La^N_P-y!FarrdVK@w?kn1>5aah-zzs6DZXpStJ7VX{|J_m7o59Kkx_jVC!)=K z3mFA%SdzEw4)ZJLc^F;Wc0za~$*fAFxK-=Ahj*;rUE!dk9YHc_C~Fq7neT%Vs+*oQ zvK>cMXSH#vNk0_3ylsQiN>@H6Y}kIqUvN_HlW)WufhA2(#6*Dyd$>bsE}%ZV=!%UT$#ZW$esI{4_W%eC~=h8G=# zKe7mjJsmqJx$&bq5kl%UL!5pGN;oJlJxNH6g@FX=*k5C|_z75t-Np8ca+mPy}m?s0yHr+Ckr4GU^Xp!fM)b|eD zhd;a>O73^JIwf-TUH|>_LpCT>7^FmT1qB^%o`(Q5X;*ZJ)AauE18AZ;W@w(qu{pgi z$ERwG@HLi)pmuwybV;N5#=>j2zyuz9ARmVO5hCiDlHyt}mTq{rBY>rI)3iF?eIScX zlquTLOk(GrQinLpGZXg;%9tV~jpk{u4{D&2oj~%+O2%c8?~K~>4!OkVXdHQ<-|Tt5 zHw`mG{fIa2?c+ZF`tV)hYx|i(vmd3ApN=i6s|1573^2gXe^?+>78VA@P#)VHzVWG| zYm(-S+4Q>u-ZL_%81X-7oOkt|>A-)3NMi9T;`2fg2*`klaes-YeFQaGe~ZhVFg`DF z1v(XPl1G0&SQvZR>Jg*ns)$S+<6)*FGZusFlmIZ*3I=HK zbO+G7>mXJ&h zsC|ktVi=|pGR5ghP`;dv(ZKne5fAHT1UKScx-d8QWD1x#E14>*Xt(I&Ev?s}E4NqJ zuQ(sa!#6fyQZW!xYG#Kpg6^@42{An%6JlNsJ$am<8!zUlve-QELL(+Zhy0Dr1|$C1 z$W3=^on{QgAV$Gu5v`Qqq)Y@3L8i!GX3$tE->^;pnZ>AY!Lun5j1Yd@Nv*L^$_v4=lGiEFcqH*(N3s4l2sYaKtvA3Gl-Q(3wImMvBgd}r=kV;}~6g&~>9 z&|uxFelgqovLtevA`+F<6EU^qa`mWj7!r9UG9e(8(TRc)HlN^`(Yfy10 zyVF{{Qv!B~MBFY3KDVI#yt9`2@T=ibUJr{v=B$Sg?{->Ix?+OmvRawbcTD34jjjX2 zI1=g!Y2rN5^HYaStXa-yjTHMzC7!Sy#Rm*L%xBsxoR=FuR1oW#2tVBl;8B@UDIA9sBW`N33%7 zoMmTEk4B-bOgav+UM~o?3vJar@S51u3NGdi#I>5v35~b-aY=jI2eBYjguF!28p1sz zHH_@G2J?a~#=6(=85(jMvx6jC*ezr9SQIWSBhTw1SHsk+sEA`|lbB$%=PE)!NTS&e zi$=&suwE)c=^C{XLOvuC>qC5mPQsf}wdDkgFlZ;R|#aSI8 zS(TWcjvqP|ukD$#g`}I^lNUt~BG_rp^CoGLrgjnET5WwZ^9F+JB)kQJq8-rTwnV}4 z7N*dV8i827Iu?!cS6IyryX#DdicFygP_IJ;(@=dMLv>$i2#JNU708Q*Azta~qK{o6 z5RgNnj5AZB!azz4OGVfnUKKHyh?u-ng|Zjj%nr+JUA)t1E5C0$@y;$jkH*Km`{#IN zu8Y@Z=s61PN6GK&!k$F&(deen$)eMF7iG44W~ zc#szArr&log^)A8ct{G^=wu{wlj)9;*T=Q^4?Z^2$k1}UO=Y99BhO1XMG3StYi^Pc z6;42DTnt;ED~ekuP5JG_~Xamkl=F{yV?ZsBrS6fI9H z8$Wva_EU)kzC6!@X)olVtoy-}_y@WAZTu`9Qz-O>HqU0=heHMuJ3oA$7P~AkdT}dS zbC;E1ETVTBjj^<@Pa`Rb?nfbqLKQBckU_#ow`Tpf^0IYOjMnUSgQoT6h^BcI3L%#* zk6_Ud5|>Ayk(wbRq6ek2z;<22&v;m~?#E>^O4mlYRLSjcy1pzr{y|FC2lyNHeMh78 zkr`e|q;Wm4)jbmhER7l1CH8y+m+cS`^uUm9{dJU5G-8;iX47ee_9N}^kcV787T)OP z+ga5-_k*e6gCkwX;+dT0r7gC7DYkJ#sG}WWJcBW5VqLnCMvVM~JM8I1MXfa5iXsIh zn|dtL2W@RRKn**ld(AtsKFSd}iIP3sz~GGT6m1c5<&s9B#N~w~j0IzYO$YRtM?{6S z?KI;g7TSl361HAPW$U!Fl8z#G@FtccB=qMv#RxAQw0LDg9>a0OIrH*cnH))j1)RFiaIeZ{&|XSB}w3YvYH`zN`fV9 zu|iT$@#r(^x}tm{iS?m!UWNpVQ^~r>nOAz}Lkb4;H3fqa^GKuu(lS9vEJij1_PtRM zQu?E1L8lBx9})J1g9kqI$@gynZv=aks7uq_d{IfX)H5{pv} z8t-j3=!&Vo-kN|8VB!=j8%5n0Lp!~;KEj9nFpz-Voo`JQwhO)CZSgE4-K1l`nTUFa z=x8L9%k6TX5g*+HidT@g+2n;eRYU2<5K<_O6&_zK5^ZR8L7vwj&S(j_B8Os1!q`p{ z%7wQy2yaNFB7{AKISu5}3ZoJ> z%~>e)*)|0CEvB?<2fQ?d6p(o%C>d;EaHnVnXXWrb>IA1e_cKJQhA`57T9k(~F(z1_ zJtjLDsTo->m2ah$(fe8IWXMT>w^^5h@g!1a37^NfZBF^5qGU9BqQ3b`2ha9Qdl63g z^9?V2fS)znl6fUtZRE9h5Aq&)&Ar4^RFJXX+(OIfxS8uA*ixpjy+n$0#v_N)+4-L$ zR75oR?-yLlz~=;SzU+cJX?b4)(LbbHQLG=421cN>4s3Z`1g?kROpyY+DHq7qVLKbe zuo7Y%`Wmjt2q2G}EQ=N}myq0H8sQ20z39L(EZR8iF_N=6Zdg|^T$H|hUiqn5rc)ys z54&WWavw!aK0KOn^g+CQ)GKUvn^2MF()OeXqCeiz^t$+v#yZ{@veir@myoBzIoJakSwKNdjEBh@x53O!F6aMzN#ep9>TI_RY z9>^41hu-&_7Cm{f)u2A(th=!JdxQEM^ahLCU>2DGrfih(^_&D$$j+{~802}uvKge1 zUb8h7;iYR`pAwTL9Ea%F5dK<5!gh*wX>dK!P}F{`rLTC!WU|P0DtgN<`wcz>b&$kI3AsyQiMM>YLh=nJ z9Hho2S?>FttdGb;mF1Yy8FrRE3_=6A5lCa)jQYg=Aq)zIx9sI**h|8kLxi{plS>Hp zq%iXtBqxGBKGeG3Nt6feCt<0pi}cPSIAqUjSsckJr?#DB<1gl6^H2=SJ=BqMEnBcY zZgeJdSEFsynL}Z>_^-ElBuvW-Mcl>??U$F8C}(?RBY$xjq_{U3U&_h>%g#Ay!{b^P z_Ivx^&LU5X`eHl1OgSrgYBq*vlnl>1=Lqd6?QYR8i(Sur_fA@7$*QI6^=aniamowA z?1Ai{R}q#N3t|X$f{PGpFb|@KcbQ$6@HRb>_+FQgT+CA>|zEvk?xDylrUhLB_h6c%JksL;l>CdrpmZ`slj9W#ml7p#4@SYGmonYZruVhMRDb$(I;Ap+B!Q@~`;`gfkHrjO7>`la3%@_AiYNOIe(y(X5Yg;E# zO0wr7;=*F;7de$sN`wl|DbdyhGZe*ib8N08yYE@~#dL<(0cxn=pu7RoA_noJF`;Vv z53*eIHq+PCNZqF&VF}`)pf)yFw1v$+NQTMSnhM4kSKd?iTJ;caqUq0G>20hWda9X; zazSj4Q%}@mA|z;{q(ISUaBf*HqT3+-FoDaTW}{B37de&^iCu9zm&mt zfQw0qED3%>^3D*A=xHz(pZLtT@r&XjO7LLxGqJj$JRI{L_5kN;4(6)XAt5f#Z(t02 zWn97(>NrXW@6!@eH~0n|2ia;}Pk8Hq3X$$g2x3YzeX2jCPeePoBK2arQuLU_k1&J1 zGZQq$Un3J3O*Fp@KJXH}$^N>|SV*+Zdp04hA`>Pqd zSQyJBhkhZHzxC~gXU!^B=8q@L?_JwgrZh+=mAA%kik)1#)=nvNGKsU7k;K4AZG|6M<&wBIT*zH&k7l9F@3zmQv`}o z8OBeqjDeyF8Fif!h&u_Ma);~7QQ{-06Uc-4dR}PW6DW@Wrg9+#4uS@wH5hZl&go(u zB%0CQ+{h3vqad*VNKKU~)>D#APtL^Zlmqx^f2&O^`g|WWaGeQAca?N1Wkt{5UK(lMfFj1$Gts3A) zR-%Pny@CnJq4_qZBt|eu1_2^4H5?_%qN`__6O)q^VHp#0AN87Tr-COVPn;$H!!GO0 z#~&ZP^6_)be0?OR$usKPl}1Z>@7sry(R$kxc;I)Qhb5k-E2hxc)@ zDpOg_^FJs$+_pb+7re#j?9$k_*dO;IO-P)!B<<<4^+o zwFAj0o+9BPz#%udt3#hW9FCR%wM#ju9@)}h*ABRZ;Eu_X!D2RFs!Hfe5H(R4lEG@d zh!HFk5+j(1eIo!f$X2#93;7|uGE5|q1Szmx^6u+{+oj$KXPQ#1q(lwLq%;0 zSM0*#JBmNu^xO4S_q;8k8KgWO|~R4F3mQUnoc0qGEF(nLT+I!IAK5r_l@jT8wG5fKoOzP~eJckjL5|L6ZnoXkvQ zGIQSNJm)#*eP>>L`b$B#v%3r$y|Kb}`ZxJ%`>!pY0?~L-`F4IRck^9fJ4m^Fc*9kl zrL~l6f0NKEDO7DJ4L-@Acy|8wBMX+hlbhg#cY6%JC+68oqVhnNtV8YG_&AT6n*X3q zWPi8?JywS`hVH2bi@M#ZV;97Ki)7IX_d1#XBi}SSul_dN?Np|=c8+;H!_58onWbwZ}(V&Y) zLjaV%^M`)cv&+)oPo-einci34cp7&u>#2`KLa^Ye?>J5GoqjLFToDWq=OWO^JxmI zEgB7I2On-!(n%C5C$jY5ik?QPpgTz~7E@BzyvRRyx=2*y*I3LMNVBZ2P&$^c2MYOX z>z|wp8hp{OkwQ%zX<#%AQ`vt4G^bvOGfCY0M0m(Sagf$2UBwveuSfe(C}#U_8z_zV zEBFaL2@UobdNuikDm=#L1Rl=nUjTm}?%l{FQM$;&2-!dke9nRhpo=WFJ*}G6HjGLV z)5AM{gI(f9zm}yMnfkbw-CeQHoNlY~tqM3U|2#v9v9WT$$b$G8%GoGs#>?WRCTF|q zUu?&mj#GEG4o2!2tb*d9{Cb7I-h85p=1Z3Ks6K_Flwf<|MDFSQdvAMM^;9ptDipuy zGFqW7gndv;uey&Oc|3X2Ni{Wt4wDGk&@v2`{j@G}a;5uBb+7GX!$daDnx3=HKbH2W zWG5`ul=z95${ZDWJRP5d;_YeBd0FH7*Vc&!#%nUMhaSAMGv>?gD%8;#3)kSpJ9d@m zRwbrD9nt%2SDOiDL`Ux=Rs+~U2ZSC9m7N5w;fGo>u+P^?p-0jGkrMMqV#>Feo0E-q zOZD&ldbh_q)3PP@`9k;QLt#GS`y3V}=0?6;=@J>SQ-@^J)x^m5jyS_XjJS7FsflG* z?>$Q@W*&nPEB;+akJ)D)(;BXQFx)^5fkOCs)vS8_COsDiy*$0Q2Irt29b_n2O|qz( z$=OXbe=SO*wC{$V#seK))L&3(w$m{+d2+Wrn_7i-q_$wJzKZz!t(b`%*)LKlL#kft zPi0g+SR^GbNjAT1RC4`S@dFSex}sa5g88z>*```dN007aR}%j|Z6ef4wbbanQ4#4| z^!(?2(o?F%w5i)M-hg9CJDn7+*$*-4i8^7DsMDB{aFZDYEdO~7R*~MA=|y_VJQkh; zurI%(!2?>NQnswBaoK42Q~a|d`_!M$&wa7~klH9{=O8X7sLo?>01yl3bN1PZ@K+=D zuhBjzql1);7@f$`7%0;AvK$8E6pjKq=)sR7@5M7|G{+p0UqdDFCEEf#jvMjQb>s=o zV_^CQf~s1K40fN|g{u2}ExRR&X{UX73oA7*-yJr&$aWIwu_(9qJwD-I69H@k`^&jU zuZmX0ogb$8%r>#)Izykm8j}^HB}Lcp4N9W~Q;(vk)`zR9_6J9%VtXd2u;bnx*#}7n z9kS?I+%famo`?`TwRzN_4rD5(KQ{B#=AjCOP~wk#NbPl?@?1QRqLgBHek(1zTb?^& zN-b+SHYZ}xeE-Ii_{ZN5q`Mbzk9={;ysQ}o=`U(Jf+I?lU<;v%^-NMsD8GYv$#r^8 zJU#v4nE-HAd!#yrMnRJt?F)`f5=h6iW_LjqsH@EmfYLUI$393*_G4?r^1~^G3BIU& zflBhDcm5;UfxYRis+f*O2JzG*7S$8S5-Y>J$I8;K-^OlzRU9Uc5+{_7>>GVItL>|d0cWW@x=_bNAD<``)7R=>zZy=3MDd6wQmsvLn_jfs}d7+ zu<}q3^f{53=)>#LgU4XdEE=(w@EY{zBKc3&#$f(P8L{ZfkJ*U%BT3LbM)zMl_~%c` zio&0_5(Al2O^3(95CNzQ6+5Ng34%X)Tpp*?5^Wbi06<0BA9Y|x`t-@FpFkIqA|wA! z#;F-&dv^9+NKA3~qc{tL;=>Firo?#5sZVDu}wmEZ7H4 zktI3fXCM{+BNf>HHwdsvus8fXR-PsBycS*BGwJXF$Cl%PmzJ!wwOtj+O3c|$ThkPp zAltvLogAhWCDiloEV!>|$+BHs5WrFlGUkx3$lIh@aWvlybS8-D=sZYFY)p3Cf)v&V zv7VcA4KJcn6&B*kY zCbdyf$;_|3pq1fw@)viel?Gybe{A*~rdT72uWOyf9{LubSOOJfG{eC2lxnm_(eudL zYbO&E&nVLs#n3Nf#pp0+s&C%WVe`pX)4*yJ>XalVYCtLdG@9>D*eN?rjWSi9{9aSz z_A7>tEkfE(&kpG~nZBJ^THK8>DS^joB&c4xB=U1% zz>PUO{pRmY@y{5muI4P>{PL!$fIGJn=r}W9-Hswo$15ehZ}g6i(J2=rG`$CZRR%+r zUHxsg>!$_PLNqXFpPb_(HFlJfk0W(8HHz+*_TMpw#a)tKX@4vui*|mcW{~h`rP1UK z%J;DN;Tw(d@tq(i4|-qNypWmwk(kP;V>e2bK7E(3t4~+$lA43qEwrUxkKyxFm%}=j zXHG_j`HYwyqc=Z%w(ht%y{!*RijmRDvUA5+(@RQ9vR`P3+`p1wd>B>ydzE0w%fqYI zxU)z07h}0?#P7k?y!5`;m`(D7%lXaKl#S;zMO@M@h>3m??QpxdB{YAME8G#T;*uIg z%nsGzcv_6MdJAS=;k;Y=8_am2snKAHJBi$>{`;fEDs!qjP!#%>P)w@-bLzs+a_*|8 zM)A>kiT3OW`GZ#41OD2!BX*7=6(!3(mV@{xK0GxsF@Ni*bHlgv$5;m+BZtHWcfQCT z^IJwDcovIaJEj$e#e!Z~+dqfv!2SwlqGK#6r_rYZuD~-;5-ToQZ+!Y5mg@h)r~LUB zAMS4d7AwSVj7?Rqqr`ulAcU3Y#b(C{yM0NjKF|lPUIl+28F{-2b?NqNitY_P^kEFz zI|+M7pFRf_nHV`cMVHYOJPe*UZJM8dzAS@iBteEBf@>>k9=`R{KP**Aq+zmQqN%Fe zPXQPF#h%^CD?38$#6q`5@4d6*NfIYWa%=-tw|m-b>1_}AYDC^%eoUxcAe=r8-CHmPNYcq;@Xtie_yrUwup-te0EWjtrnhD+7bUuHr(JQvS~1q! zH+_oFCOvs*HFCAjzKLv~2N%_+YY>aM-6X~mK3i3(&yW~uR{tl?u)gp6Bgm*>x$F8rh>tpIB#S+$GfW7>z!|KaA!trPo@Qk1wV7 zIc9b$pXF&G9GQ+qt*d2A>|1n}n+T;NU8P0-*4xSW`5QGgMe&3WX~vWB-@S+jTeWWY z-#GT7$1uqXlENoN*h>E6XqA1J)HWE>BiDaJ8p%XYwoJ`(gLoms1gmIdgxwjY@3StH zIBcD{yKiB{TFhYGn!%+zp419j`&K;ZN$SKW{q%1CdVG{1WV+QpEUB#GPTVfG0g;iS z0Mv|%ET__0M{!Ohvg~5C4D+rYKQ8*hi%nn)ZepDN8n4;Wa8*QeSlGzBD4#Xu z_T^*8MC@vBmXvyy-1cobc0q`2*gE}v$L?mc%+IccpU;==YaTj$xqe*gOR(52V*G%s zpo!)EGX^m^*PI)EjZL$A(GVK`dAk0wm_fOwuOU^KEjGD$_2>iBXLqwZDqh{aOTBcF z{pYEz*YAs8kFz$79~cAu{e#`U=&6O+jmCR^_98_o7CvR64;uVfYK|rKSz1Ep(rb@= zia_q&7d@usqDcX_li8FzDzV4ho<9<~uyEn0|60fV+Ka%CZ=0Hm`r{a{-n$wh?-J_j zV}v^>K5{k1?RC>VVf3%B?K%n7o{vi!{8;O5ppU<8Kirsl(!>yZc~SUoIn(`%A}d0l z3>%u5`phQ%ZrOuFkX)xu%)h)Ondoat9j4AJir@V#!YLr38UA*+V_HL4AR&(_$-Ai8 zt4EvfaiqmhmPe%zOd88#@1K8E{r(}r2lsilmD9^6s{8iag--c{mWlvZs6R+_NhV(H z2UUas<_M)8>( zl&QQ+u-FaP6Z5anZ?Gk#_SH2wue4s>%3uC|NGO!*z0N6x=IT(%vkxR*DcN{&;f=y+ z>9R+{S7yuBbbp@Od^673=@ew<-)_$hk;)#}s{3*dtAh9_gvAsKLEqM^)8CH2o$hfP zVms$9oOk^3pT7)lWN@%V_3VbXH*0p}dk&Scrks|YDgSjd&t}8VJcg1hcGl+Ofru9F zu_^ge*KgMCe(@>lFLsn@ADnBe8qKFzM)fsD3NuKhXLoXR2DkE=<-cLVFB$TcU%Y(W zf9n{v>}}dz0nLkNO-zN2r%g_uxr&=s(alaLU|Q7MuYA8~r62Ic8U`gbdNVerDMPJS@9)V-tP{;e{! z1GVIB$zx%nk*W0^>;B-rP3HLTth3L~e7QY3uf*p6{B@J@n1b1s@gxMcd5Ot;u2lOP z?&txpjlYk(!MlvJpXEr%otlxkTHdzes97vGdS{C(ys=pBvG8fj(?Un~Re`BxB@ zM|jz`YNfz%9nGTohQpSzGQPsc*NVdPD^j$=jO1K~8Kh2+hPU5x`6(GQ`q_iC^U4yh zg2b%8sg>BgBAbF3(;VV%GX$ZXglt!^#&Yrr+ajiuZqIsLE;FTePFELu{}8aWInDn{ zOs(41yVU5(hl-aZwH{L!{TIN4wZoTYj_m;c%8LbsWxM9Pqxz>>nGII_ZYe0HDR^y^ z7j;%&l@!u^6`L}Y8p{~;;HK^m&4(&I+tFMh>YtAEgs9Iho~s}1dADL-`1%y(SS*9) zE5e{jV}+*v>7OQQy%R0pOzpnkAk-&`kEPi<2?~pQGq1}l5=Xyjdwm*8jqkhB5dR{F zO8o&DjO8;(XWrS*)*}bEH;-cy&&$h;OD9k~zWw;kym0d(`S!&%v^m#(wc4YO`)WCO z4mg~?GR-@@XPdCT%+o$bBaPH^n+1{R9S(faB0=HpC#_VqtZ`l8_(Eu--& z=;gSBDN=IX}_@Z)SU?l$vpk$0Y7;})|Q`L69$>we?xCl2oDs_kyx#OT6q(CRjySK9?dV?D=$aF@kvMyGwfda&Q8!EB zGLg5UMaLO_?q*DBa_m{t57X_FjFS;fAGG zh(-lV%*CD!`pKKP;j8%&cPpXCt75|IQB+h(?4b?yOIi*VK4}aLdLQmImJ13SF{Qn@ zoLDlr=wk;ac{>kjLKbV)B6)pUUvaXnR>Z!biR^?G@eTUY1CHv594+YCB$HFO(GO}j zMQ)8NL?JHsv*%F$8$lWMI9sNZK@WmHh^ZYH(m$X*5Og}>zI#5~S<{R8Vk@jaCFjQu zX$Ofkkt}IPZ%t)>)Kn|xw^cqnmYw2vBSd|ggDrhyB?!H7B(-xiUT!L(&pfXPy*%Po zbL>ds#)6cz#{BD3o+Xtgmrmnu4J!f}Hk*Qcpbj(=uFNm0s3J5Fn+eCdb#Yc-F@E*x zr&90cPhS8y`i7Zp+}dxPyD8vGYxeOfy!J~ga%(h9PLqRva-2vX4|^FU#3WhrHgOxK zehCwoyV^Bjpz-pD&Xa;alHPiXhIksB3VdSyD6Jej*Soi}tbbA8*iynOC@Z63JNZ2& zt+Mxwu?3!#T3CxM71eds|L+Z*3|!{X6 zyU0ftvQvV--YUxJzG(F!O>WlAzo}R#5R|&hSFh!`L%qreYk0-Gr>G&g?y8Li74)>P zW^}jFqrvp-(i0v@IJ=wB1O=);;#O0tcqM^6*hj^t)2tb%l;ebILfA0)pl#=R#kWu8Og3gP3{d~MyT#og=*8Mef z1N|~IpUfl7tuH2BD{N)w9MrgVSV;Fvg-yS_h9Ab>l6QDwj-0_{ioCI>Z0|+g0s$tIG4`uG9FKS(Jncq8A3lg^n{WhHgl!95IJA-qy*8K_4J27-r1GLD zzo!qi$p04MDkv0b)Ea8%<65lofrVfIjiqb-7FlX+v2C0-I5-vf=JGw14%s8DzLKxo zsg~WRsAcYpzw0GHiBkrf0^wg1P8mExYgc_Wqm-1p!Lu6khq#CEWkcSIXuD>=yb%4mY?6(aFdu;M zUiZ|4?HfSh(G(RR6=rF0^LO^%i?Z_`gX7h5tSs4OQg__dZje|rAF64aKZ2Ig$$0uf zTT8o?wC*6i+uCbU2~+Sm093Y4=}@uF*I!bS!|ERm*K=kwy{7*F^d;g83EV7Jq~gf5QyCDJcT-|Qw_|83W;;-B`c`$f`d~5 zm+#7kA=lE;Zny4UbdMIEzJqC2E%chA@d#-T1nOTKfD+x4@i?LKGhw-&2slvgWOpZ59jzWDNVQuOm zFQ+FwM$$>H-gMHetR#b&=S}pFh_2{TVdIPCjPT2N!y061gnLC-T@S3bShwFkfHU>r zJ*JMzGl8HYTrFRMiXgRlZML9JCy-To27hw!YDwYl0eG0Dw|Ka&ttrX9pNGi5fhZ#F z!STE{JA0-*vf*%k+ZYq~`UNX%T;vLC{`FrkvA#HSQ6I=Z=7tRCVP8IV8*ssx z6vG-CqJ`+54<(&Z)4ub;jH{~d1m~Hs2H#pEB;okt6xe7rvVEs&OY;6FHbQcWMV|A{ zfnv(~qf2wZ@qfH+j1w;7Ff);QTsEcmwa>-sX58tUct_pzAp&O{|K2I73=Zu85vFI0 z%dGf?OEmjBifi-#}`fd z-;bz5X>=)*4pWOvdRACN7kR?nQ&xmci?eE>e3}1c!u;}?g6F#f{7Q`L39LAQt^o<9 zv#;jj?G~ieIna4|59`&{4bxWsm`&c~;8o3AK^AfQ{~$*J)>THx%}iUY9R4N|r=^U< z-==37lH*a!o_EzDni1IqRJ9Z0+vLVALf!>{bpf%Vd_7yMVfPYQBdm7 zUw{szLhV9<1OwU6Sl+O<+~(S(12HvLtzbBFncc zHFM!k-m=4yg3nsf#JD2`JiJ;;xQOS25G@d(LBwsd4~g1|iQC7)@b+JDKKH+CP;2Rv z=V_Xv>NN?GN;+wJ5Vj8cB1m#oH|9uuKM%MPfZi=%96Jg2>RKdXzr4~`6`jbele3U` zPQK#)d!5)d5N);YpvM1wWDrhJR9Vcs8T{h@#MABU%0r61uikYFXI8uX&{j-t<39X^ z(r=dVe7pI`Y5DxCT!YJvcoWXVjy<>7BZ{-`CJ}Zk26Qtqv1XYu*VZA1W zx%EDg8p#UJlSZ0mJI65JVMS}m1w}_g4C_~2L=&c*vn}ZxkU%B~+MC_#l#f6USr5w^ z*6?w^*1<_zu*sSIMW3QB4eZ~-aS==|Ehi*T5~`}Fd4)!|PFZxAewNqH;QB5UlU+&o zF|Wu?_Br_mXM9A^Jy|pjFnicR9B!YFxrF~#B^vZ$CoM_@XXADGEuL7wQzt+Jjkuvn zVZi1k_sg)ZF&6p%tQZaIha@{wv74g(BO=g15JeDR|Nbb*C$F>A{uNj%Rl%XZ*d%~= zbe)&7j!Vc)dlUXxOiHf)UG0o~_wTk*oG=p~=gRNt9aQ^YZNtWJyb>$_tmM)<&hW`V^o9yQ&ah@t za^j6!P%8Q9mgFkeys4S+0n0opZ8}CEKHd=m)nyl=mti_Q;XB}t3~Q#~aO6-ewEm?c z$Sn|tPJv6025%wVFQZZ7o+i@8WJoC(3Bm~>Z^a9^$S0n%Cpho1A$sKbP*|yx)A_29 zSG*6P_#PF+w#{>Bai9ZcBM{c#YmP&E%~J42KX$7^mUm=~?&S%0bsA0#5yWV(34&zr zl_D*lV-1s{W?xDU)k1y=9_@<&mfwn=W*Aab+d+zYF-Yma1%8u%mXX5cIsSC*#B(y+jg$za{S}r%h+5r z;pwwxiR;7xE8=G-;WUB!Z!XRHn90wPk-P_V8(jox?CZuxo`y zrIEk%dxDT10-?QB7XO}_+NMzp(L*dU28Gc{ z;@5~-+D)b!=HVjWJbWAIHIbVgAXLmlkLZOggAR?=?vr8rZTYQRBP(>6>cSpQU7v7n zjH@%-w~Aa;U08sxCLy7HGL&(rC_#8JsEu*^%(CZ-2yv3Mw8IlxlS5gOO!?L`?Q>Cn zo7g}7hDXr5S+Xwl?C4Qkq z8)6i0{A71kCQ#QQ z{@7)qBNx_o4Y-Z$U#WOdmMc>(4S3M5vzS`!F)_QLq%GVovzdF#d}OPYC-?h{Ib4=Z*$O!#u^~TZ)Tb4J zAur>33~M6vq7TANJf3aO!{=Encx~xeS?TGqt5`e8;2++_@q|$3`SZ*&H1l4BH|v+1F~w`%bK!Fp$w_ zMeNhaD97J$$WY9V_QCfv88DsnboO-R3!giFC47NJ)$g#-gWAKoCo^e@(~=6_@q?YV zF-LyjXXFg^qx3JjJI=T}G$wzhU!q$N4^mYQiI?XOQpNFQ;8cd5e%nwq7^%xe9?Dke7yG!pdKDHrOh<890f|bZJqEVTz0idDFsnYcUS;^U(K<2JuI>|cTP|~LDNn7 zm|NiK59;3L>)Tukl54~#o?o=rQbSY@5aRFcv?af4HL@?Akz1+wXw5!r!@s2x{$X)o zETGDWf~oD5#gHkuK8lJEcJdchKL)d;17yCY10|~d^z^KbuN33P=@eMLP(aEcWl}te zpccsO9Di>vNP+`jYYh#6n?ZmEv#IF9OvAbw`MTg%Oh)@{p(gqEVgWJixx4FD)G8Jw zJ*`1FgZKl|4BNa<;k=@ad!0Tfe4C3^;f?6nWq&o>voqZyZ?-+F@=89aL9)Nqko#KC zO^ApXa2D`5IXq#1;v*O6^rq8?r2AI$i$SFaIzOpi&?Bi%-kAS2Jq=Ze92awsFG;LsFElEpbZ}MdS!0|paEe>jS4elN14!HyP(-g=$@oA7BGn| z$Kc2P#4zgf732P^$S1{eCG&FC%krdt>GAdJmI^NS= zLuXDShTr-r-Uysq!+S=LPg}JIe@Ks0A~2D(({kkFhGs72iEqF*m&)i?;Ht=MooQZY zfr{%cae52EMQ2`jZ+0Nt>Xu3%&<_&W#IF z)bsXnB+^J}t~o2N2eFKf>mAqT{EOwiO%!r`2Z%8ef)IqFWP`GjcrYQ`N&ek~Fj^ zg=)6d{LFtpPT8S?%JhKsv76pbgav{nanWU6>Q;faQ1Njrwg0uW_t!A13ue8aZ1}AW zaCF;0P_16|pyhw`SnzVvZ^YQhkD>2@ub|s-t2(nxSplmqf^@g-Rs&0N=FA17^gDoC?A)vZ^+Gf^~Lb;C9r7U z5$N93PFMkz9gxt=Be1M}XFG{c-R21<0I$SPlNi!l0+sDxj~5w81i0YEt+OQ6IL2r$&oZRDifc8K}jZIs!E^&JY+DzBE;usXS8;bqqiX`inz`sWl&h;MkOOo?_-!K!w)3%MC%ql$Ziow=R2R7J={wT1^f zjA*MV8{{z=&|}L5&t}vmNOh|zNnns_vrWy-2F^5mkYrP@MBviIH$|j|jm!3j(EaqA zV!(pm68Rev#^Yx_xci4ZdJ(I6^-X1TOU65K^~-1bRKhG$NoW${mNQ7k7qFla!(14c zL%9*;FHj&rirHWP{h>1SjC^n!itBaNAKs9?sdzYPPGc&a9IQTmwiaEj^5` zIOtMKJ*i-x`~j5tRIORG@c=iHO@Q!YE~&{KkBHXth0=veU2afr@ptV6Dy%dTTFkZ` zE0fK7frytwOLvIwT^2uaEykuwGoq9 zil=V-KL&K)_%Dk>dU6d+iib|wufg0N`3|e#Jc5RK%15CuMRx@|h;+(c__*wchn)aP zp?vt&fUI36Xb?QmRs9&a>*(@zMO{}ZlB+DQ5vvO>!rjfK!pGfha+uBMx=5op`nThz|laFcZi(DUan+H{b( z=N4|V4t~%sE6`e+jo^&?2DX$W_aH+#&q_{@hBVezW)nL2o(Vl0n8x2D{V+3gdS92)_>sEnTh$DC%XufJ62|@ zeARTZdnYPl@uFyrt#-n~hWkZD0Lt5IQOHW8d70JQVa3uPWX&CC?b3JX(k4o3+bHg+ z@^S7rkvD+d)j*VPV38Wm*$hb!9s?y^l#+y!HYI^Y1N2R3VZfiiKzTOl^Wi1BfNmSi5ND?bY>*EU%gMvx%7ux`fp}lPs@|%HP=y9=GNQv>4Rl!@g zVOngEY+uw|RM&Ue-EpdP8zLs62DD4Z3=Nc6iL4`I?)HIgNsqUEIp<~k?X^|*zm4h( z%y=izlRlKStX&GpN&GO4z9JBrWCYv5>d{}2s94jr+po-U3-) z)k5MZB@BcmA`q4#KGFqd`_LTprl332AEec-&EwWMhd1m^-`lofWP)JU!u;8touhD0 z2g%}<>{$i~Rbg|5*374#9dJtxNFa6s(<7Q{ViBo9{amU1IM4t}QW^ye?E+i!Re#-B|qkoFFYSxv!?fN}he2iz4-nAbe^ekb;>Kxqr- zuI|tWB*KH5J~|~3+S`)VKc<-F@?D_A0g2x}vJ$}NpXt5m>cat17#*Z{5IYF`{lPD% z7{c9{BA!-wgAU#68$x+i5cxhc%je7ni_dA7qPPJXky>fz!$3B*G9gNeG*wO7LYMC2 zb|Ekl)Ew3|VtkiDhUKV!QTLj&H)`z+tj_1TKa))Qk^yNsT<-jzzntA^OELev7ynb- zmaw^srUAYX-?AnNEzXw*oD3@s#+(TAz*j#;b3?A}YazMLv>MDI2uWB~c^6-q+z1q8 zI3RvTXkW<99U!Nv=Y(84YXiEu&sJ9qukjJ>GaqnoVL-y0*;+ie8Kq`vUG9d{H=9vN$Z{3A zILtOTb_gw@&ULwcKYmzb2XX}>|8kY3fZ7$$%+EJg@;oF&7`B5nc%4@~0W`mDxb*om zqLucz>3x_>keleW_WZ37c+ib*?^}YpZB@dBil6J~%07f`&*t74UDnPF3J@U8v5-Io z7}IzQx@V!d343U@Ct6u>&nN!M`Tx@+0Mu4+zT2aVs&DUp3`#aTlt#WsDCx7gg z%9q?=X;9`ZA^pnCwYWLOa_RzrDSobC7m)=to#48Lu(zgfYy#SMX^sD`5$>}QD9d1G zdTgn(ZjKIQ29RI0^9SqB85Su439Ih+YNCMR9_p3=LO#rb^pilvq;ixgqz5W>Ko*Bt z`2GlE#sD+#mF=WyDJOD8Od*IoMTTuf=~pKEAkZ?X8$Pu}x4JG=CIn)Dfcf%GCK*P~ z9({)`Sq)D7GE)ngi9DW)%PV;ux)^lg^|up2=hv%S3rM>LN9HHq+ea+>%59qfz$UiO z&v}umJYA~aO#%N-eD$BHldG-pa`tUw{PUMH?bmR?Zn4R74FzH6=Tu1v5B+c%Bb$o6 z+V<25il?{AbC?X%SD=8c@}!luX88mxh{&Ryp}Z%2Zkew!o;^0Mf68Y=HaM#_u4f(! zU-CnpG04`NaV-i$z{_6!>0Y}zV>+(8=f<_B7Y*}TZtmn1yU?T}qOHdiVe22_$!Ycn z@okv*e|C8FO=eV$xNYokGIObRN=CDDll{>JXrzFc^K!=Di@dN5i>Qj5a*}yX^W@!g zed9};as?~yL0O|&S#p==m))#&Zz#$w_}!HCni%hn@Zn{~+>%?w)%Z-+cYd9s1P28j ziALd+RGV+rFXOmW?W5XGP7IYP5Tdt4oW?h=#tW>3Mz6(k8BV53PHB0%Y|2TG%y|{a z$xW-r-WL-!WFv&X3x-au4ewiA7Tg{$#A?OrLVHI-8Rvt!6Do-#nQVF| zH47Y4vIc@dL&;m(nY;_Wj7_-S}_qy zCENiE>QE}7t}ke7|NSL*k1q_pY4%5~sIWvvhFPo%yl>}eW$FWw5I^hB-OtR<*T;UHp9L9T>E2KUq2A>(s>6Mff6PE#Zcnzp>-2Q zf|GGb|LjO(CfiG~lJPrW&*s?rzi?Wg=X>)wo%@#T(geAF!H-K)v46^cv?-qYX~oZL zEEh64#a-qF#VkT^Q~b>L0&jpYM2cd#o^u0~|G&j+S(Ri!eNYV?X&&Gd+D!$($!>?l zfif)kYinuZ(+U`^Z6fLsd7E=r#iU+fMc;JMeN#4=vo)SEu4iI02NF)X1$Rq-><}eQ zEf{(z+~gd^8@$M$Xvc&rMN-FN+p?hCPtzD>J^%ak{VMl+ApDCA`99%G&)Z|&p^JoU#B$y*A981Xabg`L#435TO&}k%jb{)wXo}~AC4_s z$3j{1>BgVGoFibQ90C)Evj~`ae#@g`hXLu~*ml%JjBAEiHdtbInO5WxQ^Z60(n@YY zznHuC1`IGGsNk(|p%_qhhItYWy~)S{8@THsNaxeGRvbf~XsA zNbNA1nQtK=<}ZmHU7It zD*OhPadjWhwAWC^JL@+;rL8Lu`0r+O5$3CN$D@Ew5wnJDN^~FHROtkBv+krye&3Od zFnlE^Jr~QqyXo#L)$@5KzHajESL3h#Dv&kUoc!uf8Cc%tcD3Z{XLjqv38Ipp%etG^YJB1b^)x+3%2pubBLNR8*eDi;6Q3{7t99j6c&zbp3tX#T#g zxXaWWAoKSy&b>Now&V_%e0BhyE3{CG)~b%P-9A2w^$Y%7ToN3u=LV8Jqb~v>qlmC= zQQ*A@NN)g|2TrLPUB(qYn@sTR{AyJr+p3s_(|*_F+5K+o>N>A>s`8!SkV6gGyb-04 z0UD?rW6rx$-5n8eVN-H;ur_z#!cT#U4b4YCAW40_=v)6)|50d~Kt*cvd+OH^_N~+` z?*8Wf+0pjC3GICS@&QtJ(BSn~VCACP+_I>S1#hw~ z^R)4wL1cO%B39w6i~!P{Q?ArNW3`=F$0Yze@gA$?eWd>A9Tis3^roCZQQJaID1Q+|ico-*AJ@sW<&wWph zko3WpL#2O-yfD@9369u?=h(?22px2UrsZM!i$6Xm44=BT_zzq3PsKY9)%1>b`}h-E z)@5;_1^zwco+EbT;B)tIh0*Uu)@5){anF=LjjW+p*Lg4G*~jc;bG-*d9r?y4M;Nt- zI6s8+$bT#)O=06Ozb)Tw5hFugAKwCLS- zj?P?sq3=b7q~S}X<@n+ot~YH)N#9>Lw7-fzkA${|z5-OMj|^q9$2So}t3eN1W1$t& zk@k$?Wi^=!xN)uT z1^&b)pyxfJr0uAQpLOEy?^oR8OZDRatowP9!R^5?(I`JKIt#owZ4SO~uU>QuVr89( zab5@Nkv(&88jdd~0sZeZ5daI%&$?ciP1Gz58oAK)hD%cROmd)1D>GyKN!j2WvOlkO z(rN(D!qg}^*!oa=f)mf7_uakFXtt|M%eYn?GxN2T(5Mz8dz_RnbMbfY?xTYs7v!XA zJRtEhw367qlfM?|G&8%wWx-`x>RUhO6(Vej5bopI1)GS9@pA(!dF?%j%94|ptX}cM z#TTkXAfIIn2+jI2*efJ0->!}=@2L#}xm9~+AJ|vFoyp>ds;|&QnmSa?q7oA5q+P?W zUs!}ecG1$^?EuuSFkk5Kb_VFO5SJ!kv`!oTH`H(t>aU(4pi-!FiZVrYSOnpRD5(U@ z)Bhg9jmQlEJ&eylipu|Y<3BwyBW^NWa!`ASgj&SmrCsh~7iA*}hfNsz0DsNRC0PKY z!;!y+x(ksLKUHzqbsP`A=eB{(2iAtReDit&LYzO(Au{j*VEAdPcEiq*E1U0mwBP-{ z=0yfQ#wIu4&vcsGaFm2Zucsnj6Ad3l-2+;U-fc^QAlPzgm{|whGK0=~(K}IIvF>DF zP(4EJ?^dr`p()t;;4AyLRSs>aQ0M&Y$j=mFV!b!iMqUha(&E?w67a+lKWF_SA_Hj5 z8p^}eGcPwIIy2(|WER97&DbT&2`p<9f$-`OQaJe0HZf%%7cV0>^4}kdaUhux)s*Z* z0bRz_N&BEl1Z1JuoHJ(d9ikp0v-SiTbXg!h7fO;*PX6uVAkcImr-B0v%r7X$vjfBX zc*}AUxO+HH?rABW$(5VDW(k9x2p$je8c+3XZXfaCJ=p!OjN1^#4M~14)}jeiq4lfx z)`^JkPrNS^aoVEpIlGqnfZ_aCmC^=9i{e34gMpH(oTTYh=zAO#7xJ1n3x zIodK&!!#89&lV`yiu32n92z;9KolTiw3L~V9M?mR8$b9xBK&tF#9KMxYG`iD#s2q; zbt0Z#o30?-sCpVTd{byQ>7T-LWw^y(?+9qM?EpmG?Sje{D&kXG9Z3|sVgNL*2Esx?NO)AzPa7o4Gd+PBY?|0Vr8HH z3H3AnoWP?Iz=53El8SNTjtffMDN6|4C}ejVU^c&XCy%JT#pgeN;mmgTYIq6%m-fNK zH$|Yr`(t;wg4aaCkrfU2ryZpoMUw{Lvxu6)MgFCusD3vUm2z?oIuQm&sypQ7-8Z09 zv%qQz5k5$~%)*6gG#)7x2p8H!mn+5rk)VMnC+9CGl=+>w#j^SFW909fY2e1AHI(3M zb&NZ)TiO?XHnitmnHU5}&RrUalT{#8P(59J<>oxSdS2c9QYZP7{~gp(E7orj8bX+# z4V)X0?NoW4sqhIhs^fx)YHblKI#&vDJljiO zKC6zw5gJtjNR4mBdb8eekT32I=V(Sk&d`4$3B zy+|5VzL6(tB+5j~VE&>$cBcP%aU)xYoz>MiSO%jarO#J{tq2`vzYFH97a;3zd7~XeMI+bP5JP-VFw|LBTw0JJcAVN4MAW|-*$ob zkIgjq6F(VomHQ{Us~`dqDZwsm4D8t)TpW8!^ISZWK9Vnyc0%HheB&5yuAsRFG7atc4O_Qt`Uk*(}?6_I^jobR`mbO{dCqBQ z(f3v*B&eW3<<2(_jicRd5TS2%Jt+3th*gMYySVQL2&LiR!$l+{DOo?x!v}$v*~W>H z0^4G(xN{K6ow$398e%IGl0$iz#}pGMvn6H4eOr~Xc2zV2y~xq(QI51^eX4<$P;TuN z3J~D~X%u9b>o7M;L?~oT(}gqbLT;|mRy?ZP5d#m ztunCayY=Jp9P|r7Sw*E&Vdu|ZPJZ2G@9z8hb2~dIx0_dG^!?PP!uAb?HezSr+^#_- zWwJ)0Z3ubSEcQRzmMq`-`%MVDHnnx!CVGeAUI=q#6n`V=Rj;bX7cX!p8nbNxw3Y=SlK^!4US2?|fx{jHBqIdL`mvnOzucpk8PJY3yUFqI% zEI+<|;IT+%Q_Y&ryT=pn5`g5(7ds)vzJ@!J5IOiQl&|oMm}U5ym}OUp_=l;^d)OW9mnXY~jbL{#mzG$$dUUC~r$+&G-9z;lS@WkRQ%6MoR_&xUnW7hwKy_n_= zqdZXD-V8-C=a*b_AHStv&3>z1^KzVl|LT8i?Fv=>!jk{$Y}}`>C~NkD|7E2@SX|U_ z_Osh+BXjW68ouGGwB_@O!8K(^k<=1q;it7@juW$AV;seLIkc!6hJTI2kXTrIuiXZ6 zA+*t>J#^>&NCT-J)u26IGine_&~8&|e7+h8xaVPyR4>RM&e*uxQa9qIvjTmjO!Wq% z4MN&@S=|VARN5=CiV65)*CFFvvO_}>=O!{8(d%o{FX2Ao%93$OW}kY)RV_&P?wOlj zHZDp2zC{?rH!x%RDlG)3vob*~d@5P6baQ#hYw6o&9*xt99g@)~HM!({k$ULcK@uk^ zhU6bM{p{ZL(caJB(iO!_7b(xjLZs9wqmr{5-J>qE<25bfDLp@jDpI3Y)rT;GmjHch5dA`y zb7jfP;dENctI-bMy3s{l$8!9)w3p2KOSmH^DttbD`5*sE(Ebi2lx32x`Q&cssr=su zG(2*P-fUeqzU0t5I_ip^J9UHrlvoe^h~ytPot8!rEGlTQ#<34D_(1qLjMebY+pN%ai?v#Lt>6T0;ykk@`AthbWf-CuD z@?Bbka2urYT0nZYLpT4**SM1T#+zmNs=q!5 zd`*}Z)gA(E-tk$!F|O&gZiIZ*By!~CaAG?QJ06_?n`!JAwrOs(n@@dUHnK+-0}|MB zVy=IE0%MZNtY3o^M&_#;_&1t(weR?qsJUfh$L!#*lF=u0_rlS~b?K507<3{A=V0*2 zN9aN8fD!&;**5=u__qvdOqdt0qLB)kEi@#J_xYib2A;X6xRM*e<2Ci(ELNS;)fU{> zgFeTGKR|v78TefCoc;FGIxZxxWFpkzgv;pk;_>?7^|~X>ny-BMAIlz3ggPo-tX{9X z(jz2hxvXCDVItJd6J!KpDxeuTRC9J+b9OCZg|BdG`{3sd-N9vKMDPznVZBe3ckqCt z)if}ke+JZ=?{SrB-$doNEo0zH5>VnX<7hm1N9i=D^*&^7;9FDk+)T*WuO z8r*=ZmS6CVH{h^rDZGs=->9ZnegFF}*52zmm+h5Cjkybx++Mx4qu$;|JRiUb3i!HV zq?!R%cxo2XYc3Bfsk3{Sx8=+2fmLp zMh9PlE;R7e|79~c89&f6px+uHM;ikE?&cS#eupWdvu2U*mlF@|A)RsK%e?93|dNWb$N8{WV5yqeSOg?)^kLGT5ex`6cS}Lw_z&N2S^zGy9*u!J|yn6&Mi$2?9#b=q|)h*7*?T<+_0a z)b#h zy~uCMhdh|uFPYiXqjUK$k@U>2xhtfXyCzrraBF_=iCj zZtILfg53f*zSB}yW0)BZJ=2!mJz}p0_k6sKXe#e&&M#s}p6{{?>Y81>+RMSe>ORM1 zc^te_Gk9sw9N6}QK7=cJb4$`-FZktuldB8d2MZ%x!hzSS&}hlLjU)58^$6s1_5_3J z0hJF{1;P*+4N@ zmd?(A$^v^nLV4CA=GY=;x-RC}x!%#p4!tTlcdg3tMj+@QBbe-ARC9J|dMrc|tam0m z)DbLqT$uU;^~k~LBUvk+^s7R zYA8K4cs4%*@1Gz7@mlVL^IV9hd}H`GR0DFvwi)nyex;ES z3>IW&lH0ficj82aFL+oD1PD!#w+{wawN|d|K8{!%BnAZU>|b4KUjgf0Or&!dx{TKd zAsP2q=e@kDCO%+Z#-Ue$I80GtpdVuLbG$E7K6HQwE)g0MXA7fY0x}w<-I(=3t_bOC zR&~Hcd}Hx#+KX|?*XkwQOX_8Rez?6}CmGng-Uu1?mf4Rf^P!HCV=17IKtvE*er+Y_ zTO7wosAsKgAMEhm)JRvjkzl+j7rM>=`O{e-$3d~zSxu|hg9om}&Ens#iA^%+ucj!M zd40(AjkE7-dzxbV$)IEv;?wPp6skzN?Bc~D`e0_n#X9;RMBzyMG28JJYz4Jv2wzrN zcMxtU`IEc&_>#~+e^QixxUp?$qxVtcHUG*bxkg9lxlfr+-bUMAPA_kI#rNgj&n;1A zKD&8kE`w)gJ6>J;6)@g2dSV95c{kMR#e6Ax^+hAIKK(RU86;YPT{}sj3fPZb)EJoE z72CUk{#c+`jin z6L?OI&4kqjg9y!t0Pvw}4%$gx2CMQ3?9M7V$nE@_C9lG=&4lx%t&^zZn zFg|fLaf2#M2JK`3Gfe;On5 zmEShBhhBF=m>dcG?j42skl&>89EZ4w6 z=#$zFv1Kp{-w%P&U6;Y6KI( zItR<+)CJ;>fC+&hbri*%{hII&itT*62<-98U+?&W5M%L5A*Btv=i`b$iwSiML6@lb zimq>j=B$`yKaBPq?D(vn3o*zyD)4N{tARC-P)FgADRsnhT+t1$~l*_EyCh zfR6;h;G9PP21&gx@IL;lNbkeOB;yj)Ox%vNmkH;u2m_v|TK=ofa}HYTUo+m1|2!8b z0sCK->NcY^zy&A81QEv`7(W+eNG)u^Ne1Ie5`))pB~lP|=joauVfSnuBjVh2f# z=ngduudgnsS1qeoPU*hv@c!_LEA&EFTw(LqxT_(NFJUkR_tNoiOAy_GafX@LHa%7k z^%8cd0l2k}j$k4_tK^N6JQK5= z|0*3t+l>1dT6`}1&C#hEUsnFAtVXWpXy#E`+mcq3cQq4b^;RS&hE&G0gpDaHv-7Y% zoU4yBzdc-pGibPRQBGG!>6Tz#ewTlGX>NGbd!4x73(6n(1qfOdiAN6*ZkkyNwgsuC z_xMft2%WRe;<}^vXyTD^DsJx`y~+jG&r(=V9I?0T^01f2%j!4ew!F8}8pGDJaNp^+gpvP4BpbYCrBCCi|r1=bED)EP$eG zq_vIGK7*-}$1z1%f0R29q#C1bCIK`9R|6kg4ld|ezzuVs6xJ@|uyR9-^K%tkmd7hO zx-82x@t$bXr9pR`Zrb(c?#|(eXEMimUC7ZZc%N)h;m9b)7Qv4Mo@aZqvr&1G-c|wp z;g-Jfk>10l)7lQYlJON91G-ALm)sOzV(O#x>gSD;QjNl6M_NJ>YUC34dL}Z3>bqVk zxOmr|a{U9rg*cTu3646%dz+G-zX;q{^a)1dZ3mP!)kNa#7G~e+v z)2!rQkfkQtjFAOq zM3;p|tVz7Ak)v0jfkNIjI1z@Q7t>?-O#oI!R|R(u*ryh!^Ob^Jmo&ZGO&2d$#>nn- z2?7b#SUVxWT7|~If z(~@v)&2d23^psShilQyOJ}O3pl1WaM$7K!DUMaY%;D{fs86gSIZfJ6qyrES$UDlyO z=h?gp_LE4jwQJX{_8I&h;(Y_3^T&cot`i;{e)UY$@!20?N7y<32(>q0S#S||MC8%7 zVEp4gn_Tam!`ZouHjAxnQ1~&#OvhSO5`@m=05TI7oK*#+4eV5 zJnigJ+RJ)03j{-7$Ac*hEbK`G@uoEG@h&^uO-W{Xq;|cg4EZ7SGC9ZMCY5(yDw_!R zE^}8T1___tclv>v(%v8}s!T0eFf6Y`SKGm?I#7`6Qc4Zt2Q>Ah=RG`nIy@++{}eM# zSdQuC3^GEj!3)(hJ(7y-QjJpN=yJnJMV<%?RJmYkAh z2*SBOw!=x(B2NPW6(C-@r-~=Y?A!Lv0WW={q)J=H6$4rV+TAF;EB;lDf{P0Ify#`6 z^C_l0F3IxF-pZUlo9Oh9EHx$BpGD$MzRnBCMiopOnNP`5m$A27WvO*xzQTT1GWEK7 z9qxwx2Kz9QK9>-tOh(_9BI=E_F6m4#oyoL`C9_2DT9t&f;iM>F<>Y6chIG01sYL7E zGCZlC+jZfTv_A7_YB#!Gq-aKmJu1-}BS9=VVm_9>Xz0e4n9b_rZLUYnbXqh&AjBP& zyge@?&O2Tj?t6zNK|W|QL{?hw)%f@^TqmIND(5r64O)-LNVRbF@X?GN zI1!x44ID@;p#2fKN(6_Iur2$8c`vG$qSKc)%oNaJTr>z!)a=y^l-dF3dMkWzomE*~ zL1Y@y`I3{D8c%02#hk15CLp8DY1m3NdK@O*r7mmMTX*UzjOZ4het1;i)!}{J!$1s? zPekCAuD48(7hnh1wph@!Kn_Y8g$?wOvqqRvSi%;I%i5h*{i^z{w7%6JQNAuN&YI5M z$(S2ww9?ardJA9NF107Q61Xh0>rp)#S?7)XJt@gY$`qW}9`me%hN9K{ zfk@9YN(N$3yJHhFr1cSfmE~iiM84;Kw#zfof{lpGbxmm?q@Uf z^f!ew)#ZhyB}P{mRe4KhMs=llK>eh)b5HtAPkNh^U7#x06u~mSNRr+)Z84)nldHK5 zxbCYpoQT;crKmIR2VAf#Ax#SFh;!B(Na9E(2!(NUx2uP(ce!d=xv~fVLIcMjT`x#= z(8;ny;#|@Y2^~FN`hXDZb>I<7(5%#=IVS&UuD8tt=O=7URjz4mZ{BfH#=V$C4u;#8 zv0&2`wLeYZm^PHYdU;zP@}tQG6;H4s$z4(R3Rtj|3MdJ-PEM1Ovp_e6d4Q5O>}B9| zElq-U%Hfuo+o5}JMFjJf3#hjI}ll_OTH!Y#x}ypuQTRbSlB@3d5nwb^oBVA1KA=ciQX>&pPTci?o{=L#jn|jEjmPG`$wbv;zl+nYKIsQ1ZM^ z?#VQaqzcGfDvtPKYhU>nA$f9a0=j=!0{W&$7!mV!e~He7g@_>gEl>MlE#_|qxhFhW zPL_=75`%5QJ$}I49l;=TBk4UF*=63Y3JBuKJ#Kj1#3Rpf(5w?P?qKqgyIGzF`|6|m z2Y|i(rF?V@d{qp|QA)1*6E7rAF^l?%o~`c0H0*ClQ|-@aw_@MAn3$9u)z{NsHpme| zdsv9v+TV(o<>|(dunBUZ)>IZS+i0|0M+Kr(rybMFf((m zJlHONut!(oO^UQW(Yp~-5s0Zk+hxe2DSZuk^s*W$&!Sf}bQE~H zpU7?HeMZGf9mnUl(-X#bJi! z<>_lIOVqxd9SXzd8r2H~F3s$mlwnVv_BE&++6MOB-^CZ2>|M{pWVl)2RIn;w>wB*M zg^?A|$+_oj%kazijaVEPcwx9o&?AIRHP@`){WQ9zUUiU{`d(fzZ^~zP*M!_Q?}-j4E?{f9%PCEv5o5?+ zjn8?^XOZ67_Gz@O&$ypwxf>CjjXhZhXbEz|6dho)*jI;n`c29dJx}?dQvnNRAX;d} zUvKGn7}yXcUiLCcM_yGaTeg>k0~>MN;lNxxo)A5rWfz#8EACL3Wi_ROt(cDZ!te63ITTVpk4Jj1K4rh-Bnd!sLmqC1FK)1qbS2>6MC$hU-yt z*QVI@0?i}aykF-{5l(;5^5I{QA&_8Y5Y6hmd20>u6g_>k6TtClXTKFmk6+Rr9xm^u z^z}{CM6PR9GJG&yo>9Ic*-SlF{~Ic0$)dgOPU~-laocVbG$_1|dl870R?qhHk|~U) zE?YdYvb?LS6knoIS|aGInjRi3bk2r%z&Tgk?(w$vG3&$fR@H29&L?6dCd=h)xV8VSXm~szC&M8;2Erh_VOBD$9&dtR%mws-Ru0qCupiW0)%%hy z&O?+aQVkXtHM+A>R5=(bV1C)=)L?GOKU-|+ea1`N$!r3GG<+6sbM*ptkT;zj={yb* zU7BcQ?^}`UZ&I+~X(EgMD3K*wpEW*l*GURj z{fN7OUCdT^BM`jK_BwF}ce~)v8}Sj~u*e>q>(s1{Q5d#R?K5c`9u>b}6sghtT%nAd z^H?+*f(W4^k8=6;HA%K-F}-{sF_@TE=8j|T_?@-S?PU3~r&a_zMQu-bpCk8^2WmS< zV%qOCJe<@vcd9995O}oBQrE~VfPcY9P`}7*LN?{p?oLmGLkvF;n>gH-PU|TgCdE!j z=+a}J7*dT~ikjlvuF?6Z2=53a^O3mi;=J$q|NJitaDUwK*bb+&w_mxxT^8a0 zr?hK4cPbzB>Xu&@LMO-(^*kb;38-MC^nwq?zZICm9WB52jGWKi=iIQ}$#zfi9p7ypz4JhKiT8<11X8nMQ(X2@Sx$4f<7G z9yXL7y>c`}H?=xBG`p||$M@us?#BX zWr1RU&<#1*qp=^n`y$^GXi)L!$uf5pYi*E?OVyg*POxvW27zV!8aK9Ghv9bvD^pt2A|WvCH7?q>dBjR^?#zNL*ve)OA+p{%GeT;TL9QxW0#vX` zDWZ`!kF58(c+-R!ZYJK{Dn(-%vY5}sqsIC~)xkF}WWSK7xxLLpc)A7iBHasmoS%$X z36ZP<8THcmCH@6*-sVT!#SwJ?$%k5%P>`YUv@VZ1%ezZLjI${w<3%$TU2okqlo_s` zZF#T7A19+TxP~Y>uFrVhrMhZn@5?4QogI0r#uFXA;%V-1Eqc{8I(on&yR0XjUC?Hv zfnq-_9&Yolh@U|a`b<=x*M(79M2JIUhglWz?BzmdJ9gKbYlj%lraCGZoo9n(hZ%l} ziYWe@4ma+#FtRJHN*4AEQ9ADC6{_~x#0a06)OMF89b`Ws>>SXn>D1ia6(AU0r$a2mo-t6cay&K2x0!fDm?#ab|)%=%d{3bPx8 zrTZ-z1(hhlZ(qE_Ni_xasDfh4U~!gau8a?C1rStn1Tx$=q08T5KN!~ug_oy$w1+?F z_z0iXx?h~a$Yx}hF5>O*g?r}oN_1@xeOqT-dZ+1A&}w$O0M$yk8OUH%qVoPkkXNuG zhzj@ad!61cdrG*=O|i-9QmoBj|9rr`TO!#c9d@yo;4A}gS(n{(=`R>BEwb;cwbTFQeV)IC=yq-Ip{5r=%zD*U|DgW*3O_5UZ6TH|s%I?t ziyi!?jht0)I-~TQg$x6BcexY{oQ;0~(?;of4{NG&s}P{gyDo{_dFK0yL>KKiC6X#j zeZ>1nb9o|Dn{OaN_SH!Aut>7(JFoas@dk5U@q=ML1=)!E8X+DX=BvsTs7jqyh;FH# z$I2}GFa-H$0yQd4Xf3|BXY~15gW^L^3&xkFhfLL*t@-S0NIM5T50%=)?3%uDOOLeI01RGX{NV6N|YvbJ!b?R*n@ zy|+``=}NO>-lJ_6Q!!iotVAY`oO}(_pM)upNN1c;t>WPRDD*B6NPUorX_s?vm(#)C zLa|d6YKz)uD@0;Tt#!ENtFd>l6v5I#pY8Wh>UL6=3JNb4l-gOTzPP7KLJAA_e#2F& zU^nFh36(Z?!r+1JPP--@Tm&P%UvD$W_{|o}Ew?Clj$JA0?~nw=O3n7krzNef~`oKz-IzTu*FKcKXwMCWh0Lo%pi1FTXG3HV$j0E0OFPKLSIym!u%Xbz=6-*IgCrzs(z)7xdGcwZ@ z%(D=F8a)h5g)(21joZ>8m>|2h?T%n%M1QH^)d?a0|9tM9wt2bp)k z8g5~dw;`z+C>W`$!=8DX9_Ev)zzR<0?U2k6+_%dah&V{H3&=`mvmf9U?1E4zRrBbP zA#2%HoRcGWRh}hGlsZCf76diV(yT7Rs~yaLnoe~=MW)INhWU#AV|YT2TY+T;%Si5L z&$8Kzz*3JSKhHR}@UF%g8erAf^Wr3UrE4T6%ud#9=IErhQ|yE+_er~o{p^Lj330nX z(d>Fb)jm5vi>6Nh0F-&D6=94J$1=#atbZ(6ipS!v6oT(N4pE(5ZA^iz(9X6bKQnNC zeVSM~!q3Lx@|54JJfb1ub_7peGK!qFm!N<880Lio2x#){($NQ>MqZ!>h zTTpo)Zru5~VH>asIJk`;X+7DY_IoX!e9-nV*itXV8*~sJE(JnyG%uF_PX{-Ji3yi7 zKcB)9g;^w8eD;r#I8ZB=g*DIr;ZS7BXxQ%k&!2W58Su?K^}3)0qwRgh@(I|sS6Q95tGvsZ3gu-dKwRc_7OpiZ17ErV-oWz=|AEcXBNrpVmZ&I}MvngH#oqx`Z^0J#bmkO zwXwVd5uIu;2y`iGe?c3hn@JGO6loi z?9dGNl5KQXg;d@Z7o-(2+r4e~m&!aIXt%S4EppJISl3wSTRs7s)racglT`Ab1>%;|l z!AUJbG&NT#%YP7MpdQQixsg9G%n%_wASkj&ro%aTTS zkHKSOvLxQ+QjP2ye^g$CY+rBf>~RC7tf#yt{s2*)s@{t7K8^m^3GBgkM}@3&%9xk} z?CnAM+P4*#`tZP(9mh3nUAGIg`JM6R#s zQ`)!YjbeB34p_DN<{!*^K&rJ7Bt6a@aAeJeFD*m)J!qW zca__A_o2}BI^J<1_ia91;8)%DSSMxi2+2oeKa$sx6);dzT7l&!mnUi8=l{gKRQ%T* z=*!n+s+9F9O~Lnc-j`x@*06khcr_15TcCo^%ori;SvyP6ThSckGdq6zlVEtFsF7~I zpLk!2>9u^J8(4O4ri}7_#$JA%{r`shO(Jlefo0tPzLB`M_?qQ=>>yR=kYsSrwj~X| zE?5rC6}O3Ak&y)@nLm`NJ0cn>5B2tNTGh+8jw4Nlr(Pp>B#Pz&Wf1beC_W&Rbk4Z@ zz>A-I;+5x4ZoM4zYn*+xNa{TUKKwnM@t@7SXiwrt{4doO5Wv^UZrB29@19GE?7jr>ufwjvkfXb>Yr04wT>12McluL%-C++P$K$ zxqgl1X>*D{2|=;hqpf)(`)GyCoz!Oj4?41(?t?S1u3=HA*twa`J8qi!{dIRIrA;%l zx&q|Gvs{lGWSz5)b?*LsfYfI(^rUaEu^qyuLjRBu-WB;LEJK?XY{K<~H7H|+kU6Su zF3_Uw8cVaZ_0oLy$G}cX)TEm`dvj_cp`hibjbQgku5 zrnu0Enr4RX$$MzO)E@(eLzqiZ82JSji1oDQ=DvtXRUp%=r_*7HKsTn&)_GgC=?goMWU^LR0x9RdjwCt3RXX zsbDKuz%G;V?SL}yndjDn@X1))15JM5*JZPv4@BD(b7Kql8uue@*%ce5MW@Gil1~@` z)|+V6toL490ae$;tDnrW!tz*o@c}@1*DXTfJ;{@za~0=Kh`2SSbAE!O=lJyp>Lv< zU)1woZr3bP%6G%i-7pnGh8*Ld(Zj7O>?gOq_yDchS10T}g`}>j<#L_tb2;F`byKBw zUWrxw4R>Fb=g~=665udT$KCv3IfJ1EcUF}7>G~6lKS8cv@|m*Pa@(?cFHPBVYdpWZ z0)_r2^Sbz)^ivAaNi_$UiNACC?)BgFF=oje(bkz3EUUV|x3soOTm~nDMnHaR3;DQN zfABW!jvF&_mB8hs=Gh2^ZqN6Pe<*j&>CvFO)*7KNUrEDP4;X<+PeGC zyVtjKU5Y=sS3?9U+v{;1rjieHM7{Mg*w@N}%$%Q@L#}DoOZv-%7u!vXCm{kXe{4@K#OBL1Ne&^+AxN+Z$83Ysp%sNpvz-!cF3|OA*Y#adn6X=0v9P!M5L?8vB5Us-;*UBNHT$A! zm^*l$u%dX{>QnmqGTrRJonx~@*Y~klk2=ikx`h?F_vG&wzHWmdB-}$kt)VSr*ap4H z2Jjjrng+9FbBsCdV$;^Y`8+%CgU0t2pVj;|jC_-HWNWrzr}%E7nL&%P=Pz%5W%B9l zxEJ%M&RETs;LAqoaTNvXrT$Z)D4vDT{*}rjr=Fom_=K!fX_Gx=TOaC^g``cToxw^G z5qG4M^8vFi{NrP(q-ag2(=$!gIeY|2&{o6<6c}KJgcA;>E9Rx>k%a}KF#S6;JtDMt z*se2sqm9Dce;)}W?%-i=#RUJpZW4O6{1T80Wt2{hoq7Jta@pWMoLpj=i?ok0KK>?# zuDGiYAD?lvjVtW;2r!tS>Uk5aKo3eFazI3LCNo!3Qd&L>_0SfdV(9TFxtJu0i~5)) zr9cS^r5gvC`mD5ik!z1yBgQ<>E7W^c-CK#A+x`j|F5#~mY<5rVNEHriH5;?wX~m>7 zq%@64t7~dm7zJ#06V# zA*o@b=z5MB?;0h}Z-7yhIk|sxEtX?jBsm|6^UzrNSVXJxmTq388EX(-U4wkkDOuRA zh?87UGhl&U&ZU)Adop2<&!Lg99XvU>qf9%`i&b}1N|r-k&aZSpN(#c(kQXpm2-CW^B0Wd-2U{wJ`2GPlGh!NSeBjy;|%vKYU zOirDm>&czvQB6{wB`1A$==}ky@|mcb(snX5t@=eUFU)$vmPL9n1-UF z^sWL703{HWBecIeUSHGmv2en*<|AcWF7##^%&+IZRi9Zgu7yg4hS!UqO+0gGyO|Cw zL{C+8b#IL~y4Ts;m2{G;volQN*XxJBuXs~H2aZ0|)z!m6FJizFtbwF#AU2a+p~xjZ zLw z?X4WVD(M(@TP2-5LQmvikV|r}#O|Fm1iMbK=nj9r1ZWF3@jEzR>w-XzVBHHV30hvV zn;zNy>b$dUA&eejxTI)yHWsf$vcEJ9thWpbbz8k2{BoqG4-o7cC8^>6 zYZo&)M>w(fPS~cFNEZe{g%9UQzVv&fbjvWxM)=J2-9K}tPzxjLOC|i*$%KSk;OTA^ z7RXDv?x#csW)AO!j!!NT?bd|DJmtIMmrdthuB$J8dy%CM)&Pq#%9}0v!+yapP%crz z!!?$kirJloPcWb}BxE@P0zbDvK7aiknkp>p-$bYLnV?{5L|AtOLHd1H;Az3mPMWnO z{^V8@B1l;0uMwI}J82QiN=5Vpov}Zl(vc*J&hin%TkX?(-)9<~m+o7+aUo5_(%0A5 z*1Nu;v%yP)pHZgxbUx^_ioI zKK?jq8>5J+t_)I&q>`k%)~tu8zS8~gTJkBNnWL%ige_yv>GyVa!WsAsbUimcP9y^} zaV}$Mvj!MCjhFz+d>K(IMKt2^9pnlHCQKoY?&^xnPdJoH)}J_~H%JHgJ}E*o6(5)( z@VxtylSovLl1jYpk1Jcbe!i?ydRK$1R=K5;tI>VCSXjS<*>)$__qZXlN2Za<ax7C|IeC@CE`T1lV_)CjP9=zFs0 z-1G#z2;CGG@;rv7oj;l`W}YcXBdXL?&Y@ohpOW6yf02nU-T@K{W(Q3R-UA%Bj7~K+ zRHgR^XY@?0xa#QG8`jc+(D1pFvqH$|S^cEMIBqnxDd7MW-4d@atzk_c{l#@ki+=OI zYsg2WvI|FPp~3^Si$?b+Rv)ycCEi1K_N19gDiWa?E0|e`X&<;EAM%QHJ_}jHi{1}w zSs_H#$_6-S#QWq#A`(u^-?t}%1_4&1(`TVNVPQ)!88ylGLCN&c1)|snN`#k6crw{w zLUZb@;m1>C8nK9Gv?F)q@crRODk?c?B55LYx`+r}m~LX!x{L-x_-rd!#3X%1_%t2S zZ~#JcEHj3NFch-N0bDiK;DDi&LyN7y%4CB4=d9h zbDGs0K-el6v}% z3D~XNUh%$PXOh7KR~;?-cq*sunZTnZgXX~Vi=lhoR}gV3Mg8ZM6J@3hdQ-AwHOz&D zEr?JeLUZsn0GCQi3`K|g!Sq>UJClE5qN@w3A58}rKr7oK0>-+)q7;A^L@IzQU&#Cg zh*%>%RxF~e34eMJB$a601XZG8qzGW(-4~UbziznroIjdOc0CK#QKzBh&`NTmA;LkViaA{_#EoatvEMKjt}sfbR| zS%|WWT|CR~mMSMAg9B4xh~PIdD>u{2Vpcd6Tnqh{Th^Pc?>1eYAH?Qx7650=sRM)x zG>HJL&^07_AEuek=$Q`g_EkbqPp1PJfirFBNbr^c&Ft!`DJLCMEP9vPa~LXHAxiIi zrG6g$!euPuR8&s?d3pjpMM(}CLM%eK86hYz_Sh!$v{M2uPO!VRxXf${NAj|{F1~BFtXoego8)U-|aE3(~@DD9mz-Sj!IzRco z4c#4~!r3_F;hN-KGoZflOoWI2z9n*6^5a~_4rz@a`=HaK(1}g&;qG~TbZ4RjiO>(_ zbIw+0Fj<@poy5645hncy(m=`BRyK zKsx`p@zME!+*pO)#Xvay{{slbnpw-=F-9*KO(6T{Xu(M9IFBkPy9VUk;oD2QcnZoQ z*RHAEIS=(IHled&jVhRu)r6>aM<+l{bcfU(qYe)TJ@(1*t$(ldU)c8l->|S~!Eh_0 z8i~>_Wk~f1eTmXOff)bIFYKy;x!P3XBw1Loi)Lwpe2Mm)FDv7@bRv`RLQ+zK2upb55Yge1OOffNPTJN z0AzDVssT=+5&bePIClX}h&lbloI2ld51CcMIKrvOCIJ>_bMJrrlHqdV04)HPIz?9~ z!ZO@2)h*B&s13@IX21>tgd){*H~^@FaC^B!q%-k52%CU71K#-6y-rxA_>G$aDGflr9H z2&jDYyQP4FVKe)<{Vw;&-|y36{wVq1ii4A2f6*EFP=-7#aS6Cxgc}fHMyUQEXY&iY zAPR}rNM%TY95(zeXn-HkvzvSgh8EZyao||#s-{f@0pf9E>C^yz>VU>PR#b21N8R5k z{i6*W(EgnbGx7lnABO_=JCN^YyE+g- zl_1341c01D?eG9D;plI*4C;XG&cf3Cv4u-$+Ii07dUW`A^xw+*d$|E>!U6`H^&r*% zcnWra3O_auiGX5gSUw6d+rR{{6APaxt%3l8)1p!93dS8&L!fl9T)1{=-s3&S+k+7) zLQ4LD{}2JrAyUlWw;4p`fhvQmzz5kR>FbE-Z*Cl(2d&3p&^=`Y*hKlZ&LeUW`mgV% zgnTcQL#x+NBFI%>Q7r;Zxbe^XoC9)xU}4X{SO0fVKmvP{kg#0~fe=IxOe`E7;z&Qo zsw3xrw2QM3yo*iuLCzi{Cxf({m-zLx_)eL}=6L3&>i@s0OO-|{`M%u}fW2AH(K57m zfC!9?TpELa^G2lLM0 zaqbry{G~FO>crj1wIN_9HO|$|NB`Hav<=(_8Wf!YO4NW51qwO^IsCqX)EEmG3&61v ze*)`!o1~qF;Y5@a4d4jf+WPINIJY9#um*n^jNDB{3?AC>zf1U6QUAR|hO6apXpNo+ zlXD6c1_-ASpCAt=L4FGT@o!(QJ*|nYp!fm=D#h~=tq0q35kw?3eW|FJ*w)rI?Z}v$ z8vF%eH?woVJL`v{{x6y$g>P$C=!OCzJLtDY*S6}-X_=mvzRQe~*3XJGG z|N3T7oBhS}m7F08ScgdShl`>mZcO6tgJ#Q!TkHUkkn;Z@4bMIYo1u!lrI;1vds$XhWV%gp2U}f83CVPYLLwlTQgmp-=5;MIJ#iCqy+@V`0z^*+I0RbU-p_ zJw#uD)&hESmQKF2S!xS93O+#0(kw0w{%|T%Q_g>`Z}TB^A0DCNov`m*n`8hi%;6?* z6Np-y5WwD{R1Q6n;;ey!QWaW~o#AHEX3#pIAcq4v%!5?=A2&cF4H{^)jF8U>4Iw(* z3jWxhUT8=%@vYVltucYk$D=SA24LcXsy7>{`@uSKU zocC6$zGGIIS_NV*6y0P-)>Q!A}gwA$Dofz&B3=O8PuD4!;dC> z{V1nmJUwl_d~>n z?ko3hsqS3e@qEj3p7;m`x5guG%I`5@YPTOgSz%Ncsoh>FEd8Y2TksI+NQw=PlSaGk zoyMf24tKG2Z#Z-9)7To`t7q_})=JAg)I?d*)^wh7{D4F!4n)P14l;q|=ct-A&{psTcyEiuI05+45>hJASX|DLHw_ z1p7(cppFmob9XX~?K7YHwzp#c1G`82o9AWuj!MG7mA*bc1(KhfOnBn8iH=}jQO3g* z^9h1pI}9#>SBCX+0?F>$k~ZxRzeqBFX@2Ijt=FDQJ-mN_;t{&Lcy9#;Fehp91-1K^ zb^{;f93qXS@9Wl^a(zvRx#QBk2N>0It{j{%rY22XMq>P#_#vT7FYYc2S;we7o3xeB zw(CfX<+f3<3sgQ`bo@|PYbgdB_#{buFuys1v9mwfzcv>u?w z63v9C>33`K@h1y@3sgH8*s{<@Erf`{Gk?+~h*(QD( zqfMs_i+A{NN8bC-o!2K11^vjYb7!2U-9Q}gz5vOBb$V{C56 z)XF};n}po~gOvs@@bnHipF99Vkc0~FW$+!9HF;n95ZjSh0t3D-DXOB3$Zmqu+x2Z) zcbvmy9c+Clsi7=koUHTkCJP}XNQIDwPBZ)WlpDA*8jEr+QPDP`yI;91lzQ`qv+|J( z7Vsy;d5tO8Kb`j}IRj_`$N7^;?)Gt7*!aqOi`Jxx@L;)f zMOdOia}t18?E54l_du`vxd6fTn!b?|8@X*T9t2+cR)&Ff4AFC!vR>;D_(&5I(_2sz z75g;Q67-gI9#Zr^>J7yGY{QQkv&U-fE{@CQ8lar7?;MEuV0Wu)q^;OsH^^xevCod` z86{NN2@7bmV?6!+X6KEh{h_2I8k>edJP)e`-GiSGY3+JAL(6x39nPi z+ELCUbWE$q38cL)CBwUARZ3(FWq7GxMbhStxcphx(i4zeYjkZPAI>O9RR6boy6&&3 zBR5Fq?#ew6UR-;f8tya_!PrvfhyDNB`VzRNu5JB$5o;L4BnlJ|BngozpiClRcqJqu z0!0Zk0t$pd#EC&9)!s{FCIrH06jTDpj0{2rg(89m83YUnN-fn05pgJjt@`VG|8;`4 zz5n+lk`oB$?7h~v)?Ry`{e2m`Mlw?i8x*bTw9otVOFac!dCo@k4t0uda=h-@P}^3k z%EPgS@+ay2j+`|!|OGH8`q*aH+WRf4&<#B+;5Nck!n>yG2pxrL;^Nnon4KdWrGCmj`T_^U4 zs9Wv5rdep5IgIQJzguu#`%13IbeHO7?=W$q)Y2ZHw}m55{{hc$#G}N#dwF`qaycon zT=RmGBo|MSLbFVwsevPp?zqOqTJ6G_h^F|Byo`(|c`=ZWLyU;fIBIl^Q=+*~rG~~0 zbkXon8B@(Hc)efHiqAY{dN)7WV8=zZu|{uKgBIJTdV$gFQ)~O)K^}gQz!}Zm9$dxl zUUMe8^|*iTcH7*5SdR4Mc|hEgyzsco2&1 z9{y>hR#VW;Eo!1)^S_Cn$+uwXMKX2~JVUq7i_a2vQG6{Vv?D#%#T4P9YCY6nJGLC; z9{HwORk{q@2Em80Emd;&JTMyLkgdL{uv3*(suoF(%O8H>9&vX@bK)`L!Ku5G1Q-aC z4b>@x*o{r}9|!@|z|8I)kiTAh)ld9R(7F-A?&uUAhJ_XdUuf%v>2@T)G3Oa0{Pi=@ zReA#{)w}$NEjcBU@pj|ehgeO-M%}@q)Y<8?dUpMnLp|t3VqW3I zn<}+yuIGNnnZ^f93^ig?D8yLeFaG$~+18f9`GcXpn)%bA`|6fYd7eva!$iN^;So{u z#yw0NHnO!2#?ZzM1~yD{j`X>u>#gtnq#0E|;H|5EAY3p#N}XRi5Pe2{&`+9z!25z) z_X}mf%;9gj{Ef5Ixt3{zF8$WAVqQ#-OFxl%QyZfY*dtCGt$@!+w{|tX=7N5QO`enz z34==3uH%QP^VS?~bj=bj>&bbhEqkN8!TB5Bvp$;qz>^hIO=6(gy|x>_S3-k5qH}FJ z?l*@l1QVqr9+=qe&)-*SV-5#09zFHw?8ciqTQkI&(=co+EyYv3+MB3HW9X3Lk4C%# z$$-sl$<o(qHk$Gmsg`Ca;ClSAQCncw`3Gibbfxp3lj zh9AtUA_-OMm4&!xJ{6vsHow58o#pt@5znIFYyLn*@i&va98N6Q_e$UBhRTG!^9kNE z^(d0<%W@j@g6}+3+Ydm~xba{K3?a@OneGuYdKeupPw6brP$a)%7-DILOCF0mlRrE1 z%1;c5!zJ$kF=n5LS{XFwLYKInn^lPGZS|yHK9*O=^w8^Y5~`^e`&~|Rsm}4P?Q8QZ zE=aZw1AC_x4-gB-Q$ z#?g5&(=Jvc)H;wCj?ROfBHrWhpU^SH5kYr0rZyk$gL{>s5^~&%uv*wuj;P!8*IQpv z^QDcJJ}YG}qOBax1}-+)!Uq1^qr7*nX4Agfqu%l0vw5DUFBGoh@g%V#H?cr)riR}V zQ`CPGoP~_Sr;q{x<9ZYcw{Fq=%gx`Zx94O`wbYj2wL0^W1ONl$Fheww$G?DM&b!8f z)$m$mE}x7Rd5a2mIVxa@r8gDgbVB!;88nz%!R?l`4f(3S-qXtu+I;b=EA>iVz7?=f z(3tnTPLxl%Uz?7w8e|@1V>HWnEq;u=c zcM~?P03Fc2AXu~SA`^?2!-3F_AF%T;4hx_*VGL)*(1lX^wX=G%v5fEbQ-+IVB%^A$ zsL8K@d>dz6P<}y(y}dE7XwYRq`tDvPqyI{tG=&BMR_XC1Pn3W|cW>`3552yuntoo` zQkOM(5~O7mZx7~L8uJ#6-sx1Ojwb}uMk!KLfLi_ey!Fs*c9c{zvS->*}TqpW1O&qSfw}swt7&Mq~r)Y z;B?#)=4}US68ZQK_!(pV{3RAr2nQ=Yx=SSjz9WX7AQ^ld&_s=d9ULv21SJFqXplMm zSoA#jYdYnspBO1v-f=WgAm9P@D8pPYO$w}Mfnpx3ro^lh`$8on__bN1VYvH>l`(4d zs-4`();pxFYbB?R_k55#_1mQ*(RvWtg#8nE$+ttVsJx7hr!aZ>jz4nAi}S%)t-ZhY zLW@G+bk1Q-K`icuAQtM6ZZ7`?zk|O?ptMB;ACGHQQ>e^f@C}ply1#yZ z?^)oKX@YMRr(4I(SF_07D`DPl{NU>##b=%Ou9{0>ucGm-n5{#vTuTPkL|*=p!>$m) zrU!_jGdSksOxe5ud7C7aY15#zePEzy*??!I0??Z(WwQzY(VQoJzm!^RPx^^M-r zH?(7pkDp+ZpVa*xSiSD%T%u~wTPtN>WV=0&d;J>ePGruBeYw66#)zSX zy}|{mKXi*8<3BI$QIgGZ3`Vl;ep0)c-KH0@MD-+go-ojoqzG#nvkLCV@j0$sl3nxB# zgkONfV?Bu3@)66wK5$=7yBpiCJ@7C0p{eemX25XWkHWTIv6(kZ+GGXlP}jwq0+k#k z5}-T;Hbd#8<4&C?ji2t+Ry~r}$|R1KQ7d~~?uezxjN^sa#s74tuh8Uri9>|AQz7t} zqseh7IF^sisv@RTTW22jKE3{!PoH@1d2&Ou)v$M>u;vZn zTDFxOUORvvA)5#vG8GobFIpLNsktjL2im~OJ%x-LIvMRg{wyiA}GRI)!JyqITZcC@L}g5%1A`)X(KO42}fVU+c%m*@oY4t1J8^u`jyr zwzscly3~Ji{K=I{9}-NpVVM9be5LJ{w(jalb%8%+_ne*R#;}MR0*E8iH>C029f1RR z?eVS`S`x>k_$iCffPCtcJlG7AiFHa81{)O4?;?)OwVd?pR2p;oLD5IsDIBKh2@BN#AJus5kBrLMiRsR3u3)q8vUrJif#2%y@OsP=(yM2_VA9KR<%*Q-L7pj9_61o+@-3szC1$TTpHj}GPsEZg~Q`)A6Opv z;!g9eI-d7mK9)}+`oUPyY@2Nt zy>e1m_wc71-Uk#s9*=FFIN7%p1c*W6qjxDtb3=jVe2^L?@WXgCk@&NduzpC7lviTS zPQd2BxxC`tK^ZcOmbObsNVan^%->FJH>bzI0kOrzjentK{cy4Dde|&;^%h992i6Tb zUvr)CZ9Er{WfV|vwGH>|r@bA*MRu}c1 zPx!3(@79(i0gcMvtR`%Vm#x3<=EMSZKh6Ld=>{%bq`kjNg)lhV2pBE7mm$qI74AFY znpbpZ1^aNl$mLmRz#POx8S3Hsp@Zqv9Zp5w(pTxe*Iew)dx$Zx}qGJ)R@`*#ikY6wnma%FZPCUAqL%CVH_}VV7(Vbo%?4FRQ!|+ih3D4Ii?l&^B zoAqYZ)Vw?4oxd_BO;&n<`}ybkr$$e*a;=gd)jfy0E{GOYZDi{OT;;lKGEh81W+l*I zcDEAFFJK&q%LD!_W2FG&>|M6NA@q+Nt;dO*$i|-DYBBLX!kgnNS<^5tQWsuzjy76^ zHm_>Gy%B1gwVEhw*M6%iy`ES;>mxhAe{KNTWJ3YJDSRw7zdShW`Sc2j=k=U2GNLLS z!UdGp__tk@@0?g_SG(rlWyY$l%UTI-w^H`+Dre_OT?__Js|EL=LiavWzYGJ3W_>eo zJxsNwiK?{;kZ{E$F{<50v_u+fJ#KgImE54B*eCzlxt02{R@Iu+*d?~NlvK2Jv2eT- zyWlGnPGwvB=4TlNP2DrBpGb4b@Vh8?{ny*L{8~AT()3K`tZ(ltT6DXzZ@mXC8pwsJ zi$PA;8$mh(D3*_olhSL%^US~GeR>B@n?CLM z%J`rc=qiBF&!cQ3zd$(jM|fE-WIv>l_8Wp}9PvCZFsbFy&D2Af(Z+Gp_)m3C_cCc{ zX@gxVyW8;Kz`tCy(Kxng-IEWqKD|rEza*+QNB6qN`x);`XU7SzzjA5`R}{mEVP!zv zg>I$TD2n5H5!a>GG{tFuy}S=_ViHvd8XZj?T71(}%=5d9Jq{;lO(Oxs{JL<{@AwX# zQ!%Q|4Q0oi+Ex16QHiDN*=5;E>!Zu=w3?;@*(*G+!Kl6@?7;>AUui#S`pvrbB#&^~ zg}Qy#_|YHg_NDb-xmkOBXaZ^IacK(Wzqc$Iha~nbkxbtu2Ui1$+Z#A|<55s;Ha9W{ zdzWMFqnd(v&jx``uX=?Szu;WXGWIM|+7*}8VjABKmZb`F?dpKCPW;+C?GG<9jvf7T zuW<fL#&A0~$FurnT$5oS!fEm@i3Kc%cDV?-88d@K6m{tR4Zt81JWgC~o$G8}@ zX-R5I$h(MJT%e~heD~%{k}@9^ zzs`@`ll}o z+W4$56pO-%5-&-t%~qaQV)=t}O;EpuoogbOE;bl*eXo*KrnlBy^}e#zxQ%q~^anb%*0Yri}W9PWR=q8+2zpSgG9)sthJNP89g zq}lRzW9cD&?5x{WWomAn$6q-$H$)oAk zzyJ=&b_{Ix0eQIWn$@}y=Av0Z#>w8$cGW*(!NjZYPyZU*mwwvlzFcR5YK?D2H91Z= zkp*RTrCWKlac4cpda!reWmB ze5`o=O?vVA*hS-m+giKijo#Ue4=>O{P%zZJjzun8Qv+##(?5aQ=q)ij@X-Cty||as zJFij$6IWcGb*nb3T-|0ATTxLj7`>TZbJvR?0DupK1UHFovr1nkxEi!3_-Mb4t7{(k z*TIWD&+X3~{&C;c%*S%qffc^4zFqqY7eN`x#4y1*+LpJv1~uryiJ#YLXYVXg_U&u1 zE%A8yZ)u}@Li5S@2vAez+|PhC0Jev?cAn5B=V$~SukhDbAyaSKZe*)cL%9YHwkZzN zZjQCt_r^QI_`yYjs&oJ*>{;SdSx*MRzwD3hPghl|CyaTk*L|wVH@sf^*_}fm+uUTf zF;0zh&1bkxb>Q?SAZ~_u<^fy&l;R4EJNL3ypVMe39%}^GJsH{ko8jH^aN04C{fhmz zQ@;f87MsLQ?V2E{pxy639+*_-4wAO8e4ki{z`w>R5z_ou zbhE5iC!+2wQ#wrjyoZ``Pnw>#+f3afvx$l}<`u65n)tZ2@+n6*HtMQG#&7geeRku-No zyW)aHnO`GF?TiOkMH|7m?4yJ4E&Z+#I`(pqeeZV9diWKTDP&_Y^oVz}=A9QiV`0pd zKU4>-pcRpt=xk=#BJn|c_1}6%8f11Vuur9`VdS3VVz%~4q%fT#K39S2lG_) zL0IhGex|PfYUUHOo5iZork@odxU-ECo>T3qw!rf>z0L+sw}oKzxGirYNU^UPr?}Fg z1=lT6Y48y~Sl3^waKF?2jIcw=pv8EUc-q~|_fC`T`2K&qymzGvb<#%rcs$&5JEM|r zs8dm|oqDc0V5>AWc9iQQ6n1;pT&0~D-~Ue#S8IMH)xX-g_rOtzO%lm9FtFxoPGDlM z&+Vaq2A95FwaKPPA$8JKz_Jk0oe`iy)$a1U_OGVRq{UYb|4=^@fjFW zH!>m_J?oL{(D76JoqfsKi7%dyw+RUV;KBfX+)i9VIh}ShbHr1!WF;4C*QS=>FS%1w zsxWG~3REdzcht*vaB;c+$%mKmmTn6*|}E4u?7=EkYaom*e^+nOpYuu9~g2tw^^LBMD?&gQ7td@FrIuL3(kdK6H^fcl{Dcp|>>0WB2yM_X~=vS*3F%3hU%8pqQB}C zO5M$c<4i%p&AX+(+ghs}8s-@8P)%c7HVN>#uoo4qJa{ z;|TUvh!k78ho%ifCcEAv7*Okxpj|8GIWrM^8Q8d+(#GS1)F~rME{*b}bI2-6W=@h? z=+~EUHJka*FY3*?ACfyI>yL-Q7Z%xPUjT`Zi!kfZpM6G{_=n>rPh|Vw0XeTiYp-Ve zEiNaB$@~8q%!B+k4jNu3`w-@F?zJ-(C0+@)MT`FTNDKVPu{P3>Md@6a_^=>Byf<%o zbNoCDQ3ID7mHlf>vwvZiAk&A%Am9Qo z>l@WBpVUmY(M$#qaM+W(&BHvXe;kTnJs=q2(7N9w%jsPHM|$lhPYXM=Bc7TG+oooZ zDKh=ac2<3jy|f3aA7RHo=i+603J#sW4mrBM2Rjv9XGGfI3k z^_hOMJwtQ55$t^F22U6P#1K!oWCA6sn87FA$5N#S6!%#JW4}Vq?{jXP&bs@^LGV8J z%9#r3C+G3^ywY6h0)DU;WFN)?w0F*c0*gqtW)~z2qvjw+sk-K74l63HLEp+=3`01o!9? z21H$^+2xBt3E-Ml+E{5gt(siDE^tUeBo>t0RBl%9EqyA(v^PR*gN>{I0Wz%mzP6RLk|XUNtfq|nhdUHt3$2HohruOxU>qatb2bnjWQ={}`ZD&p>;M&98d_#n zl}v(rNyS<_FIqc&c2zayQ%TSX7-!l<{xxdRHo0B^ittO0FyFp}lStg%ulYZx-no)l zqM7_(A)jLf$#aWHcGxNEU(skWxV@XXkZjo3Y+LN{r0K~m@k`!^l3{kPmx)G%y+?u= z@%}i3NY9G9^0_ha$#(hqwmFd~wHBzXkZoiHt#Q~tC(AZ@2;vU$4U&P7w{Ldbpm!Gg zQ%Z&0eZ?U;zv7VROJzH!+ob+0q-Xrdt?3uTA9^^rfn8({5oazZw48Yn>Z!@;i=}OT zxOFW&5TaurINy9*`I^ri`ZRP(%?ujTR)ij@h(${56ut%J-DWa72u3-l^3&Nh^FhvL zaAuvFu0<0ihqL0Y%@Rxf-8+S3~PP;0C!jR9I!3s?WZ@bc+2Ep$zhB5K1~7(Xl{g$L`L>ry>iQCpT+O zWDW$3@lBE4$Nw>^z@|UhR%l??fe!{*Hx&?30ku;;epoK_12p$R(ITPx0r|lq$fLN# zJB;4Uj)}hwOwi-|x0|dl1`PnXg$fx9@k)Hhbi~G*QuX%j@(apq^y$UOT7h5(We@Q? z8&<0q;$pNk^+nZ|!nB{5y~PkBF2{)(Q0)BPBLHO!vbK9V^Rc$ZnbGS_h%i7?#eG)rdOf1aRViX<*L@*5tnIL_DRj|#Am@iI zRkAad^GJS_br*MFo2N`!0;0a60y;B#lUK?s1R8!toyoaYQJYTHm)@8UA3k?ZTrl{w zXVv|vRT`w;JyaMkk$86*I}f4+z)+WYXe}oAFK)^2pplG8kSv>t_vM`6!y81;rK`}T z)aChv`2@-Su<|qG;+LY1yL6MUuZ7(Q1qJZYq&fux668!AoIK8zKCt@Ecd@O}^Q=A< z!I#FSEuTk~NXTWCLf;n3%7j*OXI4r*P0E8a_5M@_=&{8R?t!KwK?CEgy&F(?QWYCe$~iY@}OjN%N9E{qo)b zOZ`Uto`;X1CrBz;{RN#t^|Y|C+bM!28d30hKkNb|EBE!O;d|(Q-`B64$veZA2^s*N zBi+N76UpX}F(c};7u`l;_FD!&+))6O`IzxIOBbAmFac+-*+QP!IjENp+x9a8#u48m zyx*=oBKAj4nv)QtGzOw39L(2PEEf$s;ToB6+ah^~^XnhK&z%u>+;v*&S)lLN51px3 z9|`PnhvTq1^Y01v*ABJ|%iIwTMm5t=6Q5-e^$pi;SmuA!2Ft|jsHWT9A_ulvpUs>}|3 z`ButBR;I!ad+YvSZ==RK+^?vvi2YQi0nLa>p#7#_VA8U6{LGMKci}XJanxTm<#1IZ>*JP}KVv3R+#RI&4 zIN7L5>bRN>QWz3QVb#ITQ^sW)(CbReQeKH2y&FP_Pm&i3hO+5UqHF5d1;2VefVXoGeS1 zM5O-(xXl5^Q0>(VOf$XG zGd1TJSyhb(UF>&s-2z0O0>Omwu@}QV3tnf`9&hY0X^EKxLmbeLoiU%O$Lu23Bi90r z3hsEpEa!m5c6v&CtcSt9Jixj&p#N4e2|`c5{4eLrNToxy#XAujL6?2;px^N|9Kud6 zATu)JDLCvPwqEm>eVJ7G$D29+Bql)RbbS5_LzgEDFv;9{duVlAjq|417@&##(It-7k#Rki^A7nj=1ek&K?CqOWZiAqzWc*^^i_2EPk6o)CeHO9xS2e zLoh&q@cBl~(o|qIWO}8U_+fkDlZmjY;zFl~h@{Jm_#^&OWH3#3H3)_TKXyeOk}70@ zm?_Rt0QHPXoR4fk*SkN>Vo`Lz$&#=rwV*s8D_UA{iX4>q_vI|Jz2c{PjKb4V;4h)puL1Yw8I zNq{Aa?q8&wwD1PLR0h>ZH=T(Xo$d{?Yi;j=nUEF{0hCrpWynq2^BliI%sgR`tSHDA zVPQ|EANHXf$PR6q+EKA|7DOG!K9QFlxS!@`I|drV>Od7`w&!<;A^24LlpYpj90cwM z%s1m?;Ws(x1!H46(|-pg5eV?6Ab11N zh4~m*ONdn3f~OoaN%&a;2{d{B_7LFx;X@aIp53j`fFuNag-SG_Cn2>KmBkQPi3;~R zk%ufSuCVO>1sB`$(Lpr!kx*iXK6A+#Bpu=)-v}*tbXyaYeav8@Cq=})1Om&Va@m>% ziy}^~Rbr}DnlJ+L+Huho*is45otAxP?Hg@&!ly;V!xUhYyV8Ib#YaG{qv8!N5T}$g zk!vEPf3KmfK4yPR$oksS(k?1{iE+Pqzx9Ldh2rT{*bL2A7UgUl&WO)3F49elhzx{5 z6t3im*HFm>xdbe?Murx!IE3z6;fR*CMYhbsWjkp z7DDL{`=-i<>wzvU`nc+l+a~31bNjEe-hGSopt;n4Atd{wH*& zh9C+7Z91K`2&yzt)WN8Z2u*AUJp%&oa6$m`4pvpuk| zX9UiaRH$a1x#X2~#&c@}j` z*~hyZW>)vMNwfaINIvb_)wDoA7ADQzFol|ci<^UcS^9?s5aH(Y^umaf=0a68+%ALj zu_!{FEAfalfZ)$%sWsfo$**DG3&9%>sqCQZ2h%;tvIM7J6_PiI7P04m6P6dwi0~TD zTDX_7^54In!OVlaXXNH5(d2~J^$OJ--Y=o>|2vpE|@ff*4I@k37FX0Y)Cg6w&1 zMFIRTGyk61MBlwa*8rZMOuNWVRp!pb}i+cp_x$}>u^&ke3ky~;F{KjrXZ z#VWWox9d$%#@O_u`GcN=!45 zC_@J0|6I|~Kt_?V^E6f=1fVZ8i3X*FhrG|8aqJ3+7*fnFKrUu;IkMj{kUOI(z<$7^ zDi3lDw{Yx0?C#bU4r`B8(mF135T!t>WAF|-GYL_>9^k=;&Q8J(dESJ8-q ziqf2nK4l@LPn(+qVfSqk5a$<@?667I90op*hUYVMxjP+okdo*vz48JNuh^@`N2HghPm9)sQC1)oSNVnv?;o*{)iqG^Od`BQkvc}cptdeJhkSrS_0qv(}u4@@mUbdx+Db?P99 zSK95&nEo4BR-tFQTpK1T$c^{Axw)+XihnL|9!Bp6cEOvziuiuia7z{>C+D9Ia!?K; z1P$;9IVi#)7$wV5Gw;h)qMYU_ixi1y(Fq4#QfFec87vEXl{*3E7!q9tE9(1TzW_yqFdlXGB>HzoPNdx5qeY&wPs?;G`1mob|^+ zj$Iz0Wf=h~f|aox6!I%b;L6Yrc%&nq#z8r-%re(W4^@XkB?AebSYw9rMbq% z^e_|UYvyA^PXGCYS7-j8U!JDO*awlO%dtjs0Ah1=1{@J$F~!wv7UfE0oQeO!hO-WZ z38(Bfi{C+zXVLI25{?~a?LQ#I5Ji-*2G2vFLHb}lu0 zn&-?h)C4PIYBl*F8w*!Pq8A=ib9HkKZB!70ngD%}(j`bpok2;%%K=6XR2iheX$b%y z3uG+6Ubq{#>*Aqb4{#G3 zLb4!*n zv#))Egj(hF^66ADl6p76DZBC@+2HtN zsbUd=Ajl&o8f&Q+))rC_0UW3;laZf5J&dGeC`X-{kf(`TS6K$jlMt#QPujcbe)$%T@s?8TVu0l^B)xXRt>9KH#_l`WEC20@ zqLX5eVxw1ZQm3(WILV(c!7Z{Byq0{GC`dHj0u`0Hw~=G_%LQep^H`_0^Y`kceb0qt zgWv_m9k2$tY3%{{MH>3~0j5CKD?ptW5t?Ix2q6CR_rKl)fdGGsw^xxQ5NhW*yE(hh zX2UHcw}hv6&YZaE7Iu3})<9^p<(CVr0Rk}b=Ov?M$9V7{5X>?JG45tRd{L9Qr)9zl})07O_5NF$lP`&01c?vU`Yys!P7nj} zA0$?=zO2R4Q*cYk5x!`;!^2@zPx6U4)1pZA3Z1XWqo})(Q;|{bPYb{SW)3nx@*^ab zpdGc~kw@>xbjl|XDBB<~D2vWa6f0gg6*8+VoqX{$p&VeZmFFmLTKRu{fnCTrj2{lo z%>aRC`H;6{OY(^@3x{g;-aBSo*3<7xk#XlQvp_tI{il_{8nAvU56cBG)<9N6UaHGP zqmiko>@G5(_OFx$@5|5~2K++1^ykfu!QqB%@_@TyXIe5 zf>H?ixdt-!l`oDo40f7~a7i(=f4Z@dzy3gomAx?!rKt_F;r9M|3vg0*l z!Dpl*iPaFrF_z?rAau*MbPVoTLbsDAx$$9@liVz4ND4@b`K!18VP_2DZ-{;PwUn|5 z0C1*Q>!u+;LU-}S9KgWm+DeH6*R_TufxvE-L*@e_(2m;v+WzwB7f`nA=43>$j?yJi zXreP7uXCz^uRic~n6`Ehp3g&7f`yU5|34ql2RJ+Oz~#{;_+4v*uRIb{En66TNY;NQ zo3*zA<`H1XFo>+I5u%gq@@NO4q&%2)f_+1U8wNW`!p8r<3u4NV>mw;Rg9VV!gJUqT z6E03>c3A>Ik`B0^k>-Slj36rupvP2rc&)V$hC~F4C@Q994ssLPaqjJj=p;?edap1) z@H98<>$@*#p>S%r(*J%yDT(AAKo>wp25LBPV8NIOgE&ke{+bG*5CFpX2T#xd9QXpo z!lU9-lHl69IS^J^NflIO)<*C091XZAa^-F1s$2{x_sSwG=d(ePw|{xea(00ab&(nh zk32zqK@+1K<)L;)k~Rl5n=FS>s@38{Xs;EQ8ndru{eZ?|Y9TYqW5&+jf;SS%H=M;A TOa|cjzP@xb9*-OUKQsRiuIWlv literal 0 HcmV?d00001 diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json new file mode 100644 index 000000000..ed9d26c4f --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "249.jpg", + "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/file-pdf-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/Contents.json new file mode 100644 index 000000000..961bce4df --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "pdf-74.jpg", + "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/file-pdf-box.imageset/pdf-74.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/pdf-74.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e58929f00da6287e2e9664774fdb565108c1f545 GIT binary patch literal 5321 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 KX;y34MgI?2jTmwO literal 0 HcmV?d00001 diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/Contents.json new file mode 100644 index 000000000..9ee9b71f5 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "zip-128.jpg", + "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/file-zip-box.imageset/zip-128.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/zip-128.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1947afc45a40231bf1762453375a5e3855359dd2 GIT binary patch literal 7021 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 UIImage? { - cachedImages[id] - } - - public func getCachedFile(id: String) -> URL? { - cachedFiles[id] - } - - public func getPreview(for id: String, type: String) -> Data { - guard let data = cachedImages[id]?.jpegData(compressionQuality: 1.0) - else { - return UIImage.asset(named: "file-default-box")?.jpegData(compressionQuality: 1.0) ?? Data() + public func getPreview(for id: String, type: String) -> UIImage { + guard let data = cachedImages[id] else { + return getPreview(for: type) } return data @@ -88,13 +75,14 @@ public final class FilesStorageKit { let data = try Data(contentsOf: file.url) if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: file.url.absoluteString, image: UIImage(data: data) ?? UIImage()) + cacheImage(id: file.url.absoluteString, image: UIImage(data: data)) } let id = try await networkFileManager.uploadFiles(data, type: .uploadCareApi) if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: id, image: UIImage(data: data) ?? UIImage()) + cacheImage(id: file.url.absoluteString, image: nil) + cacheImage(id: id, image: UIImage(data: data)) } file.url.stopAccessingSecurityScopedResource() @@ -108,8 +96,8 @@ public final class FilesStorageKit { ) async throws -> Data { let data = try await networkFileManager.downloadFile(id, type: storage) - if imageExtensions.contains(fileType?.lowercased() ?? defaultFileType) { - cacheImage(id: id, image: UIImage(data: data) ?? UIImage()) + if imageExtensions.contains(fileType?.uppercased() ?? defaultFileType) { + cacheImage(id: id, image: UIImage(data: data)) } else { try cacheFile(id: id, data: data) } @@ -153,6 +141,23 @@ private extension FilesStorageKit { cachedFiles[id] = fileURL } + + func cacheImage(id: String, image: UIImage?) { + cachedImages[id] = image + } + + private func getPreview(for type: String) -> UIImage { + switch type.uppercased() { + case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD": + return UIImage.asset(named: "file-image-box")! + case "ZIP": + return UIImage.asset(named: "file-zip-box")! + case "PDF": + return UIImage.asset(named: "file-pdf-box")! + default: + return UIImage.asset(named: "file-default-box")! + } + } } private let defaultFileType = "" From cf434b15da310a82ed3173dc5315700bb8fbd7e8 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 29 Feb 2024 15:48:16 +0200 Subject: [PATCH 004/123] [trello.com/c/nh7HisCi] feat: encrypt and decrypt files --- .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +- .../Chat/View/Managers/ChatAction.swift | 2 +- .../View/Managers/ChatDataSourceManager.swift | 4 +- .../Content/ChatMediaContnentView+Model.swift | 4 +- .../Content/ChatMediaContnentView.swift | 14 +++-- .../Chat/ViewModel/ChatMessageFactory.swift | 7 ++- .../Chat/ViewModel/ChatViewModel.swift | 35 +++++++---- .../CommonKit/Core/NativeAdamantCore.swift | 58 +++++++++++++++++++ .../CommonKit/Models/RichMessage.swift | 12 +++- .../FilesStorageKit/FilesStorageKit.swift | 57 +++++++++++++----- .../Models/FileValidationError.swift | 3 + 11 files changed, 161 insertions(+), 39 deletions(-) diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 2c14a6003..70246315d 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -17,6 +17,7 @@ struct ChatFile: Equatable, Hashable { var isUploading: Bool var isCached: Bool var storage: String + var nonce: String static let `default` = Self( file: .init([:]), @@ -24,6 +25,7 @@ struct ChatFile: Equatable, Hashable { isDownloading: false, isUploading: false, isCached: false, - storage: .empty + storage: .empty, + nonce: .empty ) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index a9d8acead..46207b4fe 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -20,5 +20,5 @@ enum ChatAction { case remove(id: String) case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) - case processFile(file: ChatFile) + case processFile(file: ChatFile, isFromCurrentSender: Bool) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 77cee44e7..a26f102b6 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -191,8 +191,8 @@ private extension ChatDataSourceManager { viewModel.reactAction(id, emoji: emoji) case let .presentMenu(arg): viewModel.presentMenu(arg: arg) - case let .processFile(file: file): - viewModel.processFile(file: file) + case let .processFile(file, isFromCurrentSender): + viewModel.processFile(file: file, isFromCurrentSender: isFromCurrentSender) } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift index bc423fc74..8225a06d9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -14,11 +14,13 @@ extension ChatMediaContentView { let id: String var files: [ChatFile] var isHidden: Bool + let isFromCurrentSender: Bool static let `default` = Self( id: "", files: [], - isHidden: false + isHidden: false, + isFromCurrentSender: false ) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 3d3ae079a..fa7454b3f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -61,16 +61,20 @@ private extension ChatMediaContentView { func makeCell( tableView: UITableView, indexPath: IndexPath, - model: ChatFile + fileModel: ChatFile ) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! ChatFileTableViewCell - cell.model = model + cell.model = fileModel cell.backgroundView?.backgroundColor = .clear cell.backgroundColor = .clear cell.contentView.backgroundColor = .clear - cell.buttonActionHandler = { [actionHandler, model] in - print("did select\(indexPath.row)") - actionHandler(.processFile(file: model)) + cell.buttonActionHandler = { [actionHandler, fileModel, model] in + actionHandler( + .processFile( + file: fileModel, + isFromCurrentSender: model.isFromCurrentSender + ) + ) } return cell } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 7f68bded7..70e37d4b2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -140,7 +140,6 @@ private extension ChatMessageFactory { if transaction.additionalType == .file, !transaction.isTransferReply() { - print("makeFileContent") return makeFileContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -330,7 +329,8 @@ private extension ChatMessageFactory { isDownloading: false, isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), isCached: FilesStorageKit.shared.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), - storage: storage + storage: storage, + nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty ) } @@ -341,7 +341,8 @@ private extension ChatMessageFactory { content: .init( id: id, files: chatFiles, - isHidden: false + isHidden: false, + isFromCurrentSender: isFromCurrentSender ), address: address, opponentAddress: opponentAddress diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 628a7ce7f..8d5996ec3 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -282,7 +282,8 @@ final class ChatViewModel: NSObject { func sendFile(text: String) { guard let partnerAddress = chatroom?.partner?.address, - let files = filesPicked + let files = filesPicked, + let keyPair = accountService.keypair else { return } guard chatroom?.partner?.isDummy != true else { @@ -297,7 +298,8 @@ final class ChatViewModel: NSObject { file_type: $0.extenstion, file_size: $0.size, preview_id: nil, - file_name: $0.name + file_name: $0.name, + nonce: .empty ) } @@ -326,16 +328,21 @@ final class ChatViewModel: NSObject { } for file in files { - let id = try await FilesStorageKit.shared.uploadFile(file) + let result = try await FilesStorageKit.shared.uploadFile( + file, + recipientPublicKey: chatroom?.partner?.publicKey ?? "", + senderPrivateKey: keyPair.privateKey + ) let oldId = file.url.absoluteString uploadingFilesIDs.removeAll(where: { $0 == oldId }) - updateUploadingFileId(&messages, oldId: oldId, newId: id) + updateUploadingFileId(&messages, oldId: oldId, newId: result.id) if let index = richFiles.firstIndex( where: { $0.file_id == oldId } ) { - richFiles[index].file_id = id + richFiles[index].file_id = result.id + richFiles[index].nonce = result.nonce } } @@ -692,8 +699,9 @@ final class ChatViewModel: NSObject { return true } - func processFile(file: ChatFile) { - print("processFile=\(file)") + func processFile(file: ChatFile, isFromCurrentSender: Bool) { + guard let keyPair = accountService.keypair else { return } + Task { if !file.isCached { defer { @@ -702,19 +710,26 @@ final class ChatViewModel: NSObject { downloadingFilesID.append(file.file.file_id) do { - _ = try await FilesStorageKit.shared.cacheFile( + let publicKey = isFromCurrentSender + ? keyPair.publicKey + : chatroom?.partner?.publicKey ?? .empty + + try await FilesStorageKit.shared.downloadFile( id: file.file.file_id, storage: file.storage, - fileType: file.file.file_type ?? "" + fileType: file.file.file_type ?? .empty, + senderPublicKey: publicKey, + recipientPrivateKey: keyPair.privateKey, + nonce: file.nonce ) updatePreviewForFile(&messages, id: file.file.file_id) } catch { dialog.send(.alert(error.localizedDescription)) } + return } - // } } 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/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index 0184ed81b..f073fe259 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -56,6 +56,7 @@ public enum RichContentKeys { public static let file_type = "file_type" public static let preview_id = "preview_id" public static let file_name = "file_name" + public static let nonce = "nonce" } } @@ -95,33 +96,38 @@ public struct RichMessageFile: RichMessage { public var file_size: Int64 public var preview_id: String? public var file_name: String? + public var nonce: String public init( file_id: String, file_type: String? = nil, file_size: Int64, preview_id: String? = nil, - file_name: String? = nil + file_name: String? = nil, + nonce: String ) { self.file_id = file_id self.file_type = file_type self.file_size = file_size self.preview_id = preview_id self.file_name = file_name + self.nonce = nonce } public init(_ data: [String: Any]) { - self.file_id = (data[RichContentKeys.file.file_id] as? String) ?? "" + self.file_id = (data[RichContentKeys.file.file_id] as? String) ?? .empty self.file_type = data[RichContentKeys.file.file_type] as? String self.file_size = (data[RichContentKeys.file.file_size] as? Int64) ?? .zero self.preview_id = data[RichContentKeys.file.preview_id] as? String self.file_name = data[RichContentKeys.file.file_name] as? String + self.nonce = data[RichContentKeys.file.nonce] as? String ?? .empty } public func content() -> [String: Any] { var contentDict: [String : Any] = [ RichContentKeys.file.file_id: file_id, - RichContentKeys.file.file_size: file_size + RichContentKeys.file.file_size: file_size, + RichContentKeys.file.nonce: nonce ] if let file_type = file_type, !file_type.isEmpty { diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 727214e9d..ae5fd457e 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -7,6 +7,7 @@ import UIKit public final class FilesStorageKit { public static let shared = FilesStorageKit() + private let adamantCore = NativeAdamantCore() private let window = TransparentWindow(frame: UIScreen.main.bounds) private let mediaPicker: FilePickerProtocol private let documentPicker: FilePickerProtocol @@ -69,40 +70,70 @@ public final class FilesStorageKit { cachedImages[id] != nil || cachedFiles[id] != nil } - public func uploadFile(_ file: FileResult) async throws -> String { + public func uploadFile( + _ file: FileResult, + recipientPublicKey: String, + senderPrivateKey: String + ) async throws -> (id: String, nonce: String) { + defer { + cacheImage(id: file.url.absoluteString, image: nil) + } + _ = file.url.startAccessingSecurityScopedResource() let data = try Data(contentsOf: file.url) + let encodedResult = adamantCore.encodeData( + data, + recipientPublicKey: recipientPublicKey, + privateKey: senderPrivateKey + ) + + guard let encodedData = encodedResult?.data, + let nonce = encodedResult?.nonce + else { + throw FileManagerError.cantEnctryptFile + } + if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: file.url.absoluteString, image: UIImage(data: data)) + cacheImage(id: file.url.absoluteString, image: UIImage(data: encodedData)) } - let id = try await networkFileManager.uploadFiles(data, type: .uploadCareApi) + let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: file.url.absoluteString, image: nil) cacheImage(id: id, image: UIImage(data: data)) } file.url.stopAccessingSecurityScopedResource() - return id + return (id: id, nonce: nonce) } - public func cacheFile( + public func downloadFile( id: String, storage: String, - fileType: String? - ) async throws -> Data { - let data = try await networkFileManager.downloadFile(id, type: storage) + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) async throws { + let encodedData = try await networkFileManager.downloadFile(id, type: storage) + + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) + else { + throw FileValidationError.fileNotFound + } if imageExtensions.contains(fileType?.uppercased() ?? defaultFileType) { - cacheImage(id: id, image: UIImage(data: data)) + cacheImage(id: id, image: UIImage(data: decodedData)) } else { - try cacheFile(id: id, data: data) + try cacheFile(id: id, data: encodedData) } - - return data } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift index abf694296..69ff56291 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift @@ -27,6 +27,7 @@ public enum FileValidationError: Error, LocalizedError { public enum FileManagerError: Error, LocalizedError { case cantDownloadFile case cantUploadFile + case cantEnctryptFile public var errorDescription: String { switch self { @@ -34,6 +35,8 @@ public enum FileManagerError: Error, LocalizedError { return "cant Download File" case .cantUploadFile: return "cant Upload File" + case .cantEnctryptFile: + return "cant encrypt file" } } } From 41815984b63422a904f3c4e694c9204282fb7a6e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 1 Mar 2024 10:40:47 +0200 Subject: [PATCH 005/123] [trello.com/c/nh7HisCi] feat: load cached files at start --- Adamant/App/AppDelegate.swift | 2 +- .../FilesStorageKit/FilesStorageKit.swift | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Adamant/App/AppDelegate.swift b/Adamant/App/AppDelegate.swift index f6a45625b..6931919b6 100644 --- a/Adamant/App/AppDelegate.swift +++ b/Adamant/App/AppDelegate.swift @@ -161,7 +161,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: 4. Setup dialog service dialogService.setup(window: window) - _ = FilesStorageKit.shared.isCached("") + FilesStorageKit.shared.setup() // MARK: 5. Show login let login = screensFactory.makeLogin() diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index ae5fd457e..cf7ba4b80 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -22,6 +22,10 @@ public final class FilesStorageKit { documentPicker = DocumentPickerService() } + public func setup() { + try? loadCache() + } + @MainActor public func presentImagePicker() async throws -> [FileResult] { try await withUnsafeThrowingContinuation { continuation in @@ -156,6 +160,39 @@ private extension FilesStorageKit { } } + func loadCache() throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + let files = getFiles(at: folder) + + files.forEach { url in + cachedFiles[url.lastPathComponent] = url + } + } + + func getFiles(at url: URL) -> [URL] { + let fileManager = FileManager.default + var isDirectory: ObjCBool = false + var subdirectoryNames: [URL] = [] + + guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return subdirectoryNames + } + + for item in contents { + if fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) && !isDirectory.boolValue { + subdirectoryNames.append(item) + } + } + + return subdirectoryNames + } + func cacheFile(id: String, data: Data) throws { let folder = try FileManager.default.url( for: .cachesDirectory, From cd70e2724959276086f456ca3d302552aa150612 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 1 Mar 2024 11:41:53 +0200 Subject: [PATCH 006/123] [trello.com/c/nh7HisCi] fix: decode file in chat --- .../Chat/View/Subviews/ChatMedia/ChatMediaCell.swift | 2 -- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 6 +----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index ad5a44056..95124497e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -39,8 +39,6 @@ final class ChatMediaCell: MessageContentCell { and messagesCollectionView: MessagesCollectionView ) { super.configure(with: message, at: indexPath, and: messagesCollectionView) -// messageContainerView.style = .none -// messageContainerView.backgroundColor = .clear } override func layoutMessageContainerView( diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8d5996ec3..3dfaf3a68 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -710,15 +710,11 @@ final class ChatViewModel: NSObject { downloadingFilesID.append(file.file.file_id) do { - let publicKey = isFromCurrentSender - ? keyPair.publicKey - : chatroom?.partner?.publicKey ?? .empty - try await FilesStorageKit.shared.downloadFile( id: file.file.file_id, storage: file.storage, fileType: file.file.file_type ?? .empty, - senderPublicKey: publicKey, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, nonce: file.nonce ) From 1800d53fb0c02f6a98028287d097196a3c40610b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 4 Mar 2024 15:58:10 +0200 Subject: [PATCH 007/123] [trello.com/c/nh7HisCi] feat: reply file transaction --- ...essageTransaction+CoreDataProperties.swift | 13 ++- .../Container/ChatMediaContainerView.swift | 2 +- .../Content/ChatMediaContnentView+Model.swift | 10 +- .../Content/ChatMediaContnentView.swift | 109 ++++++++++++++++-- .../Chat/ViewModel/ChatMessageFactory.swift | 20 +++- .../Chat/ViewModel/ChatViewModel.swift | 60 +++++++--- .../AdamantRichTransactionReplyService.swift | 34 ++++++ .../CommonKit/Models/RichMessage.swift | 25 ++++ .../FilesStorageKit/FilesStorageKit.swift | 2 + .../FilesStorageKit/Helpers/Constants.swift | 2 +- .../Pickers/DocumentPickerService.swift | 2 + 11 files changed, 241 insertions(+), 38 deletions(-) diff --git a/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift index 23bdd8d34..bcecc8ffb 100644 --- a/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift +++ b/Adamant/Models/CoreData/RichMessageTransaction+CoreDataProperties.swift @@ -28,13 +28,18 @@ 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 } @@ -46,12 +51,12 @@ extension RichMessageTransaction { return value } - if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: String], + if let content = richContent?[RichContentKeys.file.files] as? [String: Any], let value = content[key] as? T { return value } - if let content = richContent?[RichContentKeys.file.files] as? [String: Any], + if let content = richContent?[RichContentKeys.reply.replyMessage] as? [String: Any], let value = content[key] as? T { return value } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 60ddd7ba4..3585ac077 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -50,7 +50,7 @@ extension ChatMediaContainerView { addSubview(contentView) contentView.snp.makeConstraints { - $0.top.bottom.equalToSuperview() + $0.top.bottom.equalToSuperview().inset(12) $0.leading.trailing.equalToSuperview().inset(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 index 8225a06d9..a565e5136 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -15,12 +15,20 @@ extension ChatMediaContentView { var files: [ChatFile] var isHidden: Bool let isFromCurrentSender: Bool + let isReply: Bool + let replyMessage: NSAttributedString + let replyId: String + let comment: NSAttributedString static let `default` = Self( id: "", files: [], isHidden: false, - isFromCurrentSender: false + isFromCurrentSender: false, + isReply: false, + replyMessage: NSAttributedString(string: .empty), + replyId: .empty, + comment: NSAttributedString(string: .empty) ) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index fa7454b3f..cf6808257 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -19,6 +19,58 @@ final class ChatMediaContentView: UIView { return tableView }() + private let commentLabel = UILabel( + font: commentFont, + textColor: .adamant.textColor, + numberOfLines: .zero + ) + + var replyViewDynamicHeight: CGFloat { + model.isReply ? replyViewHeight : 0 + } + + 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.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 verticalStack: UIStackView = { + let stack = UIStackView(arrangedSubviews: [replyView, commentLabel, tableView]) + stack.axis = .vertical + stack.spacing = verticalStackSpacing + return stack + }() + private lazy var dataSource = TransactionsDiffableDataSource(tableView: tableView, cellProvider: makeCell) var model: Model = .default { @@ -43,13 +95,27 @@ final class ChatMediaContentView: UIView { private extension ChatMediaContentView { func configure() { - addSubview(tableView) - tableView.snp.makeConstraints { make in + addSubview(verticalStack) + verticalStack.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } } func update() { + commentLabel.attributedText = model.comment + commentLabel.isHidden = model.comment.string.isEmpty + replyView.isHidden = !model.isReply + + if model.isReply { + replyMessageLabel.attributedText = model.replyMessage + } else { + replyMessageLabel.attributedText = nil + } + + replyView.snp.updateConstraints { make in + make.height.equalTo(replyViewDynamicHeight) + } + let list = model.files var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.zero]) @@ -87,18 +153,37 @@ extension ChatMediaContentView: UITableViewDelegate { ) -> CGFloat { imageSize } - - func tableView( - _ tableView: UITableView, - didSelectRowAt indexPath: IndexPath - ) { - print("did select\(indexPath.row)") - } } extension ChatMediaContentView.Model { func height() -> CGFloat { - imageSize * CGFloat(files.count) + let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : 0 + let stackSpacingCount: CGFloat = isReply ? 4 : 3 + + return imageSize * CGFloat(files.count) + + stackSpacingCount * verticalStackSpacing + + labelSize(for: comment, considering: 260).height + + replyViewDynamicHeight + } + + func labelSize( + for attributedText: NSAttributedString, + considering maxWidth: CGFloat + ) -> CGSize { + let textContainer = NSTextContainer( + size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude) + ) + let layoutManager = NSLayoutManager() + + layoutManager.addTextContainer(textContainer) + + let textStorage = NSTextStorage(attributedString: attributedText) + textStorage.addLayoutManager(layoutManager) + + let range = NSRange(location: 0, length: attributedText.length) + let rect = layoutManager.usedRect(for: textContainer) + + return rect.integral.size } } @@ -107,3 +192,7 @@ private let sizeFont = UIFont.systemFont(ofSize: 13) private let imageSize: CGFloat = 90 private typealias TransactionsDiffableDataSource = UITableViewDiffableDataSource private let cellIdentifier = "cell" +private let commentFont = UIFont.systemFont(ofSize: 14) +private let verticalStackSpacing: CGFloat = 6 +private let verticalInsets: CGFloat = 8 +private let replyViewHeight: CGFloat = 25 diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 70e37d4b2..8f82915ad 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -130,7 +130,8 @@ private extension ChatMessageFactory { ) case let transaction as RichMessageTransaction: if transaction.additionalType == .reply, - !transaction.isTransferReply() { + !transaction.isTransferReply(), + !transaction.isFileReply() { return makeReplyContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -138,8 +139,9 @@ private extension ChatMessageFactory { ) } - if transaction.additionalType == .file, - !transaction.isTransferReply() { + if transaction.additionalType == .file || + (transaction.additionalType == .reply && + transaction.isFileReply()) { return makeFileContent( transaction, isFromCurrentSender: isFromCurrentSender, @@ -303,12 +305,14 @@ private extension ChatMessageFactory { ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" - let decodedMessage = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty + let decodedMessage: String = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." let decodedMessageMarkDown = Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] let storage: String = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty + let comment: String = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty + let replyId = transaction.getRichValue(for: RichContentKeys.reply.replyToId) ?? "" let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set let address = transaction.isOutgoing @@ -333,7 +337,7 @@ private extension ChatMessageFactory { nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty ) } - + print("is reply=\(transaction.isFileReply()), richcontent=\(transaction.richContent)") return .file(.init(value: .init( id: id, isFromCurrentSender: isFromCurrentSender, @@ -342,7 +346,11 @@ private extension ChatMessageFactory { id: id, files: chatFiles, isHidden: false, - isFromCurrentSender: isFromCurrentSender + isFromCurrentSender: isFromCurrentSender, + isReply: transaction.isFileReply(), + replyMessage: decodedMessageMarkDown, + replyId: replyId, + comment: Self.markdownParser.parse(comment) ), address: address, opponentAddress: opponentAddress diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 3dfaf3a68..dcfb66033 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -303,19 +303,31 @@ final class ChatViewModel: NSObject { ) } - let messageLocally: AdamantMessage = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text + let messageLocally: AdamantMessage + + if let replyMessage = replyMessage { + messageLocally = .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) ) - ) + } else { + messageLocally = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + } guard await validateSendingMessage(message: messageLocally) else { return } - replyMessage = nil - filesPicked = nil - do { let txLocally = try await chatsProvider.sendFileMessageLocally( messageLocally, @@ -346,13 +358,31 @@ final class ChatViewModel: NSObject { } } - let message: AdamantMessage = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text + let message: AdamantMessage + + if let replyMessage = replyMessage { + message = .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) ) - ) + } else { + message = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + } + + replyMessage = nil + filesPicked = nil _ = try await chatsProvider.sendFileMessage( message, diff --git a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift index 6eb4d1cb7..d8d95d39c 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 = getRawFilePresentation(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 = getRawFilePresentation(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 = getRawFilePresentation(richContent) + break + } + message = unknownErrorMessage default: message = unknownErrorMessage @@ -266,6 +287,19 @@ private extension AdamantRichTransactionReplyService { return message } + func getRawFilePresentation(_ richContent: [String: Any]) -> String { + let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent + + let files = richContent[RichContentKeys.file.files] as? [[String: Any]] ?? [] + + let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty + let comment = !rawComment.isEmpty + ? ": \(rawComment)" + : "" + + return "[\(files.count) file(s)]\(comment)" + } + func setReplyMessage( for transaction: RichMessageTransaction, message: String diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index f073fe259..da1e2b64f 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -177,6 +177,31 @@ public struct RichMessageFile: RichMessage { } } +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 + ] + } +} + // MARK: - RichMessageReply public struct RichMessageReply: RichMessage { diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index cf7ba4b80..a730beeb8 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -148,8 +148,10 @@ private extension FilesStorageKit { } for fileURL in fileURLs { + _ = fileURL.startAccessingSecurityScopedResource() let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + fileURL.stopAccessingSecurityScopedResource() guard let fileSize = fileAttributes[.size] as? Int64 else { throw FileValidationError.fileNotFound } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift b/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift index a7803976a..1434e1471 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift @@ -10,7 +10,7 @@ import UIKit final class Constants { static let maxFilesCount = 15 - static let maxFileSize: Int64 = 8 * 1024 * 1024 + static let maxFileSize: Int64 = 800 * 1024 * 1024 } extension UIApplication { diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift index 29ec1ae07..e889d1b8c 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift @@ -50,12 +50,14 @@ extension DocumentPickerService: UIDocumentPickerDelegate { private extension DocumentPickerService { func getFileSize(from fileURL: URL) throws -> Int64 { + _ = fileURL.startAccessingSecurityScopedResource() let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) guard let fileSize = fileAttributes[.size] as? Int64 else { throw FileValidationError.fileNotFound } + fileURL.stopAccessingSecurityScopedResource() return fileSize } } From 1032a5d5298cff731eb785b687488808b9951261 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 6 Mar 2024 16:28:51 +0200 Subject: [PATCH 008/123] [trello.com/c/nh7HisCi] split to FilesPickerKit --- Adamant.xcodeproj/project.pbxproj | 10 +- Adamant.xcworkspace/contents.xcworkspacedata | 2 +- .../xcshareddata/swiftpm/Package.resolved | 9 - .../Chat/ViewModel/ChatViewModel.swift | 7 +- .../Sources/CommonKit/Models/FileResult.swift | 39 ++ .../.gitignore | 0 .../Package.swift | 22 +- .../FilesPickerKit/FilesPickerKit.swift | 63 +++ .../FilesPickerKit/Models}/Constants.swift | 6 +- .../Models/FileValidationError.swift | 17 - .../Pickers/DocumentPickerService.swift | 6 +- .../Pickers/MediaPickerService.swift | 173 ++++++++ .../Protocols/FilePickerProtocol.swift | 6 +- .../FilesPickerKitTests.swift | 4 +- .../FilesStorageKit/FilesStorageKit.swift | 234 ----------- .../FilesStorageKit/Models/FileResult.swift | 24 -- .../Models/NetworkFileProtocolType.swift | 12 - .../Protocols/ApiManagerProtocol.swift | 13 - .../NetworkFileManagerProtocol.swift | 13 - .../API Managers/UploadCareApiManager.swift | 29 -- .../Services/NetworkFileManager.swift | 30 -- .../Services/Pickers/MediaPickerService.swift | 378 ------------------ 22 files changed, 301 insertions(+), 796 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Models/FileResult.swift rename {FilesStorageKit => FilesPickerKit}/.gitignore (100%) rename {FilesStorageKit => FilesPickerKit}/Package.swift (55%) create mode 100644 FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift rename {FilesStorageKit/Sources/FilesStorageKit/Helpers => FilesPickerKit/Sources/FilesPickerKit/Models}/Constants.swift (95%) rename {FilesStorageKit/Sources/FilesStorageKit => FilesPickerKit/Sources/FilesPickerKit}/Models/FileValidationError.swift (55%) rename {FilesStorageKit/Sources/FilesStorageKit/Services => FilesPickerKit/Sources/FilesPickerKit}/Pickers/DocumentPickerService.swift (94%) create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift rename {FilesStorageKit/Sources/FilesStorageKit => FilesPickerKit/Sources/FilesPickerKit}/Protocols/FilePickerProtocol.swift (59%) rename FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift => FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift (78%) delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 399b07676..c9fbdec41 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -8,8 +8,8 @@ /* Begin PBXBuildFile section */ 269E13522B594B2D008D1CA7 /* AccountFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269E13512B594B2D008D1CA7 /* AccountFooterView.swift */; }; + 3A075C9E2B98A3B100714E3B /* FilesPickerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A075C9D2B98A3B100714E3B /* FilesPickerKit */; }; 3A20D93B2AE7F316005475A6 /* AdamantTransactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A20D93A2AE7F316005475A6 /* AdamantTransactionDetails.swift */; }; - 3A299C652B83678F00B54C61 /* FilesStorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A299C642B83678F00B54C61 /* FilesStorageKit */; }; 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 */; }; @@ -1250,7 +1250,6 @@ files = ( A5AC8DFF262E0B030053A7E2 /* SipHash in Frameworks */, 3A8875EF27BBF38D00436195 /* Parchment in Frameworks */, - 3A299C652B83678F00B54C61 /* FilesStorageKit in Frameworks */, 3C06931576393125C61FB8F6 /* Pods_Adamant.framework in Frameworks */, A50AEB0C262C81E300B37C22 /* QRCodeReader in Frameworks */, 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, @@ -1275,6 +1274,7 @@ 4184F1712A33044E00D7B8B9 /* FirebaseCrashlytics in Frameworks */, A5DBBAEE262C72EF004AC028 /* BitcoinKit in Frameworks */, A57282CA262C94CD00C96FA8 /* DateToolsSwift in Frameworks */, + 3A075C9E2B98A3B100714E3B /* FilesPickerKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2671,7 +2671,7 @@ 4177E5E02A52DA7100C089FE /* AdvancedContextMenuKit */, 9342F6C12A6A35E300A9B39F /* CommonKit */, 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, - 3A299C642B83678F00B54C61 /* FilesStorageKit */, + 3A075C9D2B98A3B100714E3B /* FilesPickerKit */, ); productName = Adamant; productReference = E913C8EE1FFFA51D001A83F7 /* Adamant.app */; @@ -4255,9 +4255,9 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 3A299C642B83678F00B54C61 /* FilesStorageKit */ = { + 3A075C9D2B98A3B100714E3B /* FilesPickerKit */ = { isa = XCSwiftPackageProductDependency; - productName = FilesStorageKit; + productName = FilesPickerKit; }; 3A8875EE27BBF38D00436195 /* Parchment */ = { isa = XCSwiftPackageProductDependency; diff --git a/Adamant.xcworkspace/contents.xcworkspacedata b/Adamant.xcworkspace/contents.xcworkspacedata index 879d2496f..cc09e3250 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -2,7 +2,7 @@ + location = "group:FilesPickerKit"> diff --git a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6252fdfc6..88a2fda6f 100644 --- a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -325,15 +325,6 @@ "version": "2.7.1" } }, - { - "package": "Uploadcare", - "repositoryURL": "https://github.com/uploadcare/uploadcare-swift.git", - "state": { - "branch": "master", - "revision": "3ab0706a726abcb9c935347add80f855c2a08f12", - "version": null - } - }, { "package": "Web3swift", "repositoryURL": "https://github.com/skywinder/web3swift.git", diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index dcfb66033..1b13f94c1 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -14,6 +14,7 @@ import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker import FilesStorageKit +import FilesPickerKit @MainActor final class ChatViewModel: NSObject { @@ -775,21 +776,19 @@ final class ChatViewModel: NSObject { // dialog.send(.progress(true)) if case(.uploadFile) = action { - result = try await FilesStorageKit.shared.presentDocumentPicker() + result = try await FilesPickerKit.shared.presentDocumentPicker() } if case(.uploadMedia) = action { - result = try await FilesStorageKit.shared.presentImagePicker() + result = try await FilesPickerKit.shared.presentImagePicker() } presentFilePicker.send(action) dialog.send(.progress(false)) filesPicked = result - print("data=\(result.count)") } catch { dialog.send(.progress(false)) dialog.send(.alert(error.localizedDescription)) - print("error=\(error)") } } } diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift new file mode 100644 index 000000000..50c0207ec --- /dev/null +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -0,0 +1,39 @@ +// +// FileResult.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import UIKit + +public enum FileType { + case image + case video + case other +} + +public struct FileResult { + public let url: URL + public let type: FileType + public let preview: UIImage? + public let size: Int64 + public let name: String? + public let extenstion: String? + + public init( + url: URL, + type: FileType, + preview: UIImage?, + size: Int64, + name: String?, + extenstion: String? + ) { + self.url = url + self.type = type + self.preview = preview + self.size = size + self.name = name + self.extenstion = extenstion + } +} diff --git a/FilesStorageKit/.gitignore b/FilesPickerKit/.gitignore similarity index 100% rename from FilesStorageKit/.gitignore rename to FilesPickerKit/.gitignore diff --git a/FilesStorageKit/Package.swift b/FilesPickerKit/Package.swift similarity index 55% rename from FilesStorageKit/Package.swift rename to FilesPickerKit/Package.swift index d49cd3035..16f60b983 100644 --- a/FilesStorageKit/Package.swift +++ b/FilesPickerKit/Package.swift @@ -4,34 +4,28 @@ import PackageDescription let package = Package( - name: "FilesStorageKit", + name: "FilesPickerKit", platforms: [ .iOS(.v15) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( - name: "FilesStorageKit", - targets: ["FilesStorageKit"]), + name: "FilesPickerKit", + targets: ["FilesPickerKit"]), ], dependencies: [ - .package(path: "../CommonKit"), - .package(url: "https://github.com/uploadcare/uploadcare-swift.git", branch: "master") + .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", - .product(name: "Uploadcare", package: "uploadcare-swift") - ] + name: "FilesPickerKit", + dependencies: ["CommonKit"] ), .testTarget( - name: "FilesStorageKitTests", - dependencies: [ - "FilesStorageKit" - ]), + name: "FilesPickerKitTests", + dependencies: ["FilesPickerKit"]), ] ) diff --git a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift new file mode 100644 index 000000000..a48c0a303 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift @@ -0,0 +1,63 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit + +public final class FilesPickerKit { + public static let shared = FilesPickerKit() + + private let mediaPicker: FilePickerProtocol + private let documentPicker: FilePickerProtocol + + public init() { + mediaPicker = MediaPickerService() + documentPicker = DocumentPickerService() + } + + @MainActor + public func presentImagePicker() async throws -> [FileResult] { + try await withUnsafeThrowingContinuation { continuation in + mediaPicker.startPicker { [weak self] data in + do { + try self?.validateFiles(data) + } catch { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: data) + } + } + } + + @MainActor + public func presentDocumentPicker() async throws -> [FileResult] { + try await withUnsafeThrowingContinuation { continuation in + documentPicker.startPicker { [weak self] data in + do { + try self?.validateFiles(data) + } catch { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: data) + } + } + } +} + +private extension FilesPickerKit { + func validateFiles(_ files: [FileResult]) throws { + guard files.count <= Constants.maxFilesCount else { + throw FileValidationError.tooManyFiles + } + + for file in files { + guard file.size <= Constants.maxFileSize else { + throw FileValidationError.fileSizeExceedsLimit + } + } + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift similarity index 95% rename from FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift rename to FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 1434e1471..c76aacaa6 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Helpers/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -1,8 +1,8 @@ // -// File.swift -// +// Constants.swift // -// Created by Stanislav Jelezoglo on 19.02.2024. +// +// Created by Stanislav Jelezoglo on 06.03.2024. // import Foundation diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift similarity index 55% rename from FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift rename to FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift index 69ff56291..530c58204 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift @@ -23,20 +23,3 @@ public enum FileValidationError: Error, LocalizedError { } } } - -public enum FileManagerError: Error, LocalizedError { - case cantDownloadFile - case cantUploadFile - case cantEnctryptFile - - public var errorDescription: String { - switch self { - case .cantDownloadFile: - return "cant Download File" - case .cantUploadFile: - return "cant Upload File" - case .cantEnctryptFile: - return "cant encrypt file" - } - } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift similarity index 94% rename from FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift rename to FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index e889d1b8c..fd7b169d6 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -7,6 +7,7 @@ import Foundation import UIKit +import CommonKit final class DocumentPickerService: NSObject, FilePickerProtocol { let documentPicker = UIDocumentPickerViewController( @@ -16,10 +17,7 @@ final class DocumentPickerService: NSObject, FilePickerProtocol { private var onPreparedDataCallback: (([FileResult]) -> Void)? - func startPicker( - window: UIWindow, - completion: (([FileResult]) -> Void)? - ) { + func startPicker(completion: (([FileResult]) -> Void)?) { onPreparedDataCallback = completion documentPicker.allowsMultipleSelection = true diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift new file mode 100644 index 000000000..28142b414 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -0,0 +1,173 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 11.02.2024. +// + +import CommonKit +import UIKit +import Photos +import PhotosUI + +final class MediaPickerService: NSObject, FilePickerProtocol { + private var onPreparedDataCallback: (([FileResult]) -> Void)? + + func startPicker(completion: (([FileResult]) -> Void)?) { + onPreparedDataCallback = completion + + var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) + phPickerConfig.selectionLimit = Constants.maxFilesCount + phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) + + let phPickerVC = PHPickerViewController(configuration: phPickerConfig) + phPickerVC.delegate = self + UIApplication.shared.topViewController()?.present(phPickerVC, animated: true) + } +} + +extension MediaPickerService: PHPickerViewControllerDelegate { + func picker( + _ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult] + ) { + picker.dismiss(animated: true, completion: .none) + Task { + await processResults(results) + } + } +} + +private extension MediaPickerService { + func processResults(_ results: [PHPickerResult]) async { + var dataArray: [FileResult] = [] + + for result in results { + let itemProvider = result.itemProvider + + guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, + let utType = UTType(typeIdentifier) + else { continue } + + if utType.conforms(to: .image) { + guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), + let preview = try? await getPhoto(from: itemProvider), + let fileSize = try? getFileSize(from: url) + else { continue } + + dataArray.append( + .init( + url: url, + type: .image, + preview: preview, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: "JPG" + ) + ) + } + + if utType.conforms(to: .movie) { + guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), + let fileSize = try? getFileSize(from: url) + else { continue } + + let preview = getThumbnailImage(forUrl: url) + + dataArray.append( + .init( + url: url, + type: .video, + preview: preview, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: "JPG" + ) + ) + } + } + + onPreparedDataCallback?(dataArray) + } + + func getFileSize(from fileURL: URL) throws -> Int64 { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + + guard let fileSize = fileAttributes[.size] as? Int64 else { + throw FileValidationError.fileNotFound + } + + return fileSize + } + + func getPhoto(from itemProvider: NSItemProvider) async throws -> UIImage { + let objectType: NSItemProviderReading.Type = UIImage.self + + guard itemProvider.canLoadObject(ofClass: objectType) else { + throw FileValidationError.tooManyFiles + } + + return try await withUnsafeThrowingContinuation { continuation in + itemProvider.loadObject(ofClass: objectType) { object, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let image = object as? UIImage else { + continuation.resume(throwing: FileValidationError.tooManyFiles) + return + } + + continuation.resume(returning: image) + } + } + } + + func getUrl( + from itemProvider: NSItemProvider, + typeIdentifier: String + ) async throws -> URL { + try await withUnsafeThrowingContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let url = url else { + continuation.resume(throwing: FileValidationError.tooManyFiles) + return + } + + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { return } + + do { + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + continuation.resume(returning: targetURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func getThumbnailImage(forUrl url: URL) -> UIImage? { + let asset: AVAsset = AVAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + + do { + let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) + return UIImage(cgImage: thumbnailImage) + } catch let error { + print("error in thumbail=", error) + return nil + } + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift similarity index 59% rename from FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift rename to FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift index ead4152af..f15f75062 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilePickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift @@ -7,10 +7,8 @@ import Foundation import UIKit +import CommonKit protocol FilePickerProtocol { - func startPicker( - window: UIWindow, - completion: (([FileResult]) -> Void)? - ) + func startPicker(completion: (([FileResult]) -> Void)?) } diff --git a/FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift b/FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift similarity index 78% rename from FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift rename to FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift index 988a30f98..586837470 100644 --- a/FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift +++ b/FilesPickerKit/Tests/FilesPickerKitTests/FilesPickerKitTests.swift @@ -1,7 +1,7 @@ import XCTest -@testable import FilesStorageKit +@testable import FilesPickerKit -final class FilesStorageKitTests: XCTestCase { +final class FilesPickerKitTests: XCTestCase { func testExample() throws { // XCTest Documentation // https://developer.apple.com/documentation/xctest diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift deleted file mode 100644 index a730beeb8..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ /dev/null @@ -1,234 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -import CommonKit -import UIKit - -public final class FilesStorageKit { - public static let shared = FilesStorageKit() - - private let adamantCore = NativeAdamantCore() - private let window = TransparentWindow(frame: UIScreen.main.bounds) - private let mediaPicker: FilePickerProtocol - private let documentPicker: FilePickerProtocol - private var cachedImages: [String: UIImage] = [:] - private var cachedFiles: [String: URL] = [:] - - private let networkFileManager: NetworkFileManagerProtocol = NetworkFileManager() - private let imageExtensions = ["JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD"] - - public init() { - mediaPicker = MediaPickerService() - documentPicker = DocumentPickerService() - } - - public func setup() { - try? loadCache() - } - - @MainActor - public func presentImagePicker() async throws -> [FileResult] { - try await withUnsafeThrowingContinuation { continuation in - mediaPicker.startPicker( - window: window - ) { [weak self] data in - do { - try self?.validateFiles(data.map { $0.url }) - } catch { - continuation.resume(throwing: error) - return - } - - continuation.resume(returning: data) - } - } - } - - @MainActor - public func presentDocumentPicker() async throws -> [FileResult] { - try await withUnsafeThrowingContinuation { continuation in - documentPicker.startPicker( - window: window - ) { [weak self] data in - do { - try self?.validateFiles(data.map { $0.url }) - } catch { - continuation.resume(throwing: error) - return - } - - continuation.resume(returning: data) - } - } - } - - public func getPreview(for id: String, type: String) -> UIImage { - guard let data = cachedImages[id] else { - return getPreview(for: type) - } - - return data - } - - public func isCached(_ id: String) -> Bool { - cachedImages[id] != nil || cachedFiles[id] != nil - } - - public func uploadFile( - _ file: FileResult, - recipientPublicKey: String, - senderPrivateKey: String - ) async throws -> (id: String, nonce: String) { - defer { - cacheImage(id: file.url.absoluteString, image: nil) - } - - _ = file.url.startAccessingSecurityScopedResource() - - let data = try Data(contentsOf: file.url) - - let encodedResult = adamantCore.encodeData( - data, - recipientPublicKey: recipientPublicKey, - privateKey: senderPrivateKey - ) - - guard let encodedData = encodedResult?.data, - let nonce = encodedResult?.nonce - else { - throw FileManagerError.cantEnctryptFile - } - - if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: file.url.absoluteString, image: UIImage(data: encodedData)) - } - - let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) - - if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: id, image: UIImage(data: data)) - } - - file.url.stopAccessingSecurityScopedResource() - return (id: id, nonce: nonce) - } - - public func downloadFile( - id: String, - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String - ) async throws { - let encodedData = try await networkFileManager.downloadFile(id, type: storage) - - guard let decodedData = adamantCore.decodeData( - encodedData, - rawNonce: nonce, - senderPublicKey: senderPublicKey, - privateKey: recipientPrivateKey - ) - else { - throw FileValidationError.fileNotFound - } - - if imageExtensions.contains(fileType?.uppercased() ?? defaultFileType) { - cacheImage(id: id, image: UIImage(data: decodedData)) - } else { - try cacheFile(id: id, data: encodedData) - } - } -} - -private extension FilesStorageKit { - func validateFiles(_ fileURLs: [URL]) throws { - guard fileURLs.count <= Constants.maxFilesCount else { - throw FileValidationError.tooManyFiles - } - - for fileURL in fileURLs { - _ = fileURL.startAccessingSecurityScopedResource() - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - - fileURL.stopAccessingSecurityScopedResource() - guard let fileSize = fileAttributes[.size] as? Int64 else { - throw FileValidationError.fileNotFound - } - - guard fileSize <= Constants.maxFileSize else { - throw FileValidationError.fileSizeExceedsLimit - } - } - } - - func loadCache() throws { - let folder = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ).appendingPathComponent(cachePath) - - let files = getFiles(at: folder) - - files.forEach { url in - cachedFiles[url.lastPathComponent] = url - } - } - - func getFiles(at url: URL) -> [URL] { - let fileManager = FileManager.default - var isDirectory: ObjCBool = false - var subdirectoryNames: [URL] = [] - - guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { - return subdirectoryNames - } - - for item in contents { - if fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) && !isDirectory.boolValue { - subdirectoryNames.append(item) - } - } - - return subdirectoryNames - } - - func cacheFile(id: String, data: Data) throws { - let folder = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ).appendingPathComponent(cachePath) - - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - - let fileURL = folder.appendingPathComponent(id) - - try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - - cachedFiles[id] = fileURL - } - - func cacheImage(id: String, image: UIImage?) { - cachedImages[id] = image - } - - private func getPreview(for type: String) -> UIImage { - switch type.uppercased() { - case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD": - return UIImage.asset(named: "file-image-box")! - case "ZIP": - return UIImage.asset(named: "file-zip-box")! - case "PDF": - return UIImage.asset(named: "file-pdf-box")! - default: - return UIImage.asset(named: "file-default-box")! - } - } -} - -private let defaultFileType = "" -private let cachePath = "downloads" diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift deleted file mode 100644 index 402b83c51..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileResult.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// File.swift -// -// -// Created by Stanislav Jelezoglo on 19.02.2024. -// - -import Foundation -import UIKit - -public enum FileType { - case image - case video - case other -} - -public struct FileResult { - public let url: URL - public let type: FileType - public let preview: UIImage? - public let size: Int64 - public let name: String? - public let extenstion: String? -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift deleted file mode 100644 index 747f19573..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/NetworkFileProtocolType.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// NetworkFileProtocolType.swift -// -// -// Created by Stanislav Jelezoglo on 26.02.2024. -// - -import Foundation - -public enum NetworkFileProtocolType: String { - case uploadCareApi -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift deleted file mode 100644 index 380480ea6..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Protocols/ApiManagerProtocol.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ApiManagerProtocol.swift -// -// -// Created by Stanislav Jelezoglo on 26.02.2024. -// - -import Foundation - -protocol ApiManagerProtocol { - func uploadFile(data: Data) async throws -> String - func downloadFile(id: String) async throws -> Data -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift deleted file mode 100644 index 6352ac35f..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Protocols/NetworkFileManagerProtocol.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// NetworkFileManagerProtocol.swift -// -// -// Created by Stanislav Jelezoglo on 20.02.2024. -// - -import Foundation - -protocol NetworkFileManagerProtocol { - func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String - func downloadFile(_ id: String, type: String) async throws -> Data -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift deleted file mode 100644 index 2edade542..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/API Managers/UploadCareApiManager.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// BaseApiManager.swift -// -// -// Created by Stanislav Jelezoglo on 20.02.2024. -// - -import Foundation -import Uploadcare - -final class UploadCareApiManager: ApiManagerProtocol { - private var uploadcare: Uploadcare - - init() { - self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") - } - - func uploadFile(data: Data) async throws -> String { - let fileForUploading = uploadcare.file(fromData: data) - try await fileForUploading.upload(withName: String.random(length: 6), store: .auto) - return fileForUploading.fileId - } - - func downloadFile(id: String) async throws -> Data { - let request = URLRequest(url: URL(string: "https://ucarecdn.com/\(id)/")!) - let (data, _) = try await URLSession.shared.data(for: request) - return data - } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift deleted file mode 100644 index 506637e42..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/NetworkFileManager.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// NetworkFileManager.swift -// -// -// Created by Stanislav Jelezoglo on 26.02.2024. -// - -import Foundation - -final class NetworkFileManager: NetworkFileManagerProtocol { - private let uploadCareApi: ApiManagerProtocol = UploadCareApiManager() - - func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String { - switch type { - case .uploadCareApi: - return try await uploadCareApi.uploadFile(data: data) - } - } - - func downloadFile(_ id: String, type: String) async throws -> Data { - guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { - throw FileManagerError.cantDownloadFile - } - - switch netwrokProtocol { - case .uploadCareApi: - return try await uploadCareApi.downloadFile(id: id) - } - } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift b/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift deleted file mode 100644 index ec93f4a91..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Services/Pickers/MediaPickerService.swift +++ /dev/null @@ -1,378 +0,0 @@ -// -// File.swift -// -// -// Created by Stanislav Jelezoglo on 11.02.2024. -// - -import Foundation -import UIKit -import Photos -import PhotosUI - -final class MediaPickerService: NSObject, FilePickerProtocol { - private var onPreparedDataCallback: (([FileResult]) -> Void)? - - func startPicker( - window: UIWindow, - completion: (([FileResult]) -> Void)? - ) { - onPreparedDataCallback = completion - - var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) - phPickerConfig.selectionLimit = Constants.maxFilesCount - phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) - - let phPickerVC = PHPickerViewController(configuration: phPickerConfig) - phPickerVC.delegate = self - UIApplication.shared.topViewController()?.present(phPickerVC, animated: true) - } -} - -extension MediaPickerService: PHPickerViewControllerDelegate { - func picker( - _ picker: PHPickerViewController, - didFinishPicking results: [PHPickerResult] - ) { - picker.dismiss(animated: true, completion: .none) - Task { - await processResults(results) - } - } -} - -private extension MediaPickerService { - func processResults(_ results: [PHPickerResult]) async { - var dataArray: [FileResult] = [] - - for result in results { - let itemProvider = result.itemProvider - - guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, - let utType = UTType(typeIdentifier) - else { continue } - - if utType.conforms(to: .image) { - guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), - let preview = try? await getPhoto(from: itemProvider), - let fileSize = try? getFileSize(from: url) - else { continue } - - dataArray.append( - .init( - url: url, - type: .image, - preview: preview, - size: fileSize, - name: itemProvider.suggestedName, - extenstion: "JPG" - ) - ) - } - - if utType.conforms(to: .movie) { - guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), - let fileSize = try? getFileSize(from: url) - else { continue } - - let preview = getThumbnailImage(forUrl: url) - - dataArray.append( - .init( - url: url, - type: .video, - preview: preview, - size: fileSize, - name: itemProvider.suggestedName, - extenstion: "JPG" - ) - ) - } - } - - onPreparedDataCallback?(dataArray) - } - - func getFileSize(from fileURL: URL) throws -> Int64 { - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - - guard let fileSize = fileAttributes[.size] as? Int64 else { - throw FileValidationError.fileNotFound - } - - return fileSize - } - - func getPhoto(from itemProvider: NSItemProvider) async throws -> UIImage { - let objectType: NSItemProviderReading.Type = UIImage.self - - guard itemProvider.canLoadObject(ofClass: objectType) else { - throw FileValidationError.tooManyFiles - } - - return try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadObject(ofClass: objectType) { object, error in - if let error = error { - continuation.resume(throwing: error) - return - } - - guard let image = object as? UIImage else { - continuation.resume(throwing: FileValidationError.tooManyFiles) - return - } - - continuation.resume(returning: image) - } - } - } - - func getUrl( - from itemProvider: NSItemProvider, - typeIdentifier: String - ) async throws -> URL { - try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in - if let error = error { - continuation.resume(throwing: error) - return - } - - guard let url = url else { - continuation.resume(throwing: FileValidationError.tooManyFiles) - return - } - - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { return } - - do { - if FileManager.default.fileExists(atPath: targetURL.path) { - try FileManager.default.removeItem(at: targetURL) - } - - try FileManager.default.copyItem(at: url, to: targetURL) - - continuation.resume(returning: targetURL) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - func getThumbnailImage(forUrl url: URL) -> UIImage? { - let asset: AVAsset = AVAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - - do { - let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) - return UIImage(cgImage: thumbnailImage) - } catch let error { - print("error in thumbail=", error) - return nil - } - } -} - -//private extension MediaPickerService { -// func fetchData(for assets: [PHAsset]) { -// var dataArray: [FileResult] = [] -// let dispatchGroup = DispatchGroup() -// -// for asset in assets { -// dispatchGroup.enter() -// -// if asset.mediaType == .image { -// requestImage(asset: asset) { data in -// defer { dispatchGroup.leave() } -// -// guard let data = data else { return } -// dataArray.append(.init( -// data: data, -// type: .image, -// preview: UIImage(data: data)) -// ) -// } -// } -// -// if asset.mediaType == .video { -// requestVideo(asset: asset) { data, imgData in -// defer { dispatchGroup.leave() } -// -// guard let data = data, let imgData = imgData else { return } -// dataArray.append( -// .init( -// data: data, -// type: .video, -// preview: UIImage(data: imgData)) -// ) -// } -// } -// } -// -// dispatchGroup.notify(queue: DispatchQueue.main) { -// self.onPreparedDataCallback?(dataArray) -// } -// } -// -// func requestImage( -// asset: PHAsset, -// completion: ((Data?) -> Void)?, -// tryNumber: Int = 1 -// ) { -// requestImageData(asset: asset) { [weak self, asset] image in -// if image == nil && tryNumber < 4 { -// self?.requestImage( -// asset: asset, -// completion: completion, -// tryNumber: tryNumber + 1 -// ) -// return -// } -// -// completion?(image) -// } -// } -// -// func requestImageData( -// asset: PHAsset, -// completion: ((Data?) -> Void)? -// ) { -// let imgManager = PHImageManager.default() -// -// let options = PHImageRequestOptions() -// options.isSynchronous = false -// options.deliveryMode = .opportunistic -// options.isNetworkAccessAllowed = true -// options.resizeMode = .exact -// -// imgManager.requestImage( -// for: asset, -// targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), -// contentMode: .default, -// options: options -// ) { image, info in -// if info?[PHImageResultIsDegradedKey] as? Bool == true { -// return -// } -// completion?(image?.jpegData(compressionQuality: 0.6)) -// } -// } -// -// func requestVideo( -// asset: PHAsset, -// completion: ((Data?, Data?) -> Void)? -// ) { -// let options = PHVideoRequestOptions() -// options.deliveryMode = .fastFormat -// options.isNetworkAccessAllowed = true -// -// PHCachingImageManager().requestAVAsset( -// forVideo: asset, -// options: options -// ) { [weak self] (newAsset, _, _) in -// guard let newAsset = newAsset as? AVURLAsset -// else { -// return -// } -// -// let videoData = try? Data(contentsOf: newAsset.url) -// -// self?.requestImage(asset: asset) { data in -// completion?(videoData, data) -// } -// } -// } -//} - -//private extension MediaPickerService { -// func fetchData(for assets: [PHAsset]) async { -// var dataArray: [FileResult] = [] -// -// for asset in assets { -// if asset.mediaType == .image, -// let imageData = await requestImageData(asset: asset) { -// let previewImage = UIImage(data: imageData) -// dataArray.append(.init( -// data: imageData, -// type: .image, -// preview: previewImage) -// ) -// } -// -// if asset.mediaType == .video, -// let (videoData, imageData) = await requestVideo(asset: asset) { -// let previewImage = UIImage(data: imageData) -// dataArray.append(.init( -// data: videoData, -// type: .video, -// preview: previewImage) -// ) -// } -// } -// -// onPreparedDataCallback?(dataArray) -// } -// -// func requestImageData(asset: PHAsset) async -> Data? { -// let imgManager = PHImageManager.default() -// -// let options = PHImageRequestOptions() -// options.isSynchronous = false -// options.deliveryMode = .opportunistic -// options.isNetworkAccessAllowed = true -// options.resizeMode = .exact -// -// return await withCheckedContinuation { continuation in -// imgManager.requestImage( -// for: asset, -// targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), -// contentMode: .default, -// options: options -// ) { image, info in -// if info?[PHImageResultIsDegradedKey] as? Bool == true { -// return -// } -// -// if let imageData = image?.jpegData(compressionQuality: 0.6) { -// continuation.resume(returning: imageData) -// } else { -// continuation.resume(returning: nil) -// } -// } -// } -// } -// -// func requestVideo(asset: PHAsset) async -> (Data, Data)? { -// guard let videoData = await requestVideoData(asset: asset), -// let imageData = await requestImageData(asset: asset) -// else { -// return nil -// } -// -// return (videoData, imageData) -// } -// -// func requestVideoData(asset: PHAsset) async -> Data? { -// let options = PHVideoRequestOptions() -// options.deliveryMode = .fastFormat -// options.isNetworkAccessAllowed = true -// -// return await withCheckedContinuation { continuation in -// PHCachingImageManager().requestAVAsset( -// forVideo: asset, -// options: options -// ) { (newAsset, _, _) in -// guard let newAsset = newAsset as? AVURLAsset -// else { -// return -// } -// -// let videoData = try? Data(contentsOf: newAsset.url) -// -// continuation.resume(returning: videoData) -// } -// } -// } -//} From d53395961eb33f46257c120196e9526e9d2c444f Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 7 Mar 2024 12:12:29 +0200 Subject: [PATCH 009/123] [trello.com/c/A7r39rOK] Implemented files network manager --- Adamant.xcodeproj/project.pbxproj | 7 ++++ Adamant.xcworkspace/contents.xcworkspacedata | 3 ++ .../xcshareddata/swiftpm/Package.resolved | 9 +++++ .../Chat/ViewModel/ChatViewModel.swift | 1 - FilesNetworkManagerKit/.gitignore | 8 +++++ FilesNetworkManagerKit/Package.swift | 33 +++++++++++++++++++ .../FilesNetworkManagerKit.swift | 28 ++++++++++++++++ .../Managers/UploadCareApiManager.swift | 30 +++++++++++++++++ .../Models/FileManagerError.swift | 25 ++++++++++++++ .../Models/NetworkFileProtocolType.swift | 12 +++++++ .../Protocols/ApiManagerProtocol.swift | 13 ++++++++ .../FilesNetworkManagerKitTests.swift | 12 +++++++ 12 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 FilesNetworkManagerKit/.gitignore create mode 100644 FilesNetworkManagerKit/Package.swift create mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift create mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift create mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift create mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift create mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift create mode 100644 FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index c9fbdec41..f4ac45b1f 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 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 */; }; + 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */; }; 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 */; }; @@ -1254,6 +1255,7 @@ A50AEB0C262C81E300B37C22 /* QRCodeReader in Frameworks */, 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, A5F92994262C855B00C3E60A /* MarkdownKit in Frameworks */, + 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */, A50AEB04262C815200B37C22 /* EFQRCode in Frameworks */, A544F0D4262C9878001F1A6D /* Eureka in Frameworks */, 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */, @@ -2672,6 +2674,7 @@ 9342F6C12A6A35E300A9B39F /* CommonKit */, 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, 3A075C9D2B98A3B100714E3B /* FilesPickerKit */, + 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */, ); productName = Adamant; productReference = E913C8EE1FFFA51D001A83F7 /* Adamant.app */; @@ -4259,6 +4262,10 @@ isa = XCSwiftPackageProductDependency; productName = FilesPickerKit; }; + 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */ = { + isa = XCSwiftPackageProductDependency; + productName = FilesNetworkManagerKit; + }; 3A8875EE27BBF38D00436195 /* Parchment */ = { isa = XCSwiftPackageProductDependency; package = 3A8875ED27BBF38D00436195 /* XCRemoteSwiftPackageReference "Parchment" */; diff --git a/Adamant.xcworkspace/contents.xcworkspacedata b/Adamant.xcworkspace/contents.xcworkspacedata index cc09e3250..b4c1bb4b6 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,9 @@ + + diff --git a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved index 88a2fda6f..6252fdfc6 100644 --- a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -325,6 +325,15 @@ "version": "2.7.1" } }, + { + "package": "Uploadcare", + "repositoryURL": "https://github.com/uploadcare/uploadcare-swift.git", + "state": { + "branch": "master", + "revision": "3ab0706a726abcb9c935347add80f855c2a08f12", + "version": null + } + }, { "package": "Web3swift", "repositoryURL": "https://github.com/skywinder/web3swift.git", diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 1b13f94c1..8f496523c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -13,7 +13,6 @@ import UIKit import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker -import FilesStorageKit import FilesPickerKit @MainActor diff --git a/FilesNetworkManagerKit/.gitignore b/FilesNetworkManagerKit/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/FilesNetworkManagerKit/.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/FilesNetworkManagerKit/Package.swift b/FilesNetworkManagerKit/Package.swift new file mode 100644 index 000000000..a4a45d76c --- /dev/null +++ b/FilesNetworkManagerKit/Package.swift @@ -0,0 +1,33 @@ +// 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: "FilesNetworkManagerKit", + platforms: [ + .iOS(.v15) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "FilesNetworkManagerKit", + targets: ["FilesNetworkManagerKit"]), + ], + dependencies: [ + .package(url: "https://github.com/uploadcare/uploadcare-swift.git", branch: "master") + ], + 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: "FilesNetworkManagerKit", + dependencies: [ + .product(name: "Uploadcare", package: "uploadcare-swift") + ] + ), + .testTarget( + name: "FilesNetworkManagerKitTests", + dependencies: ["FilesNetworkManagerKit"]), + ] +) diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift new file mode 100644 index 000000000..15569265a --- /dev/null +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift @@ -0,0 +1,28 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import Foundation + +public final class FilesNetworkManager { + private let uploadCareApi: ApiManagerProtocol = UploadCareApiManager() + + public init() { } + + public func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String { + switch type { + case .uploadCareApi: + return try await uploadCareApi.uploadFile(data: data) + } + } + + public func downloadFile(_ id: String, type: String) async throws -> Data { + guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { + throw FileManagerError.cantDownloadFile + } + + switch netwrokProtocol { + case .uploadCareApi: + return try await uploadCareApi.downloadFile(id: id) + } + } +} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift new file mode 100644 index 000000000..921215dd0 --- /dev/null +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift @@ -0,0 +1,30 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import Foundation +import Uploadcare +import CommonKit + +final class UploadCareApiManager: ApiManagerProtocol { + private var uploadcare: Uploadcare + + init() { + self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") + } + + func uploadFile(data: Data) async throws -> String { + let fileForUploading = uploadcare.file(fromData: data) + try await fileForUploading.upload(withName: String.random(length: 6), store: .auto) + return fileForUploading.fileId + } + + func downloadFile(id: String) async throws -> Data { + let request = URLRequest(url: URL(string: "https://ucarecdn.com/\(id)/")!) + let (data, _) = try await URLSession.shared.data(for: request) + return data + } +} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift new file mode 100644 index 000000000..9b2a040ef --- /dev/null +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift @@ -0,0 +1,25 @@ +// +// FileManagerError.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import Foundation + +public enum FileManagerError: Error, LocalizedError { + case cantDownloadFile + case cantUploadFile + case cantEnctryptFile + + public var errorDescription: String { + switch self { + case .cantDownloadFile: + return "cant Download File" + case .cantUploadFile: + return "cant Upload File" + case .cantEnctryptFile: + return "cant encrypt file" + } + } +} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift new file mode 100644 index 000000000..a663816e2 --- /dev/null +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift @@ -0,0 +1,12 @@ +// +// NetworkFileProtocolType.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import Foundation + +public enum NetworkFileProtocolType: String { + case uploadCareApi +} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift new file mode 100644 index 000000000..5cd694c47 --- /dev/null +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift @@ -0,0 +1,13 @@ +// +// ApiManagerProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 06.03.2024. +// + +import Foundation + +protocol ApiManagerProtocol { + func uploadFile(data: Data) async throws -> String + func downloadFile(id: String) async throws -> Data +} diff --git a/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift b/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift new file mode 100644 index 000000000..f8c7628c6 --- /dev/null +++ b/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FilesNetworkManagerKit + +final class FilesNetworkManagerKitTests: 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 + } +} From 60b6442b9e018ac27bd9c7620c3ca3ed421b7722 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 7 Mar 2024 13:01:27 +0200 Subject: [PATCH 010/123] [trello.com/c/JesZTqq8] implemented files storage kit --- Adamant.xcodeproj/project.pbxproj | 11 ++ Adamant.xcworkspace/contents.xcworkspacedata | 3 + Adamant/App/AppDelegate.swift | 2 - Adamant/App/DI/AppAssembly.swift | 4 + Adamant/Modules/Chat/ChatFactory.swift | 8 +- .../FilesToolbarCollectionViewCell.swift | 1 + .../FilesToolBarView/FilesToolbarView.swift | 1 + .../Chat/ViewModel/ChatMessageFactory.swift | 12 +- .../Chat/ViewModel/ChatViewModel.swift | 84 ++++----- .../FilesStorageProtocol.swift | 35 ++++ FilesStorageKit/.gitignore | 8 + FilesStorageKit/Package.swift | 31 ++++ .../FilesStorageKit/FilesStorageKit.swift | 170 ++++++++++++++++++ .../FilesStorageKitTests.swift | 12 ++ 14 files changed, 332 insertions(+), 50 deletions(-) create mode 100644 Adamant/ServiceProtocols/FilesStorageProtocol.swift create mode 100644 FilesStorageKit/.gitignore create mode 100644 FilesStorageKit/Package.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift create mode 100644 FilesStorageKit/Tests/FilesStorageKitTests/FilesStorageKitTests.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index f4ac45b1f..2e46e3939 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 3A7BD0102AA9BD030045AAB0 /* AdamantVibroService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7BD00F2AA9BD030045AAB0 /* AdamantVibroService.swift */; }; 3A7BD0122AA9BD5A0045AAB0 /* AdamantVibroType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A7BD0112AA9BD5A0045AAB0 /* AdamantVibroType.swift */; }; 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */; }; + 3A833C3E2B99CCD600238F6A /* FilesStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.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 */; }; @@ -689,6 +691,7 @@ 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 = ""; }; + 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesStorageProtocol.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 = ""; }; @@ -1255,6 +1258,7 @@ A50AEB0C262C81E300B37C22 /* QRCodeReader in Frameworks */, 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, A5F92994262C855B00C3E60A /* MarkdownKit in Frameworks */, + 3A833C402B99CDA000238F6A /* FilesStorageKit in Frameworks */, 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */, A50AEB04262C815200B37C22 /* EFQRCode in Frameworks */, A544F0D4262C9878001F1A6D /* Eureka in Frameworks */, @@ -2031,6 +2035,7 @@ 3A2F55FB2AC6F885000A3F26 /* CoinStorage.swift */, 3A96E37B2AED27F8001F5A52 /* PartnerQRService.swift */, 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */, + 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */, ); path = ServiceProtocols; sourceTree = ""; @@ -2675,6 +2680,7 @@ 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, 3A075C9D2B98A3B100714E3B /* FilesPickerKit */, 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */, + 3A833C3F2B99CDA000238F6A /* FilesStorageKit */, ); productName = Adamant; productReference = E913C8EE1FFFA51D001A83F7 /* Adamant.app */; @@ -3417,6 +3423,7 @@ A50A41132822FC35006BDFE1 /* BtcWalletService+Send.swift in Sources */, A5E04229282A998C0076CD13 /* BtcTransactionResponse.swift in Sources */, 4186B334294200C5006594A3 /* EthWalletService+DynamicConstants.swift in Sources */, + 3A833C3E2B99CCD600238F6A /* FilesStorageProtocol.swift in Sources */, 93CCAE802B06E2D100EA5B94 /* ApiServiceError+Extension.swift in Sources */, 644EC34F20EFA77A00F40C73 /* Delegate.swift in Sources */, 64EAB37622463F680018D9B2 /* AdamantCurrencyInfoService.swift in Sources */, @@ -4266,6 +4273,10 @@ isa = XCSwiftPackageProductDependency; productName = FilesNetworkManagerKit; }; + 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 b4c1bb4b6..ab41fcc38 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -1,6 +1,9 @@ + + diff --git a/Adamant/App/AppDelegate.swift b/Adamant/App/AppDelegate.swift index 6931919b6..ad9b80d34 100644 --- a/Adamant/App/AppDelegate.swift +++ b/Adamant/App/AppDelegate.swift @@ -161,8 +161,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: 4. Setup dialog service dialogService.setup(window: window) - FilesStorageKit.shared.setup() - // MARK: 5. Show login let login = screensFactory.makeLogin() let welcomeIsShown = UserDefaults.standard.bool(forKey: StoreKey.application.welcomeScreensIsShown) diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index bfad567c2..b7d00067e 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -9,6 +9,7 @@ import Swinject import BitcoinKit import CommonKit +import FilesStorageKit struct AppAssembly: Assembly { func assemble(container: Container) { @@ -16,6 +17,9 @@ struct AppAssembly: Assembly { // MARK: AdamantCore container.register(AdamantCore.self) { _ in NativeAdamantCore() }.inObjectScope(.container) + // MARK: AdamantCore + container.register(FilesStorageProtocol.self) { _ in FilesStorageKit() }.inObjectScope(.container) + // MARK: CellFactory container.register(CellFactory.self) { _ in AdamantCellFactory() }.inObjectScope(.container) diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 68bb471b6..3e8fc7385 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -27,6 +27,7 @@ struct ChatFactory { let avatarService: AvatarService let emojiService: EmojiService let walletServiceCompose: WalletServiceCompose + let filesStorage: FilesStorageProtocol nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! @@ -40,6 +41,7 @@ struct ChatFactory { avatarService = assembler.resolve(AvatarService.self)! emojiService = assembler.resolve(EmojiService.self)! walletServiceCompose = assembler.resolve(WalletServiceCompose.self)! + filesStorage = assembler.resolve(FilesStorageProtocol.self)! } func makeViewController(screensFactory: ScreensFactory) -> ChatViewController { @@ -97,7 +99,8 @@ private extension ChatFactory { markdownParser: .init(font: UIFont.systemFont(ofSize: UIFont.systemFontSize)), transfersProvider: transferProvider, chatMessagesListFactory: .init(chatMessageFactory: .init( - walletServiceCompose: walletServiceCompose + walletServiceCompose: walletServiceCompose, + filesStorage: filesStorage )), addressBookService: addressBookService, visibleWalletService: visibleWalletService, @@ -111,7 +114,8 @@ private extension ChatFactory { avatarService: avatarService, emojiService: emojiService ), - emojiService: emojiService + emojiService: emojiService, + filesStorage: filesStorage ) } diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index ac1674085..e8342dfa9 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -9,6 +9,7 @@ import UIKit import SnapKit import FilesStorageKit +import CommonKit final class FilesToolbarCollectionViewCell: UICollectionViewCell { private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift index 5d9ab8c78..20b0facdf 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -9,6 +9,7 @@ import UIKit import SnapKit import FilesStorageKit +import CommonKit final class FilesToolbarView: UIView { private lazy var collectionView: UICollectionView = { diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 8f82915ad..560ed262b 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -14,6 +14,7 @@ import FilesStorageKit struct ChatMessageFactory { private let walletServiceCompose: WalletServiceCompose + private let filesStorage: FilesStorageProtocol static let markdownParser = MarkdownParser( font: .adamantChatDefault, @@ -63,8 +64,11 @@ struct ChatMessageFactory { ] ) - init(walletServiceCompose: WalletServiceCompose) { + init(walletServiceCompose: WalletServiceCompose, + filesStorage: FilesStorageProtocol + ) { self.walletServiceCompose = walletServiceCompose + self.filesStorage = filesStorage } func makeMessage( @@ -326,18 +330,18 @@ private extension ChatMessageFactory { let chatFiles = files.map { ChatFile.init( file: RichMessageFile.File.init($0), - previewData: FilesStorageKit.shared.getPreview( + previewData: filesStorage.getPreview( for: $0[RichContentKeys.file.file_id] as? String ?? .empty, type: $0[RichContentKeys.file.file_type] as? String ?? .empty ), isDownloading: false, isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), - isCached: FilesStorageKit.shared.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), + isCached: filesStorage.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty ) } - print("is reply=\(transaction.isFileReply()), richcontent=\(transaction.richContent)") + return .file(.init(value: .init( id: id, isFromCurrentSender: isFromCurrentSender, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8f496523c..039b4871d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -14,6 +14,7 @@ import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker import FilesPickerKit +import FilesNetworkManagerKit @MainActor final class ChatViewModel: NSObject { @@ -32,6 +33,7 @@ final class ChatViewModel: NSObject { private let walletServiceCompose: WalletServiceCompose private let avatarService: AvatarService private let emojiService: EmojiService + private let filesStorage: FilesStorageProtocol let chatMessagesListViewModel: ChatMessagesListViewModel @@ -146,7 +148,8 @@ final class ChatViewModel: NSObject { walletServiceCompose: WalletServiceCompose, avatarService: AvatarService, chatMessagesListViewModel: ChatMessagesListViewModel, - emojiService: EmojiService + emojiService: EmojiService, + filesStorage: FilesStorageProtocol ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -162,6 +165,8 @@ final class ChatViewModel: NSObject { self.avatarService = avatarService self.chatMessagesListViewModel = chatMessagesListViewModel self.emojiService = emojiService + self.filesStorage = filesStorage + super.init() setupObservers() } @@ -340,7 +345,7 @@ final class ChatViewModel: NSObject { } for file in files { - let result = try await FilesStorageKit.shared.uploadFile( + let result = try await filesStorage.uploadFile( file, recipientPublicKey: chatroom?.partner?.publicKey ?? "", senderPrivateKey: keyPair.privateKey @@ -348,7 +353,15 @@ final class ChatViewModel: NSObject { let oldId = file.url.absoluteString uploadingFilesIDs.removeAll(where: { $0 == oldId }) - updateUploadingFileId(&messages, oldId: oldId, newId: result.id) + + let preview = filesStorage.getPreview( + for: result.id, + type: file.extenstion ?? "" + ) + + let cached = filesStorage.isCached(result.id) + + updateFileFields(&messages, id: oldId, newId: result.id, preview: preview, cached: cached) if let index = richFiles.firstIndex( where: { $0.file_id == oldId } @@ -740,7 +753,7 @@ final class ChatViewModel: NSObject { downloadingFilesID.append(file.file.file_id) do { - try await FilesStorageKit.shared.downloadFile( + try await filesStorage.downloadFile( id: file.file.file_id, storage: file.storage, fileType: file.file.file_type ?? .empty, @@ -749,7 +762,14 @@ final class ChatViewModel: NSObject { nonce: file.nonce ) - updatePreviewForFile(&messages, id: file.file.file_id) + let preview = filesStorage.getPreview( + for: file.file.file_id, + type: file.file.file_type ?? "" + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) } catch { dialog.send(.alert(error.localizedDescription)) } @@ -1144,22 +1164,15 @@ private extension ChatViewModel { } } - func updateUploadingFileId( - _ messages: inout [ChatMessage], - oldId: String, - newId: String - ) { - messages.indices.forEach { index in - messages[index].updateID(old: oldId, new: newId) - } - } - - func updatePreviewForFile( + func updateFileFields( _ messages: inout [ChatMessage], - id: String + id oldId: String, + newId: String? = nil, + preview: UIImage?, + cached: Bool ) { messages.indices.forEach { index in - messages[index].updatePreview(for: id) + messages[index].updateFields(id: oldId, newId: newId, preview: preview, cached: cached) } } @@ -1241,7 +1254,12 @@ private extension ChatMessage { content = .file(.init(value: model)) } - mutating func updateID(old oldId: String, new newId: String) { + mutating func updateFields( + id oldId: String, + newId: String? = nil, + preview: UIImage?, + cached: Bool + ) { guard case let .file(fileModel) = content else { return } var model = fileModel.value @@ -1249,29 +1267,11 @@ private extension ChatMessage { where: { $0.file.file_id == oldId } ) else { return } - model.content.files[index].file.file_id = newId - model.content.files[index].previewData = FilesStorageKit.shared.getPreview( - for: newId, - type: model.content.files[index].file.file_type ?? "" - ) - model.content.files[index].isCached = FilesStorageKit.shared.isCached(newId) - - content = .file(.init(value: model)) - } - - mutating func updatePreview(for id: String) { - guard case let .file(fileModel) = content else { return } - var model = fileModel.value - - guard let index = model.content.files.firstIndex( - where: { $0.file.file_id == id } - ) else { return } - - model.content.files[index].previewData = FilesStorageKit.shared.getPreview( - for: id, - type: model.content.files[index].file.file_type ?? "" - ) - model.content.files[index].isCached = FilesStorageKit.shared.isCached(id) + if let newId = newId { + model.content.files[index].file.file_id = newId + } + model.content.files[index].previewData = preview + model.content.files[index].isCached = cached content = .file(.init(value: model)) } diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift new file mode 100644 index 000000000..497a57399 --- /dev/null +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -0,0 +1,35 @@ +// +// FilesStorageProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 07.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import UIKit +import CommonKit +import FilesStorageKit + +protocol FilesStorageProtocol { + func getPreview(for id: String, type: String) -> UIImage + + func isCached(_ id: String) -> Bool + + func uploadFile( + _ file: FileResult, + recipientPublicKey: String, + senderPrivateKey: String + ) async throws -> (id: String, nonce: String) + + func downloadFile( + id: String, + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) async throws +} + +extension FilesStorageKit: FilesStorageProtocol { } 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..d24396c56 --- /dev/null +++ b/FilesStorageKit/Package.swift @@ -0,0 +1,31 @@ +// 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"), + .package(path: "../FilesNetworkManagerKit") + ], + 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", "FilesNetworkManagerKit"]), + .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..948dfe3f4 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -0,0 +1,170 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit +import FilesNetworkManagerKit +import FilesPickerKit + +public final class FilesStorageKit { + private let adamantCore = NativeAdamantCore() + private let networkFileManager = FilesNetworkManager() + + private var cachedImages: [String: UIImage] = [:] + private var cachedFiles: [String: URL] = [:] + private let imageExtensions = ["JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD"] + + public init() { + try? loadCache() + } + + public func getPreview(for id: String, type: String) -> UIImage { + guard let data = cachedImages[id] else { + return getPreview(for: type) + } + + return data + } + + public func isCached(_ id: String) -> Bool { + cachedImages[id] != nil || cachedFiles[id] != nil + } + + public func uploadFile( + _ file: FileResult, + recipientPublicKey: String, + senderPrivateKey: String + ) async throws -> (id: String, nonce: String) { + defer { + cacheImage(id: file.url.absoluteString, image: nil) + } + + _ = file.url.startAccessingSecurityScopedResource() + + let data = try Data(contentsOf: file.url) + + let encodedResult = adamantCore.encodeData( + data, + recipientPublicKey: recipientPublicKey, + privateKey: senderPrivateKey + ) + + guard let encodedData = encodedResult?.data, + let nonce = encodedResult?.nonce + else { + throw FileManagerError.cantEnctryptFile + } + + if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { + cacheImage(id: file.url.absoluteString, image: UIImage(data: encodedData)) + } + + let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) + + if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { + cacheImage(id: id, image: UIImage(data: data)) + } + + file.url.stopAccessingSecurityScopedResource() + return (id: id, nonce: nonce) + } + + public func downloadFile( + id: String, + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) async throws { + let encodedData = try await networkFileManager.downloadFile(id, type: storage) + + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) + else { + throw FileValidationError.fileNotFound + } + + if imageExtensions.contains(fileType?.uppercased() ?? defaultFileType) { + cacheImage(id: id, image: UIImage(data: decodedData)) + } else { + try cacheFile(id: id, data: encodedData) + } + } +} + +private extension FilesStorageKit { + func loadCache() throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + let files = getFiles(at: folder) + + files.forEach { url in + cachedFiles[url.lastPathComponent] = url + } + } + + func getFiles(at url: URL) -> [URL] { + let fileManager = FileManager.default + var isDirectory: ObjCBool = false + var subdirectoryNames: [URL] = [] + + guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + return subdirectoryNames + } + + for item in contents { + if fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) && !isDirectory.boolValue { + subdirectoryNames.append(item) + } + } + + return subdirectoryNames + } + + func cacheFile(id: String, data: Data) throws { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(id) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + cachedFiles[id] = fileURL + } + + func cacheImage(id: String, image: UIImage?) { + cachedImages[id] = image + } + + private func getPreview(for type: String) -> UIImage { + switch type.uppercased() { + case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD": + return UIImage.asset(named: "file-image-box")! + case "ZIP": + return UIImage.asset(named: "file-zip-box")! + case "PDF": + return UIImage.asset(named: "file-pdf-box")! + default: + return UIImage.asset(named: "file-default-box")! + } + } +} + +private let defaultFileType = "" +private let cachePath = "downloads" 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 + } +} From 2983a9c18ccb3f48c26086df0aaa7d46ffba94c7 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 7 Mar 2024 16:20:39 +0200 Subject: [PATCH 011/123] [trello.com/c/uxBZaznD] replace tableView with stackView --- Adamant.xcodeproj/project.pbxproj | 14 ++-- .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +- .../Content/ChatMediaContnentView.swift | 77 ++++++++----------- .../ChatFileView.swift} | 50 +++++++----- .../Chat/ViewModel/ChatMessageFactory.swift | 3 +- 5 files changed, 73 insertions(+), 75 deletions(-) rename Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/{Cell/ChatFileTableViewCell.swift => Views/ChatFileView.swift} (82%) diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 2e46e3939..ebca51148 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ 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 /* ChatFileTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7A2B85EABB00B54C61 /* ChatFileTableViewCell.swift */; }; + 3A299C7B2B85EABB00B54C61 /* ChatFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7A2B85EABB00B54C61 /* ChatFileView.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 */; }; @@ -676,7 +676,7 @@ 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 /* ChatFileTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileTableViewCell.swift; sourceTree = ""; }; + 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileView.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 = ""; }; @@ -1354,7 +1354,7 @@ 3A299C6E2B83901000B54C61 /* Content */ = { isa = PBXGroup; children = ( - 3A299C792B85EAA900B54C61 /* Cell */, + 3A299C792B85EAA900B54C61 /* Views */, 3A299C702B83975700B54C61 /* ChatMediaContnentView.swift */, 3A299C722B83975D00B54C61 /* ChatMediaContnentView+Model.swift */, ); @@ -1379,12 +1379,12 @@ path = FilesToolBarView; sourceTree = ""; }; - 3A299C792B85EAA900B54C61 /* Cell */ = { + 3A299C792B85EAA900B54C61 /* Views */ = { isa = PBXGroup; children = ( - 3A299C7A2B85EABB00B54C61 /* ChatFileTableViewCell.swift */, + 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, ); - path = Cell; + path = Views; sourceTree = ""; }; 3A41938D2A580C3B006A6B22 /* RichTransactionReactService */ = { @@ -3270,7 +3270,7 @@ E9E7CDB32002B9FB00DFC4DB /* LoginFactory.swift in Sources */, E941CCDE20E7B70200C96220 /* WalletCollectionViewCell.swift in Sources */, 4186B33A294200F4006594A3 /* DashWalletService+DynamicConstants.swift in Sources */, - 3A299C7B2B85EABB00B54C61 /* ChatFileTableViewCell.swift in Sources */, + 3A299C7B2B85EABB00B54C61 /* ChatFileView.swift in Sources */, 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */, E9AA8BFA212C166600F9249F /* EthWalletService+Send.swift in Sources */, 411743042A39B257008CD98A /* ContributeViewModel.swift in Sources */, diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 70246315d..394514ea8 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -18,6 +18,7 @@ struct ChatFile: Equatable, Hashable { var isCached: Bool var storage: String var nonce: String + var isFromCurrentSender: Bool static let `default` = Self( file: .init([:]), @@ -26,6 +27,7 @@ struct ChatFile: Equatable, Hashable { isUploading: false, isCached: false, storage: .empty, - nonce: .empty + nonce: .empty, + isFromCurrentSender: false ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index cf6808257..b0c3625b2 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -11,14 +11,6 @@ import UIKit import CommonKit final class ChatMediaContentView: UIView { - private lazy var tableView: UITableView = { - let tableView = UITableView() - tableView.register(ChatFileTableViewCell.self, forCellReuseIdentifier: "cell") - tableView.delegate = self - tableView.backgroundColor = .clear - return tableView - }() - private let commentLabel = UILabel( font: commentFont, textColor: .adamant.textColor, @@ -65,13 +57,24 @@ final class ChatMediaContentView: UIView { }() private lazy var verticalStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [replyView, commentLabel, tableView]) + let stack = UIStackView(arrangedSubviews: [replyView, commentLabel, filesStack]) stack.axis = .vertical - stack.spacing = verticalStackSpacing + stack.spacing = .zero return stack }() - private lazy var dataSource = TransactionsDiffableDataSource(tableView: tableView, cellProvider: makeCell) + private lazy var filesStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = verticalStackSpacing + + for _ in 0...5 { + let view = ChatFileView() + view.snp.makeConstraints { $0.height.equalTo(imageSize) } + stack.addArrangedSubview(view) + } + return stack + }() var model: Model = .default { didSet { @@ -115,43 +118,28 @@ private extension ChatMediaContentView { replyView.snp.updateConstraints { make in make.height.equalTo(replyViewDynamicHeight) } - - let list = model.files - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.zero]) - snapshot.appendItems(list) - snapshot.reconfigureItems(list) - dataSource.apply(snapshot, animatingDifferences: false) + + updateStackLayout() } - func makeCell( - tableView: UITableView, - indexPath: IndexPath, - fileModel: ChatFile - ) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! ChatFileTableViewCell - cell.model = fileModel - cell.backgroundView?.backgroundColor = .clear - cell.backgroundColor = .clear - cell.contentView.backgroundColor = .clear - cell.buttonActionHandler = { [actionHandler, fileModel, model] in - actionHandler( - .processFile( - file: fileModel, - isFromCurrentSender: model.isFromCurrentSender + func updateStackLayout() { + let fileList = model.files.prefix(5) + + filesStack.arrangedSubviews.forEach { $0.isHidden = true } + + for (index, file) in fileList.enumerated() { + let view = filesStack.arrangedSubviews[index] as? ChatFileView + view?.isHidden = false + view?.model = file + view?.buttonActionHandler = { [actionHandler, file, model] in + actionHandler( + .processFile( + file: file, + isFromCurrentSender: model.isFromCurrentSender + ) ) - ) + } } - return cell - } -} - -extension ChatMediaContentView: UITableViewDelegate { - func tableView( - _ tableView: UITableView, - heightForRowAt indexPath: IndexPath - ) -> CGFloat { - imageSize } } @@ -180,7 +168,6 @@ extension ChatMediaContentView.Model { let textStorage = NSTextStorage(attributedString: attributedText) textStorage.addLayoutManager(layoutManager) - let range = NSRange(location: 0, length: attributedText.length) let rect = layoutManager.usedRect(for: textContainer) return rect.integral.size diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift similarity index 82% rename from Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift rename to Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift index eeaa232ba..6062c76c6 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Cell/ChatFileTableViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift @@ -9,7 +9,7 @@ import UIKit import CommonKit -class ChatFileTableViewCell: UITableViewCell { +class ChatFileView: UIView { private lazy var iconImageView: UIImageView = UIImageView() private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) @@ -32,13 +32,13 @@ class ChatFileTableViewCell: UITableViewCell { }() private let nameLabel = UILabel(font: nameFont, textColor: .adamant.textColor) - private let sizeLabel = UILabel(font: sizeFont, textColor: .adamant.textColor) + private let sizeLabel = UILabel(font: sizeFont, textColor: .lightGray) private lazy var vStack: UIStackView = { let stack = UIStackView() stack.alignment = .leading stack.axis = .vertical - stack.spacing = stackSpacing + stack.spacing = verticalStackSpacing stack.backgroundColor = .clear stack.addArrangedSubview(nameLabel) @@ -61,11 +61,16 @@ class ChatFileTableViewCell: UITableViewCell { var buttonActionHandler: (() -> Void)? - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = nil - contentView.backgroundColor = nil + init(model: ChatFile) { + super.init(frame: .zero) + backgroundColor = .clear + configure() + self.model = model + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear configure() } @@ -85,21 +90,14 @@ class ChatFileTableViewCell: UITableViewCell { super.awakeFromNib() // Initialization code } - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - // Configure the view for the selected state - } - @objc func tapBtnAction() { buttonActionHandler?() } } -private extension ChatFileTableViewCell { +private extension ChatFileView { func configure() { - contentView.addSubview(horizontalStack) + addSubview(horizontalStack) horizontalStack.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } @@ -108,18 +106,18 @@ private extension ChatFileTableViewCell { make.size.equalTo(imageSize) } - contentView.addSubview(spinner) + addSubview(spinner) spinner.snp.makeConstraints { make in make.center.equalTo(iconImageView) } - contentView.addSubview(downloadImageView) + addSubview(downloadImageView) downloadImageView.snp.makeConstraints { make in make.center.equalTo(iconImageView) make.size.equalTo(imageSize / 1.3) } - contentView.addSubview(tapBtn) + addSubview(tapBtn) tapBtn.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } @@ -148,7 +146,16 @@ private extension ChatFileTableViewCell { nameLabel.text = fileName.contains(fileType) ? fileName : "\(fileName.uppercased()).\(fileType.uppercased())" - sizeLabel.text = "\(model.file.file_size) kb" + + sizeLabel.text = formatSize(model.file.file_size) + } + + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB] + formatter.countStyle = .file + + return formatter.string(fromByteCount: bytes) } } @@ -156,3 +163,4 @@ 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 diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 560ed262b..acc46a76e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -338,7 +338,8 @@ private extension ChatMessageFactory { isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), isCached: filesStorage.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), storage: storage, - nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty + nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, + isFromCurrentSender: isFromCurrentSender ) } From 759f4c99647cb2fa05ee239bdcf0286fb9b878c5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 7 Mar 2024 16:34:36 +0200 Subject: [PATCH 012/123] [trello.com/c/uxBZaznD] make file cell selectable --- .../Subviews/ChatMedia/ChatMediaCell.swift | 5 ++++- .../Content/ChatMediaContnentView.swift | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index 95124497e..e7fdfe11c 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -29,7 +29,10 @@ final class ChatMediaCell: MessageContentCell { override var isSelected: Bool { didSet { - //containerView.isSelected = isSelected + messageContainerView.animateIsSelected( + isSelected, + originalColor: messageContainerView.backgroundColor + ) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index b0c3625b2..da33c1c8c 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -36,6 +36,11 @@ final class ChatMediaContentView: UIView { view.layer.cornerRadius = 5 view.clipsToBounds = true + view.addGestureRecognizer(UITapGestureRecognizer( + target: self, + action: #selector(didTap) + )) + view.addSubview(colorView) view.addSubview(replyMessageLabel) @@ -141,6 +146,21 @@ private extension ChatMediaContentView { } } } + + @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 { From 48459bc29e744b3524a3c003c5cd39c2cedb7351 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 7 Mar 2024 17:02:39 +0200 Subject: [PATCH 013/123] [trello.com/c/uxBZaznD] fix: send reply file locally --- .../AdamantRichTransactionReplyService.swift | 2 +- CommonKit/Sources/CommonKit/Models/RichMessage.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift index d8d95d39c..8941a08fc 100644 --- a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift +++ b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift @@ -290,7 +290,7 @@ private extension AdamantRichTransactionReplyService { func getRawFilePresentation(_ richContent: [String: Any]) -> String { let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent - let files = richContent[RichContentKeys.file.files] as? [[String: Any]] ?? [] + let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty let comment = !rawComment.isEmpty diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index da1e2b64f..aae2c74b6 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -197,7 +197,7 @@ public struct RichFileReply: RichMessage { public func content() -> [String: Any] { return [ RichContentKeys.reply.replyToId: replyto_id, - RichContentKeys.reply.replyMessage: reply_message + RichContentKeys.reply.replyMessage: reply_message.content() ] } } From b57bf9a1e359b2543a7088a9b44591119c050e41 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 12:53:52 +0200 Subject: [PATCH 014/123] [trello.com/c/uxBZaznD] feat: open image & file --- .../Chat/ViewModel/ChatViewModel.swift | 13 ++ .../FilesStorageProtocol.swift | 7 + FilesNetworkManagerKit/Package.swift | 6 +- .../FilesPickerKit/FilesPickerKit.swift | 22 ++ .../Protocols/PinchZoomViewProtocol.swift | 16 ++ .../Views/ImageViewer/ImageViewer.swift | 189 ++++++++++++++++++ .../ImageViewer/ImageViewerViewModel.swift | 34 ++++ .../Views/OtherViewer/OtherViewer.swift | 134 +++++++++++++ .../OtherViewer/OtherViewerViewModel.swift | 29 +++ .../FilesPickerKit/Views/PinchZoomView.swift | 159 +++++++++++++++ .../FilesPickerKit/Views/ShareSheet.swift | 28 +++ .../FilesStorageKit/FilesStorageKit.swift | 42 +++- .../Models/FileValidationError.swift | 25 +++ 13 files changed, 696 insertions(+), 8 deletions(-) create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift create mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 039b4871d..3746ed92c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -776,6 +776,19 @@ final class ChatViewModel: NSObject { return } + + let data = try filesStorage.getFileData( + with: file.file.file_id, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + nonce: file.nonce + ) + + FilesPickerKit.shared.openFile( + data: data, + name: file.file.file_name ?? .empty, + size: file.file.file_size ?? .zero + ) } } diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 497a57399..521800072 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -16,6 +16,13 @@ protocol FilesStorageProtocol { func isCached(_ id: String) -> Bool + func getFileData( + with id: String, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) throws -> Data + func uploadFile( _ file: FileResult, recipientPublicKey: String, diff --git a/FilesNetworkManagerKit/Package.swift b/FilesNetworkManagerKit/Package.swift index a4a45d76c..d6b2d6f2a 100644 --- a/FilesNetworkManagerKit/Package.swift +++ b/FilesNetworkManagerKit/Package.swift @@ -15,14 +15,16 @@ let package = Package( targets: ["FilesNetworkManagerKit"]), ], dependencies: [ - .package(url: "https://github.com/uploadcare/uploadcare-swift.git", branch: "master") - ], + .package(path: "../CommonKit"), + .package(url: "https://github.com/uploadcare/uploadcare-swift.git", branch: "master") + ], 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: "FilesNetworkManagerKit", dependencies: [ + "CommonKit", .product(name: "Uploadcare", package: "uploadcare-swift") ] ), diff --git a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift index a48c0a303..e7e27ac76 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift @@ -3,6 +3,7 @@ import CommonKit import UIKit +import SwiftUI public final class FilesPickerKit { public static let shared = FilesPickerKit() @@ -46,9 +47,30 @@ public final class FilesPickerKit { } } } + + public func openFile(data: Data, name: String, size: Int64) { + guard let uiImage = UIImage(data: data) else { + let viewModel = OtherViewerViewModel(caption: name, size: size, data: data) + let view = OtherViewer(viewModel: viewModel) + present(view: view) + return + } + + let view = ImageViewer(image: uiImage, caption: name) + present(view: view) + } } private extension FilesPickerKit { + func present(view: some View) { + let vc = UIHostingController( + rootView: view + ) + vc.modalPresentationStyle = .overCurrentContext + vc.view.backgroundColor = .clear + UIApplication.shared.topViewController()?.present(vc, animated: false) + } + func validateFiles(_ files: [FileResult]) throws { guard files.count <= Constants.maxFilesCount else { throw FileValidationError.tooManyFiles diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift new file mode 100644 index 000000000..781b56b4d --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation +import SwiftUI + +protocol PinchZoomViewProtocol: AnyObject { + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift new file mode 100644 index 000000000..a409a1cae --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift @@ -0,0 +1,189 @@ +// +// ImageViewer.swift +// +// +// Created by Stanislav Jelezoglo on 11.03.2024. +// + +import Foundation +import SwiftUI +import CommonKit +import Combine + +public struct ImageViewer: View { + @StateObject private var viewModel: ImageViewerViewModel + @Environment(\.dismiss) private var dismiss + + public init(image: UIImage, caption: String? = nil) { + _viewModel = StateObject( + wrappedValue: ImageViewerViewModel(image: image, caption: caption) + ) + } + + public var body: some View { + VStack { + if viewModel.viewerShown { + ViewerContent(viewModel: viewModel, dismissAction: { + dismissAction() + }) + .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2))) + .onAppear { + resetDragOffset() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + animateViewer() + } + } + + private func animateViewer() { + Task { + await animate(duration: 0.25) { + viewModel.viewerShown.toggle() + } + } + } + + private func resetDragOffset() { + viewModel.dragOffset = .zero + viewModel.dragOffsetPredicted = .zero + } + + private func dismissAction() { + Task { + await animate(duration: 0.25) { + viewModel.viewerShown = false + } + + dismiss() + } + } +} + +private struct ViewerContent: View { + @ObservedObject var viewModel: ImageViewerViewModel + var dismissAction: () -> Void + + var body: some View { + ZStack { + ViewerControls(viewModel: viewModel, dismissAction: dismissAction) + + ImageContent(viewModel: viewModel, dismissAction: dismissAction) + } + .background(backgroundOpacity()) + } + + private func backgroundOpacity() -> Color { + Color( + red: 0.12, + green: 0.12, + blue: 0.12, + opacity: (1.0 - Double(abs(viewModel.dragOffset.width) + abs(viewModel.dragOffset.height)) / 1000) + ) + } +} + +private struct ViewerControls: View { + @ObservedObject var viewModel: ImageViewerViewModel + + var dismissAction: () -> Void + + @State private var isShareSheetPresented = false + + var body: some View { + VStack { + HStack { + if let caption = viewModel.caption { + Text(caption) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .padding() + } + Spacer() + CloseButton(dismissAction: dismissAction, color: .white) + .padding() + } + .background(Color.black.opacity(0.5)) + + Spacer() + + HStack { + Spacer() + + Button { + isShareSheetPresented.toggle() + } label: { + Image(systemName: "square.and.arrow.up") + .resizable() + .frame(width: 22, height: 30) + .tint(.white) + } + .padding() + .sheet(isPresented: $isShareSheetPresented) { + ShareSheet(activityItems: [viewModel.uiImage]) + } + + Spacer() + } + .background(Color.black.opacity(0.5)) + } + .zIndex(2) + } +} + +struct CloseButton: View { + var dismissAction: () -> Void + let color: Color + + var body: some View { + Button(action: dismissAction) { + Image(systemName: "xmark") + .foregroundColor(color) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 24))) + } + } +} + +private struct ImageContent: View { + @ObservedObject var viewModel: ImageViewerViewModel + var dismissAction: () -> Void + + var body: some View { + VStack { + viewModel.image + .resizable() + .aspectRatio(contentMode: .fit) + .offset(x: viewModel.dragOffset.width, y: viewModel.dragOffset.height) + .rotationEffect(.init(degrees: Double(viewModel.dragOffset.width / 30))) + .pinchToZoom() + .gesture(dragGesture()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func dragGesture() -> some Gesture { + DragGesture() + .onChanged { value in + viewModel.dragOffset = value.translation + viewModel.dragOffsetPredicted = value.predictedEndTranslation + } + .onEnded { _ in + handleDragEnd() + } + } + + private func handleDragEnd() { + if viewModel.shouldDismissViewer() { + withAnimation(.spring()) { + viewModel.dragOffset = viewModel.dragOffsetPredicted + } + dismissAction() + } else { + withAnimation(.interactiveSpring()) { + viewModel.dragOffset = .zero + } + } + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift new file mode 100644 index 000000000..78edc7404 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift @@ -0,0 +1,34 @@ +// +// ImageViewerViewModel.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation +import SwiftUI +import CommonKit + +final class ImageViewerViewModel: ObservableObject { + @Published var viewerShown: Bool = false + @Published var image: Image + @Published var uiImage: UIImage + @Published var caption: String? + @Published var dragOffset: CGSize = CGSize.zero + @Published var dragOffsetPredicted: CGSize = CGSize.zero + + var dismissAction: (() -> Void)? + let presentSendTokensVC = ObservableSender() + + init(image: UIImage, caption: String? = nil) { + self.uiImage = image + self.image = .init(uiImage: image) + self.caption = caption + } + + func shouldDismissViewer() -> Bool { + (abs(dragOffset.height) + abs(dragOffset.width) > 570) || + ((abs(dragOffsetPredicted.height)) / (abs(dragOffset.height)) > 3) || + ((abs(dragOffsetPredicted.width)) / (abs(dragOffset.width))) > 3 + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift new file mode 100644 index 000000000..2d067381b --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift @@ -0,0 +1,134 @@ +// +// OtherViewer.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation +import SwiftUI +import CommonKit + +struct OtherViewer: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: OtherViewerViewModel + + init(viewModel: OtherViewerViewModel) { + _viewModel = StateObject( + wrappedValue: viewModel + ) + } + + public var body: some View { + VStack { + if viewModel.viewerShown { + ViewerContent(viewModel: viewModel, dismissAction: { + dismissAction() + }) + .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + animateViewer() + } + } + + private func animateViewer() { + Task { + await animate(duration: 0.25) { + viewModel.viewerShown.toggle() + } + } + } + + private func dismissAction() { + Task { + await animate(duration: 0.25) { + viewModel.viewerShown = false + } + + dismiss() + } + } +} + +private struct ViewerContent: View { + @ObservedObject var viewModel: OtherViewerViewModel + + var dismissAction: () -> Void + + @State private var isShareSheetPresented = false + + var body: some View { + VStack { + HStack { + if let caption = viewModel.caption { + Text(caption) + .foregroundColor(.black) + .multilineTextAlignment(.leading) + .padding() + } + Spacer() + CloseButton(dismissAction: dismissAction, color: .black) + .padding() + } + .background(Color(UIColor.tertiarySystemGroupedBackground)) + + Content(viewModel: viewModel) + + HStack { + Spacer() + + Button { + isShareSheetPresented.toggle() + } label: { + Image(systemName: "square.and.arrow.up") + .resizable() + .frame(width: 22, height: 30) + .tint(Color(UIColor.adamant.active)) + } + .padding(EdgeInsets(top: 5, leading: .zero, bottom: 5, trailing: .zero)) + .sheet(isPresented: $isShareSheetPresented) { + ShareSheet(activityItems: [viewModel.data]) + } + + Spacer() + } + .background(Color(UIColor.tertiarySystemGroupedBackground)) + } + .background(Color.white) + } +} + +private struct Content: View { + @ObservedObject var viewModel: OtherViewerViewModel + + var body: some View { + VStack { + Image(uiImage: image) + .resizable() + .frame(width: 80, height: 90) + + if let caption = viewModel.caption { + Text(caption) + .font(.headline) + .foregroundColor(.black) + .multilineTextAlignment(.center) + .padding() + } + + if let size = viewModel.size { + Text(viewModel.formatSize(size)) + .font(.subheadline) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + } +} + +private let image: UIImage = UIImage.asset(named: "file-default-box")! diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift new file mode 100644 index 000000000..1cc481eb4 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift @@ -0,0 +1,29 @@ +// +// OtherViewerViewModel.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation + +final class OtherViewerViewModel: ObservableObject { + @Published var viewerShown: Bool = false + @Published var caption: String? + @Published var size: Int64? + @Published var data: Data + + init(caption: String?, size: Int64?, data: Data) { + self.caption = caption + self.size = size + self.data = data + } + + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB] + formatter.countStyle = .file + + return formatter.string(fromByteCount: bytes) + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift new file mode 100644 index 000000000..d2f662dff --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift @@ -0,0 +1,159 @@ +// +// File.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation +import UIKit +import SwiftUI + +class PinchZoomView: UIView { + weak var delegate: PinchZoomViewProtocol? + + private(set) var scale: CGFloat = 0 { + didSet { + delegate?.pinchZoomView(self, didChangeScale: scale) + } + } + + private(set) var anchor: UnitPoint = .center { + didSet { + delegate?.pinchZoomView(self, didChangeAnchor: anchor) + } + } + + private(set) var offset: CGSize = .zero { + didSet { + delegate?.pinchZoomView(self, didChangeOffset: offset) + } + } + + private(set) var isPinching: Bool = false { + didSet { + delegate?.pinchZoomView(self, didChangePinching: isPinching) + } + } + + private var startLocation: CGPoint = .zero + private var location: CGPoint = .zero + private var numberOfTouches: Int = 0 + + init() { + super.init(frame: .zero) + + let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:))) + pinchGesture.cancelsTouchesInView = false + addGestureRecognizer(pinchGesture) + } + + required init?(coder: NSCoder) { + fatalError() + } + + @objc private func pinch(gesture: UIPinchGestureRecognizer) { + switch gesture.state { + case .began: + isPinching = true + startLocation = gesture.location(in: self) + anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height) + numberOfTouches = gesture.numberOfTouches + + case .changed: + if gesture.numberOfTouches != numberOfTouches { + // If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping. + let newLocation = gesture.location(in: self) + let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y) + startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height) + + numberOfTouches = gesture.numberOfTouches + } + + scale = gesture.scale + + location = gesture.location(in: self) + offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y) + + case .ended, .cancelled, .failed: + withAnimation(.interactiveSpring()) { + isPinching = false + scale = 1.0 + anchor = .center + offset = .zero + } + default: + break + } + } +} + +struct PinchZoom: UIViewRepresentable { + @Binding var scale: CGFloat + @Binding var anchor: UnitPoint + @Binding var offset: CGSize + @Binding var isPinching: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> PinchZoomView { + let pinchZoomView = PinchZoomView() + pinchZoomView.delegate = context.coordinator + return pinchZoomView + } + + func updateUIView(_ pageControl: PinchZoomView, context: Context) { } + + class Coordinator: NSObject, PinchZoomViewProtocol { + var pinchZoom: PinchZoom + + init(_ pinchZoom: PinchZoom) { + self.pinchZoom = pinchZoom + } + + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) { + pinchZoom.isPinching = isPinching + } + + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) { + pinchZoom.scale = scale + } + + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) { + pinchZoom.anchor = anchor + } + + func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) { + pinchZoom.offset = offset + } + } +} + +struct PinchToZoom: ViewModifier { + @State var scale: CGFloat = 1.0 + @State var anchor: UnitPoint = .center + @State var offset: CGSize = .zero + @State var isPinching: Bool = false + + func body(content: Content) -> some View { + content + .scaleEffect(scale, anchor: anchor) + .offset(offset) + .overlay( + PinchZoom( + scale: $scale, + anchor: $anchor, + offset: $offset, + isPinching: $isPinching + ) + ) + } +} + +extension View { + func pinchToZoom() -> some View { + self.modifier(PinchToZoom()) + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift new file mode 100644 index 000000000..b6ed82025 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift @@ -0,0 +1,28 @@ +// +// ShareSheet.swift +// +// +// Created by Stanislav Jelezoglo on 12.03.2024. +// + +import Foundation +import SwiftUI + +struct ShareSheet: View { + let activityItems: [Any] + + var body: some View { + ActivityView(activityItems: activityItems) + } +} + +struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 948dfe3f4..91114f08b 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -4,7 +4,6 @@ import CommonKit import UIKit import FilesNetworkManagerKit -import FilesPickerKit public final class FilesStorageKit { private let adamantCore = NativeAdamantCore() @@ -30,6 +29,34 @@ public final class FilesStorageKit { cachedImages[id] != nil || cachedFiles[id] != nil } + public func getFileData( + with id: String, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) throws -> Data { + if let image = cachedImages[id], + let data = image.jpegData(compressionQuality: 1.0) { + return data + } + + if let url = cachedFiles[id], + let encodedData = try? Data(contentsOf: url) { + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) else { + throw FileValidationError.fileNotFound + } + + return decodedData + } + + throw FileValidationError.fileNotFound + } + public func uploadFile( _ file: FileResult, recipientPublicKey: String, @@ -79,6 +106,12 @@ public final class FilesStorageKit { ) async throws { let encodedData = try await networkFileManager.downloadFile(id, type: storage) + let fileExtension = fileType?.uppercased() ?? defaultFileType + + guard imageExtensions.contains(fileExtension) else { + return try cacheFile(id: id, data: encodedData) + } + guard let decodedData = adamantCore.decodeData( encodedData, rawNonce: nonce, @@ -89,11 +122,8 @@ public final class FilesStorageKit { throw FileValidationError.fileNotFound } - if imageExtensions.contains(fileType?.uppercased() ?? defaultFileType) { - cacheImage(id: id, image: UIImage(data: decodedData)) - } else { - try cacheFile(id: id, data: encodedData) - } + cacheImage(id: id, image: UIImage(data: decodedData)) + return } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift new file mode 100644 index 000000000..afa230ba4 --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift @@ -0,0 +1,25 @@ +// +// FileValidationError.swift +// +// +// Created by Stanislav Jelezoglo on 13.03.2024. +// + +import Foundation + +public enum FileValidationError: Error, LocalizedError { + case tooManyFiles + case fileSizeExceedsLimit + case fileNotFound + + public var errorDescription: String { + switch self { + case .tooManyFiles: + return "too Many Files" + case .fileSizeExceedsLimit: + return "file Size Exceeds Limit" + case .fileNotFound: + return "file Not Found" + } + } +} From eaa1af281f35c75f78d80f1c72abc29a756b8602 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 13:25:37 +0200 Subject: [PATCH 015/123] [trello.com/c/uxBZaznD] fix: localizations & limits --- .../FilesNetworkManagerKit/Models/FileManagerError.swift | 8 +++++--- .../Sources/FilesPickerKit/Models/Constants.swift | 4 ++-- .../FilesPickerKit/Models/FileValidationError.swift | 8 +++++--- .../FilesStorageKit/Models/FileValidationError.swift | 8 +++++--- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift index 9b2a040ef..a1407a495 100644 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift +++ b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift @@ -7,12 +7,14 @@ import Foundation -public enum FileManagerError: Error, LocalizedError { +public enum FileManagerError: Error { case cantDownloadFile case cantUploadFile case cantEnctryptFile - - public var errorDescription: String { +} + +extension FileManagerError: LocalizedError { + public var errorDescription: String? { switch self { case .cantDownloadFile: return "cant Download File" diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index c76aacaa6..66b5b9ab2 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -9,8 +9,8 @@ import Foundation import UIKit final class Constants { - static let maxFilesCount = 15 - static let maxFileSize: Int64 = 800 * 1024 * 1024 + static let maxFilesCount = 5 + static let maxFileSize: Int64 = 10 * 1024 * 1024 } extension UIApplication { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift index 530c58204..122951522 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift @@ -7,12 +7,14 @@ import Foundation -public enum FileValidationError: Error, LocalizedError { +public enum FileValidationError: Error { case tooManyFiles case fileSizeExceedsLimit case fileNotFound - - public var errorDescription: String { +} + +extension FileValidationError: LocalizedError { + public var errorDescription: String? { switch self { case .tooManyFiles: return "too Many Files" diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift index afa230ba4..5870b1162 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift @@ -7,12 +7,14 @@ import Foundation -public enum FileValidationError: Error, LocalizedError { +public enum FileValidationError: Error { case tooManyFiles case fileSizeExceedsLimit case fileNotFound - - public var errorDescription: String { +} + +extension FileValidationError: LocalizedError { + public var errorDescription: String? { switch self { case .tooManyFiles: return "too Many Files" From 1318a92412ce5239110ab97d586fd96de64713e8 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 15:26:03 +0200 Subject: [PATCH 016/123] [trello.com/c/uxBZaznD] feat: short description for chat list & notification --- .../Content/ChatMediaContnentView.swift | 16 +++- .../ChatsList/ChatListViewController.swift | 96 +++++++++++++------ .../FilesPickerKit/FilesPickerKit.swift | 4 +- .../FilesPickerKit/Models/Constants.swift | 4 +- .../Pickers/MediaPickerService.swift | 2 +- .../NotificationService.swift | 44 +++++++++ 6 files changed, 127 insertions(+), 39 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index da33c1c8c..83434b3ae 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -9,6 +9,7 @@ import SnapKit import UIKit import CommonKit +import FilesPickerKit final class ChatMediaContentView: UIView { private let commentLabel = UILabel( @@ -73,7 +74,7 @@ final class ChatMediaContentView: UIView { stack.axis = .vertical stack.spacing = verticalStackSpacing - for _ in 0...5 { + for _ in 0...FilesConstants.maxFilesCount { let view = ChatFileView() view.snp.makeConstraints { $0.height.equalTo(imageSize) } stack.addArrangedSubview(view) @@ -128,7 +129,7 @@ private extension ChatMediaContentView { } func updateStackLayout() { - let fileList = model.files.prefix(5) + let fileList = model.files.prefix(FilesConstants.maxFilesCount) filesStack.arrangedSubviews.forEach { $0.isHidden = true } @@ -166,9 +167,16 @@ private extension ChatMediaContentView { extension ChatMediaContentView.Model { func height() -> CGFloat { let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : 0 - let stackSpacingCount: CGFloat = isReply ? 4 : 3 - return imageSize * CGFloat(files.count) + let filesCount = files.count > FilesConstants.maxFilesCount + ? FilesConstants.maxFilesCount + : files.count + + let stackSpacingCount: CGFloat = isReply + ? CGFloat(filesCount) + 1 + : CGFloat(filesCount) + + return imageSize * CGFloat(filesCount) + stackSpacingCount * verticalStackSpacing + labelSize(for: comment, considering: 260).height + replyViewDynamicHeight diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 21c093ec2..aaccd8e1c 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -944,36 +944,7 @@ extension ChatListViewController { if richMessage.additionalType == .reply, let content = richMessage.richContent, let text = content[RichContentKeys.reply.replyMessage] as? String { - - 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() - - let fullString = NSMutableAttributedString(string: prefix) - if richMessage.isOutgoing { - fullString.append(imageString) - } - fullString.append(markDownText) - - return fullString + return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: text) } if richMessage.additionalType == .reaction, @@ -990,6 +961,26 @@ extension ChatListViewController { return text } + if richMessage.additionalType == .reply, + let content = richMessage.richContent, + richMessage.isFileReply() { + let text = getRawFilePresentation(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 = getRawFilePresentation(content) + + let attributesText = markdownParser.parse(prefix + fileText).resolveLinkColor() + + return attributesText + } + if let serialized = richMessage.serializedMessage() { return NSAttributedString(string: serialized) } @@ -1011,6 +1002,51 @@ extension ChatListViewController { return nil } } + + private func getRawFilePresentation(_ richContent: [String: Any]) -> String { + let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent + + let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] + + let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty + let comment = !rawComment.isEmpty + ? ": \(rawComment)" + : "" + + return "[\(files.count) file(s)]\(comment)" + } + + 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 fullString + } } // MARK: - Swipe actions diff --git a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift index e7e27ac76..f4f299d1e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift @@ -72,12 +72,12 @@ private extension FilesPickerKit { } func validateFiles(_ files: [FileResult]) throws { - guard files.count <= Constants.maxFilesCount else { + guard files.count <= FilesConstants.maxFilesCount else { throw FileValidationError.tooManyFiles } for file in files { - guard file.size <= Constants.maxFileSize else { + guard file.size <= FilesConstants.maxFileSize else { throw FileValidationError.fileSizeExceedsLimit } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 66b5b9ab2..7b3915c06 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -8,8 +8,8 @@ import Foundation import UIKit -final class Constants { - static let maxFilesCount = 5 +public final class FilesConstants { + public static let maxFilesCount = 5 static let maxFileSize: Int64 = 10 * 1024 * 1024 } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 28142b414..47d7cc737 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -17,7 +17,7 @@ final class MediaPickerService: NSObject, FilePickerProtocol { onPreparedDataCallback = completion var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) - phPickerConfig.selectionLimit = Constants.maxFilesCount + phPickerConfig.selectionLimit = FilesConstants.maxFilesCount phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) let phPickerVC = PHPickerViewController(configuration: phPickerConfig) diff --git a/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift index 2e0fbe6e8..87e80328e 100644 --- a/NotificationServiceExtension/NotificationService.swift +++ b/NotificationServiceExtension/NotificationService.swift @@ -233,6 +233,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 = getRawFilePresentation(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 = getRawFilePresentation(richContent) + content = NotificationContent( + title: partnerName ?? partnerAddress, + subtitle: nil, + body: MarkdownParser().parse(text).string, + attachments: nil, + categoryIdentifier: AdamantNotificationCategories.message + ) + } + guard let content = content else { break } @@ -281,6 +312,19 @@ class NotificationService: UNNotificationServiceExtension { } } + private func getRawFilePresentation(_ richContent: [String: Any]) -> String { + let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent + + let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] + + let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty + let comment = !rawComment.isEmpty + ? ": \(rawComment)" + : "" + + return "[\(files.count) file(s)]\(comment)" + } + private func handleAdamantTransfer( notificationContent: UNMutableNotificationContent, partnerAddress address: String, From 00055bede63d359f3415ff2009b12348432e2f64 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 15:34:25 +0200 Subject: [PATCH 017/123] [trello.com/c/uxBZaznD] fix: dismiss preview file & reply view --- Adamant/Modules/Chat/View/ChatViewController.swift | 2 -- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 8 +++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 799934fb2..663861aa3 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -91,8 +91,6 @@ final class ChatViewController: MessagesViewController { 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() } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 3746ed92c..cf881bfaa 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -296,6 +296,11 @@ final class ChatViewModel: NSObject { return } + let replyMessage = replyMessage + + self.filesPicked = nil + self.replyMessage = nil + Task { var richFiles: [RichMessageFile.File] = files.compactMap { RichMessageFile.File.init( @@ -394,9 +399,6 @@ final class ChatViewModel: NSObject { ) } - replyMessage = nil - filesPicked = nil - _ = try await chatsProvider.sendFileMessage( message, recipientId: partnerAddress, From 9e2610df29cdc367947b6317ae65392119685de5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 15:56:51 +0200 Subject: [PATCH 018/123] [trello.com/c/uxBZaznD] feat: send file without text --- Adamant/Modules/Chat/View/ChatViewController.swift | 3 +++ Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift | 10 +++++++++- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 663861aa3..705a0493b 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -695,10 +695,13 @@ private extension ChatViewController { 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, diff --git a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift index 16e7413b9..3e560dd7a 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift @@ -22,6 +22,10 @@ final class ChatInputBar: InputBarAccessoryView { didSet { updateIsEnabled() } } + var isForcedSendEnabled = false { + didSet { updateSendIsEnabled() } + } + var isAttachmentButtonEnabled = true { didSet { updateIsAttachmentButtonEnabled() } } @@ -51,7 +55,7 @@ final class ChatInputBar: InputBarAccessoryView { override func didMoveToWindow() { super.didMoveToWindow() - sendButton.isEnabled = !inputTextView.text.isEmpty + sendButton.isEnabled = (isEnabled && !inputTextView.text.isEmpty) || isForcedSendEnabled } } @@ -78,6 +82,10 @@ private extension ChatInputBar { updateIsAttachmentButtonEnabled() } + func updateSendIsEnabled() { + sendButton.isEnabled = (isEnabled && !inputTextView.text.isEmpty) || isForcedSendEnabled + } + func updateIsAttachmentButtonEnabled() { let isEnabled = isEnabled && isAttachmentButtonEnabled diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index cf881bfaa..f2b7e1a17 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -285,7 +285,7 @@ final class ChatViewModel: NSObject { }.stored(in: tasksStorage) } - func sendFile(text: String) { + func sendFile(text: String?) { guard let partnerAddress = chatroom?.partner?.address, let files = filesPicked, let keyPair = accountService.keypair @@ -407,7 +407,7 @@ final class ChatViewModel: NSObject { from: chatroom ) } catch { - await handleMessageSendingError(error: error, sentText: text) + await handleMessageSendingError(error: error, sentText: text ?? .empty) } }.stored(in: tasksStorage) } @@ -789,7 +789,7 @@ final class ChatViewModel: NSObject { FilesPickerKit.shared.openFile( data: data, name: file.file.file_name ?? .empty, - size: file.file.file_size ?? .zero + size: file.file.file_size ) } } From 66cd74635303de8397c26ea7c082c2444834260d Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 13 Mar 2024 15:59:54 +0200 Subject: [PATCH 019/123] [trello.com/c/uxBZaznD] fix: clear picked files if needed --- Adamant/Modules/Chat/View/ChatViewController.swift | 1 + Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 705a0493b..8f4685eb8 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -379,6 +379,7 @@ private extension ChatViewController { sendTransaction(self, self.viewModel.replyMessage?.id) self.viewModel.clearReplyMessage() + self.viewModel.clearPickedFiles() } .store(in: &subscriptions) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index f2b7e1a17..c4d17c38a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -680,6 +680,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) From 6e3f3582544388d69a5271c347be0582a214c6e0 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 14 Mar 2024 18:12:27 +0200 Subject: [PATCH 020/123] [trello.com/c/uxBZaznD] feat: cache images like file & new file preview for iOS --- Adamant.xcodeproj/project.pbxproj | 4 - Adamant/Helpers/UITextField+adamant.swift | 1 + .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +- .../Chat/View/Managers/ChatMenuManager.swift | 16 +--- .../Content/Views/ChatFileView.swift | 8 +- .../Chat/ViewModel/ChatMessageFactory.swift | 2 +- .../Chat/ViewModel/ChatViewModel.swift | 18 ++-- .../FilesStorageProtocol.swift | 9 +- CommonKit/Package.swift | 5 ++ .../file-default-box.png} | Bin .../249.jpg => File-icons/file-image-box.jpg} | Bin .../file-pdf-box.jpg} | Bin .../file-default-box.imageset/Contents.json | 2 +- .../file-default-box.png | Bin 0 -> 929 bytes .../file-image-box.imageset/Contents.json | 21 ----- .../files/file-jpg-box.imageset/Contents.json | 21 ----- .../file-jpg-box.imageset/file-jpg-box.png | Bin 4131 -> 0 bytes .../files/file-pdf-box.imageset/Contents.json | 21 ----- .../files/file-zip-box.imageset/Contents.json | 21 ----- .../files/file-zip-box.imageset/zip-128.jpg | Bin 7021 -> 0 bytes .../CommonKit}/Helpers/MacOSDeterminer.swift | 7 +- .../CommonKit/Helpers/UIImage+adamant.swift | 4 + .../FilesPickerKit/FilesPickerKit.swift | 40 ++++----- .../Pickers/DocumentInteractionService.swift | 63 ++++++++++++++ .../DocumentInteractionProtocol.swift | 12 +++ .../Views/ImageViewer/ImageViewer.swift | 2 +- .../Views/OtherViewer/OtherViewer.swift | 41 +++++---- .../OtherViewer/OtherViewerViewModel.swift | 56 +++++++++++- .../FilesPickerKit/Views/ShareSheet.swift | 5 +- .../FilesStorageKit/FilesStorageKit.swift | 81 ++++-------------- .../AdvancedContextMenuManager.swift | 14 +-- 31 files changed, 228 insertions(+), 250 deletions(-) rename CommonKit/Sources/CommonKit/Assets/{Shared.xcassets/files/file-default-box.imageset/default.png => File-icons/file-default-box.png} (100%) rename CommonKit/Sources/CommonKit/Assets/{Shared.xcassets/files/file-image-box.imageset/249.jpg => File-icons/file-image-box.jpg} (100%) rename CommonKit/Sources/CommonKit/Assets/{Shared.xcassets/files/file-pdf-box.imageset/pdf-74.jpg => File-icons/file-pdf-box.jpg} (100%) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/file-default-box.png delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-jpg-box.imageset/Contents.json delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-jpg-box.imageset/file-jpg-box.png delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/Contents.json delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/Contents.json delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-zip-box.imageset/zip-128.jpg rename {Adamant => CommonKit/Sources/CommonKit}/Helpers/MacOSDeterminer.swift (55%) create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index ebca51148..fb578bdb3 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -95,7 +95,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 */; }; @@ -748,7 +747,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 = ""; }; @@ -2137,7 +2135,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 */, @@ -3469,7 +3466,6 @@ 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 */, 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/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 394514ea8..ee5d3b433 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -12,7 +12,7 @@ import UIKit struct ChatFile: Equatable, Hashable { var file: RichMessageFile.File - var previewData: UIImage? + var previewDataURL: URL? var isDownloading: Bool var isUploading: Bool var isCached: Bool @@ -22,7 +22,7 @@ struct ChatFile: Equatable, Hashable { static let `default` = Self( file: .init([:]), - previewData: nil, + previewDataURL: nil, isDownloading: false, isUploading: false, isCached: false, diff --git a/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift b/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift index ded9d636a..1962c1b47 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatMenuManager.swift @@ -26,18 +26,6 @@ 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?) { @@ -45,7 +33,7 @@ final class ChatMenuManager: NSObject { } func setup(for contentView: UIView ) { - guard !isiOSAppOnMac else { + 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/Subviews/ChatMedia/Content/Views/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift index 6062c76c6..48d67bd43 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift @@ -131,7 +131,12 @@ private extension ChatFileView { } func update() { - iconImageView.image = model.previewData + if let url = model.previewDataURL { + iconImageView.image = UIImage(contentsOfFile: url.path) + } else { + iconImageView.image = defaultImage + } + downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading if model.isDownloading || model.isUploading { @@ -164,3 +169,4 @@ 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: "file-default-box") diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index acc46a76e..58fd0f3f2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -330,7 +330,7 @@ private extension ChatMessageFactory { let chatFiles = files.map { ChatFile.init( file: RichMessageFile.File.init($0), - previewData: filesStorage.getPreview( + previewDataURL: filesStorage.getPreview( for: $0[RichContentKeys.file.file_id] as? String ?? .empty, type: $0[RichContentKeys.file.file_type] as? String ?? .empty ), diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index c4d17c38a..4a11af6b0 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -783,17 +783,13 @@ final class ChatViewModel: NSObject { return } - let data = try filesStorage.getFileData( - with: file.file.file_id, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: file.nonce - ) + let data = try filesStorage.getFileURL(with: file.file.file_id) FilesPickerKit.shared.openFile( - data: data, + url: data, name: file.file.file_name ?? .empty, - size: file.file.file_size + size: file.file.file_size, + ext: file.file.file_type ?? .empty ) } } @@ -1187,7 +1183,7 @@ private extension ChatViewModel { _ messages: inout [ChatMessage], id oldId: String, newId: String? = nil, - preview: UIImage?, + preview: URL?, cached: Bool ) { messages.indices.forEach { index in @@ -1276,7 +1272,7 @@ private extension ChatMessage { mutating func updateFields( id oldId: String, newId: String? = nil, - preview: UIImage?, + preview: URL?, cached: Bool ) { guard case let .file(fileModel) = content else { return } @@ -1289,7 +1285,7 @@ private extension ChatMessage { if let newId = newId { model.content.files[index].file.file_id = newId } - model.content.files[index].previewData = preview + model.content.files[index].previewDataURL = preview model.content.files[index].isCached = cached content = .file(.init(value: model)) diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 521800072..b419f637d 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -12,16 +12,11 @@ import CommonKit import FilesStorageKit protocol FilesStorageProtocol { - func getPreview(for id: String, type: String) -> UIImage + func getPreview(for id: String, type: String) -> URL? func isCached(_ id: String) -> Bool - func getFileData( - with id: String, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String - ) throws -> Data + func getFileURL(with id: String) throws -> URL func uploadFile( _ file: FileResult, diff --git a/CommonKit/Package.swift b/CommonKit/Package.swift index 54fb7ce47..035f4853e 100644 --- a/CommonKit/Package.swift +++ b/CommonKit/Package.swift @@ -60,6 +60,11 @@ let package = Package( "MarkdownKit", "KeychainAccess", "RNCryptor" + ], + resources: [ + .process("Assets/File-icons/file-default-box.png"), + .process("Assets/File-icons/file-image-box.jpg"), + .process("Assets/File-icons/file-pdf-box.jpg") ] ), .testTarget( diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/default.png b/CommonKit/Sources/CommonKit/Assets/File-icons/file-default-box.png similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/default.png rename to CommonKit/Sources/CommonKit/Assets/File-icons/file-default-box.png diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/249.jpg b/CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/249.jpg rename to CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/pdf-74.jpg b/CommonKit/Sources/CommonKit/Assets/File-icons/file-pdf-box.jpg similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-pdf-box.imageset/pdf-74.jpg rename to CommonKit/Sources/CommonKit/Assets/File-icons/file-pdf-box.jpg diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json index 343d59471..4b509890e 100644 --- a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "default.png", + "filename" : "file-default-box.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/file-default-box.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/file-default-box.png new file mode 100644 index 0000000000000000000000000000000000000000..8d219f70193c0b82ce2722b51d0a58fac926a37f GIT binary patch literal 929 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 URL? { + Bundle.module.url(forResource: name, withExtension: ext) +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift index f4f299d1e..3af08c62b 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift @@ -5,15 +5,17 @@ import CommonKit import UIKit import SwiftUI -public final class FilesPickerKit { +public final class FilesPickerKit: NSObject { public static let shared = FilesPickerKit() private let mediaPicker: FilePickerProtocol private let documentPicker: FilePickerProtocol + private let documentInteration: DocumentInteractionProtocol - public init() { + public override init() { mediaPicker = MediaPickerService() documentPicker = DocumentPickerService() + documentInteration = DocumentInteractionService() } @MainActor @@ -47,30 +49,28 @@ public final class FilesPickerKit { } } } - - public func openFile(data: Data, name: String, size: Int64) { - guard let uiImage = UIImage(data: data) else { - let viewModel = OtherViewerViewModel(caption: name, size: size, data: data) - let view = OtherViewer(viewModel: viewModel) - present(view: view) - return + + public func openFile(url: URL, name: String, size: Int64, ext: String) { + let fullName = name.contains(ext) + ? name + : "\(name).\(ext)" + + var copyURL = URL(fileURLWithPath: url.deletingLastPathComponent().path) + copyURL.appendPathComponent(fullName) + + if FileManager.default.fileExists(atPath: copyURL.path) { + try? FileManager.default.removeItem(at: copyURL) } - let view = ImageViewer(image: uiImage, caption: name) - present(view: view) + try? FileManager.default.copyItem(at: url, to: copyURL) + + documentInteration.open(url: copyURL, name: fullName) { [copyURL] in + try? FileManager.default.removeItem(at: copyURL) + } } } private extension FilesPickerKit { - func present(view: some View) { - let vc = UIHostingController( - rootView: view - ) - vc.modalPresentationStyle = .overCurrentContext - vc.view.backgroundColor = .clear - UIApplication.shared.topViewController()?.present(vc, animated: false) - } - func validateFiles(_ files: [FileResult]) throws { guard files.count <= FilesConstants.maxFilesCount else { throw FileValidationError.tooManyFiles diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift new file mode 100644 index 000000000..4860cb083 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -0,0 +1,63 @@ +// +// DocumentInteractionService.swift +// +// +// Created by Stanislav Jelezoglo on 14.03.2024. +// + +import Foundation +import UIKit +import CommonKit +import SwiftUI +import WebKit + +final class DocumentInteractionService: NSObject, DocumentInteractionProtocol { + private var documentInteractionController: UIDocumentInteractionController? + private var completion: (() -> Void)? + + func open(url: URL, name: String, completion: (() -> Void)?) { + self.completion = completion + + documentInteractionController = UIDocumentInteractionController(url: url) + documentInteractionController?.delegate = self + + guard isMacOS else { + documentInteractionController?.presentPreview(animated: true) + return + } + + let vc = UIApplication.shared.topViewController()! + + guard let uiImage = UIImage(contentsOfFile: url.path) else { + documentInteractionController?.presentOpenInMenu(from: vc.view.frame, in: vc.view, animated: true) + return + } + + let view = ImageViewer(image: uiImage, caption: name) + present(view: view) + + documentInteractionController = nil + } +} + +private extension DocumentInteractionService { + func present(view: some View) { + let vc = UIHostingController( + rootView: view + ) + vc.modalPresentationStyle = .overCurrentContext + vc.view.backgroundColor = .clear + UIApplication.shared.topViewController()?.present(vc, animated: false) + } +} + +extension DocumentInteractionService: UIDocumentInteractionControllerDelegate { + public func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { + return UIApplication.shared.topViewController()! + } + + public func documentInteractionControllerDidEndPreview(_ controller: UIDocumentInteractionController) { + documentInteractionController = nil + completion?() + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift new file mode 100644 index 000000000..0f263fc7c --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift @@ -0,0 +1,12 @@ +// +// DocumentInteractionProtocol.swift +// +// +// Created by Stanislav Jelezoglo on 14.03.2024. +// + +import Foundation + +protocol DocumentInteractionProtocol { + func open(url: URL, name: String, completion: (() -> Void)?) +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift index a409a1cae..a5a288cc9 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift @@ -122,7 +122,7 @@ private struct ViewerControls: View { } .padding() .sheet(isPresented: $isShareSheetPresented) { - ShareSheet(activityItems: [viewModel.uiImage]) + ShareSheet(activityItems: [viewModel.uiImage], completion: nil) } Spacer() diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift index 2d067381b..8b1f7d747 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift @@ -58,17 +58,13 @@ private struct ViewerContent: View { var dismissAction: () -> Void - @State private var isShareSheetPresented = false - var body: some View { VStack { HStack { - if let caption = viewModel.caption { - Text(caption) - .foregroundColor(.black) - .multilineTextAlignment(.leading) - .padding() - } + Text(viewModel.caption) + .foregroundColor(.black) + .multilineTextAlignment(.leading) + .padding() Spacer() CloseButton(dismissAction: dismissAction, color: .black) .padding() @@ -81,7 +77,7 @@ private struct ViewerContent: View { Spacer() Button { - isShareSheetPresented.toggle() + viewModel.shareAction() } label: { Image(systemName: "square.and.arrow.up") .resizable() @@ -89,8 +85,8 @@ private struct ViewerContent: View { .tint(Color(UIColor.adamant.active)) } .padding(EdgeInsets(top: 5, leading: .zero, bottom: 5, trailing: .zero)) - .sheet(isPresented: $isShareSheetPresented) { - ShareSheet(activityItems: [viewModel.data]) + .sheet(isPresented: viewModel.$isShareSheetPresented) { + shareView() } Spacer() @@ -99,6 +95,17 @@ private struct ViewerContent: View { } .background(Color.white) } + + func shareView() -> some View { + if let copyURL = try? viewModel.getCopyOfFile() { + let completion: UIActivityViewController.CompletionWithItemsHandler = { [weak viewModel] (_, _, _, _) in + viewModel?.removeCopyOfFile() + } + return ShareSheet(activityItems: [copyURL], completion: completion) + } + + return ShareSheet(activityItems: [viewModel.fileUrl], completion: nil) + } } private struct Content: View { @@ -110,13 +117,11 @@ private struct Content: View { .resizable() .frame(width: 80, height: 90) - if let caption = viewModel.caption { - Text(caption) - .font(.headline) - .foregroundColor(.black) - .multilineTextAlignment(.center) - .padding() - } + Text(viewModel.caption) + .font(.headline) + .foregroundColor(.black) + .multilineTextAlignment(.center) + .padding() if let size = viewModel.size { Text(viewModel.formatSize(size)) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift index 1cc481eb4..c3867462e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift @@ -6,17 +6,24 @@ // import Foundation +import CommonKit +import SwiftUI final class OtherViewerViewModel: ObservableObject { @Published var viewerShown: Bool = false - @Published var caption: String? + @Published var caption: String @Published var size: Int64? - @Published var data: Data + @Published var fileUrl: URL + @State var isShareSheetPresented = false - init(caption: String?, size: Int64?, data: Data) { + private var copyURL: URL + + init(caption: String, size: Int64?, fileUrl: URL) { self.caption = caption self.size = size - self.data = data + self.fileUrl = fileUrl + self.copyURL = URL(fileURLWithPath: fileUrl.deletingLastPathComponent().path) + copyURL.appendPathComponent(caption) } func formatSize(_ bytes: Int64) -> String { @@ -26,4 +33,45 @@ final class OtherViewerViewModel: ObservableObject { return formatter.string(fromByteCount: bytes) } + + func getCopyOfFile() throws -> URL { + if FileManager.default.fileExists(atPath: copyURL.path) { + try? FileManager.default.removeItem(at: copyURL) + } + + try FileManager.default.copyItem(at: fileUrl, to: copyURL) + return copyURL + } + + func removeCopyOfFile() { + try? FileManager.default.removeItem(at: copyURL) + } + + func shareAction() { +// if isMacOS { +// try? saveFileToDownloadsFolder() +// return +// } +// +// isShareSheetPresented = true + + let documentInteractionController = UIDocumentInteractionController(url: fileUrl) + documentInteractionController.presentPreview(animated: true) + } +} + +private extension OtherViewerViewModel { + func saveFileToDownloadsFolder() throws { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + + let fileURLInDownloads = downloadsURL.appendingPathComponent(caption) + + do { + let data = try Data(contentsOf: fileUrl) + + try data.write(to: fileURLInDownloads) + } catch { + print("saving error=\(error)") + } + } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift index b6ed82025..41f286a7a 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift @@ -10,17 +10,20 @@ import SwiftUI struct ShareSheet: View { let activityItems: [Any] + let completion: UIActivityViewController.CompletionWithItemsHandler? var body: some View { - ActivityView(activityItems: activityItems) + ActivityView(activityItems: activityItems, completion: completion) } } struct ActivityView: UIViewControllerRepresentable { let activityItems: [Any] + let completion: UIActivityViewController.CompletionWithItemsHandler? func makeUIViewController(context: Context) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + controller.completionWithItemsHandler = completion return controller } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 91114f08b..1c00eb935 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -8,53 +8,26 @@ import FilesNetworkManagerKit public final class FilesStorageKit { private let adamantCore = NativeAdamantCore() private let networkFileManager = FilesNetworkManager() - - private var cachedImages: [String: UIImage] = [:] private var cachedFiles: [String: URL] = [:] - private let imageExtensions = ["JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD"] public init() { try? loadCache() } - public func getPreview(for id: String, type: String) -> UIImage { - guard let data = cachedImages[id] else { - return getPreview(for: type) - } - - return data + public func getPreview(for id: String, type: String) -> URL? { + getPreview(for: type, url: cachedFiles[id]) } public func isCached(_ id: String) -> Bool { - cachedImages[id] != nil || cachedFiles[id] != nil + cachedFiles[id] != nil } - public func getFileData( - with id: String, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String - ) throws -> Data { - if let image = cachedImages[id], - let data = image.jpegData(compressionQuality: 1.0) { - return data - } - - if let url = cachedFiles[id], - let encodedData = try? Data(contentsOf: url) { - guard let decodedData = adamantCore.decodeData( - encodedData, - rawNonce: nonce, - senderPublicKey: senderPublicKey, - privateKey: recipientPrivateKey - ) else { - throw FileValidationError.fileNotFound - } - - return decodedData + public func getFileURL(with id: String) throws -> URL { + guard let url = cachedFiles[id] else { + throw FileValidationError.fileNotFound } - throw FileValidationError.fileNotFound + return url } public func uploadFile( @@ -62,10 +35,6 @@ public final class FilesStorageKit { recipientPublicKey: String, senderPrivateKey: String ) async throws -> (id: String, nonce: String) { - defer { - cacheImage(id: file.url.absoluteString, image: nil) - } - _ = file.url.startAccessingSecurityScopedResource() let data = try Data(contentsOf: file.url) @@ -82,15 +51,9 @@ public final class FilesStorageKit { throw FileManagerError.cantEnctryptFile } - if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: file.url.absoluteString, image: UIImage(data: encodedData)) - } - let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) - if imageExtensions.contains(file.extenstion?.lowercased() ?? .empty) { - cacheImage(id: id, image: UIImage(data: data)) - } + try cacheFile(id: id, data: data) file.url.stopAccessingSecurityScopedResource() return (id: id, nonce: nonce) @@ -106,12 +69,6 @@ public final class FilesStorageKit { ) async throws { let encodedData = try await networkFileManager.downloadFile(id, type: storage) - let fileExtension = fileType?.uppercased() ?? defaultFileType - - guard imageExtensions.contains(fileExtension) else { - return try cacheFile(id: id, data: encodedData) - } - guard let decodedData = adamantCore.decodeData( encodedData, rawNonce: nonce, @@ -122,8 +79,7 @@ public final class FilesStorageKit { throw FileValidationError.fileNotFound } - cacheImage(id: id, image: UIImage(data: decodedData)) - return + return try cacheFile(id: id, data: decodedData) } } @@ -177,21 +133,18 @@ private extension FilesStorageKit { cachedFiles[id] = fileURL } - - func cacheImage(id: String, image: UIImage?) { - cachedImages[id] = image - } - - private func getPreview(for type: String) -> UIImage { + + private func getPreview(for type: String, url: URL?) -> URL? { switch type.uppercased() { case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD": - return UIImage.asset(named: "file-image-box")! - case "ZIP": - return UIImage.asset(named: "file-zip-box")! + if let url = url { + return url + } + return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") case "PDF": - return UIImage.asset(named: "file-pdf-box")! + return getLocalImageUrl(by: "file-pdf-box", withExtension: "jpg") default: - return UIImage.asset(named: "file-default-box")! + return getLocalImageUrl(by: "file-default-box", withExtension: "png") } } } 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, From 8b093ec18f9a73c1c9c9589e72da509be7580754 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 15 Mar 2024 10:42:21 +0200 Subject: [PATCH 021/123] [trello.com/c/uxBZaznD] feat: new file preview for mac --- .../Pickers/DocumentInteractionService.swift | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index 4860cb083..8a0e88f49 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -10,36 +10,68 @@ import UIKit import CommonKit import SwiftUI import WebKit +import QuickLook final class DocumentInteractionService: NSObject, DocumentInteractionProtocol { private var documentInteractionController: UIDocumentInteractionController? private var completion: (() -> Void)? + private var url: URL! + func open(url: URL, name: String, completion: (() -> Void)?) { self.completion = completion + self.url = url - documentInteractionController = UIDocumentInteractionController(url: url) - documentInteractionController?.delegate = self - - guard isMacOS else { - documentInteractionController?.presentPreview(animated: true) - return - } +// documentInteractionController = UIDocumentInteractionController(url: url) +// documentInteractionController?.delegate = self +// +// guard isMacOS else { +// documentInteractionController?.presentPreview(animated: true) +// return +// } let vc = UIApplication.shared.topViewController()! - guard let uiImage = UIImage(contentsOfFile: url.path) else { - documentInteractionController?.presentOpenInMenu(from: vc.view.frame, in: vc.view, animated: true) - return - } +// guard let uiImage = UIImage(contentsOfFile: url.path) else { +// documentInteractionController?.presentOpenInMenu(from: vc.view.frame, in: vc.view, animated: true) +// return +// } - let view = ImageViewer(image: uiImage, caption: name) - present(view: view) +// let view = ImageViewer(image: uiImage, caption: name) +// present(view: view) + + let quickVC = QLPreviewController() + quickVC.delegate = self + quickVC.dataSource = self + quickVC.modalPresentationStyle = .fullScreen + vc.present(quickVC, animated: true) documentInteractionController = nil } } +extension DocumentInteractionService: QLPreviewControllerDelegate, QLPreviewControllerDataSource { + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + QLPreviewItemEq(url: url) + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + completion?() + } +} + +final class QLPreviewItemEq: NSObject, QLPreviewItem { + let previewItemURL: URL? + + init(url: URL) { + previewItemURL = url + } +} + private extension DocumentInteractionService { func present(view: some View) { let vc = UIHostingController( From 67baeb147a1cad3c035d40cefc9d528bf0e80a70 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 15 Mar 2024 12:22:21 +0200 Subject: [PATCH 022/123] [trello.com/c/uxBZaznD] feat: use previewID & add video support --- .../Chat/ViewModel/ChatMessageFactory.swift | 2 +- .../Chat/ViewModel/ChatViewModel.swift | 27 ++++- .../FilesStorageProtocol.swift | 6 +- .../Sources/CommonKit/Models/FileResult.swift | 7 +- .../CommonKit/Models/RichMessage.swift | 9 +- .../Pickers/DocumentPickerService.swift | 1 + .../Pickers/MediaPickerService.swift | 63 +++++++++- .../FilesStorageKit/FilesStorageKit.swift | 111 ++++++++++++++---- 8 files changed, 187 insertions(+), 39 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 58fd0f3f2..e96f1987c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -331,7 +331,7 @@ private extension ChatMessageFactory { ChatFile.init( file: RichMessageFile.File.init($0), previewDataURL: filesStorage.getPreview( - for: $0[RichContentKeys.file.file_id] as? String ?? .empty, + for: $0[RichContentKeys.file.preview_id] as? String ?? .empty, type: $0[RichContentKeys.file.file_type] as? String ?? .empty ), isDownloading: false, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 4a11af6b0..7591fe82d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -307,7 +307,8 @@ final class ChatViewModel: NSObject { file_id: $0.url.absoluteString, file_type: $0.extenstion, file_size: $0.size, - preview_id: nil, + preview_id: $0.previewUrl?.absoluteString, + preview_nonce: nil, file_name: $0.name, nonce: .empty ) @@ -359,8 +360,15 @@ final class ChatViewModel: NSObject { let oldId = file.url.absoluteString uploadingFilesIDs.removeAll(where: { $0 == oldId }) + let previewID: String + if let id = result.idPreview { + previewID = id + } else { + previewID = result.id + } + let preview = filesStorage.getPreview( - for: result.id, + for: previewID, type: file.extenstion ?? "" ) @@ -373,6 +381,8 @@ final class ChatViewModel: NSObject { ) { richFiles[index].file_id = result.id richFiles[index].nonce = result.nonce + richFiles[index].preview_id = result.idPreview + richFiles[index].preview_nonce = result.noncePreview } } @@ -765,11 +775,20 @@ final class ChatViewModel: NSObject { fileType: file.file.file_type ?? .empty, senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, - nonce: file.nonce + nonce: file.nonce, + previewId: file.file.preview_id, + previewNonce: file.file.preview_nonce ) + let previewID: String + if let id = file.file.preview_id { + previewID = id + } else { + previewID = file.file.file_id + } + let preview = filesStorage.getPreview( - for: file.file.file_id, + for: previewID, type: file.file.file_type ?? "" ) diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index b419f637d..eae3f8144 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -22,7 +22,7 @@ protocol FilesStorageProtocol { _ file: FileResult, recipientPublicKey: String, senderPrivateKey: String - ) async throws -> (id: String, nonce: String) + ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) func downloadFile( id: String, @@ -30,7 +30,9 @@ protocol FilesStorageProtocol { fileType: String?, senderPublicKey: String, recipientPrivateKey: String, - nonce: String + nonce: String, + previewId: String?, + previewNonce: String? ) async throws } diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index 50c0207ec..a9ec011e5 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -16,6 +16,7 @@ public enum FileType { public struct FileResult { public let url: URL public let type: FileType + public let previewUrl: URL? public let preview: UIImage? public let size: Int64 public let name: String? @@ -25,15 +26,17 @@ public struct FileResult { url: URL, type: FileType, preview: UIImage?, - size: Int64, + previewUrl: URL?, + size: Int64, name: String?, extenstion: String? ) { self.url = url self.type = type - self.preview = preview + self.previewUrl = previewUrl self.size = size self.name = name self.extenstion = extenstion + self.preview = preview } } diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index aae2c74b6..e5ac6c5a3 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -57,6 +57,7 @@ public enum RichContentKeys { public static let preview_id = "preview_id" public static let file_name = "file_name" public static let nonce = "nonce" + public static let preview_nonce = "preview_nonce" } } @@ -97,12 +98,14 @@ public struct RichMessageFile: RichMessage { public var preview_id: String? public var file_name: String? public var nonce: String + public var preview_nonce: String? public init( file_id: String, file_type: String? = nil, file_size: Int64, preview_id: String? = nil, + preview_nonce: String? = nil, file_name: String? = nil, nonce: String ) { @@ -112,6 +115,7 @@ public struct RichMessageFile: RichMessage { self.preview_id = preview_id self.file_name = file_name self.nonce = nonce + self.preview_nonce = preview_nonce } public init(_ data: [String: Any]) { @@ -121,6 +125,7 @@ public struct RichMessageFile: RichMessage { self.preview_id = data[RichContentKeys.file.preview_id] as? String self.file_name = data[RichContentKeys.file.file_name] as? String self.nonce = data[RichContentKeys.file.nonce] as? String ?? .empty + self.preview_nonce = data[RichContentKeys.file.preview_nonce] as? String ?? .empty } public func content() -> [String: Any] { @@ -134,8 +139,10 @@ public struct RichMessageFile: RichMessage { contentDict[RichContentKeys.file.file_type] = file_type } - if let preview_id = preview_id, !preview_id.isEmpty { + if let preview_id = preview_id, !preview_id.isEmpty, + let preview_nonce = preview_nonce, !preview_nonce.isEmpty { contentDict[RichContentKeys.file.preview_id] = preview_id + contentDict[RichContentKeys.file.preview_nonce] = preview_nonce } if let file_name = file_name, !file_name.isEmpty { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index fd7b169d6..946560af4 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -36,6 +36,7 @@ extension DocumentPickerService: UIDocumentPickerDelegate { url: $0, type: .other, preview: nil, + previewUrl: nil, size: (try? getFileSize(from: $0)) ?? .zero, name: $0.lastPathComponent, extenstion: $0.pathExtension diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 47d7cc737..71fbe3b65 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -55,11 +55,16 @@ private extension MediaPickerService { let fileSize = try? getFileSize(from: url) else { continue } + let resizedPreview = self.resizeImage(image: preview, targetSize: .init(squareSize: 50)) + + let previewUrl = try? getUrl(for: resizedPreview, name: url.lastPathComponent) + dataArray.append( .init( url: url, type: .image, - preview: preview, + preview: resizedPreview, + previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, extenstion: "JPG" @@ -73,15 +78,17 @@ private extension MediaPickerService { else { continue } let preview = getThumbnailImage(forUrl: url) + let previewUrl = try? getUrl(for: preview, name: url.lastPathComponent) dataArray.append( .init( url: url, type: .video, preview: preview, + previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, - extenstion: "JPG" + extenstion: url.pathExtension ) ) } @@ -164,10 +171,56 @@ private extension MediaPickerService { do { let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) - return UIImage(cgImage: thumbnailImage) - } catch let error { - print("error in thumbail=", error) + + let image = UIImage(cgImage: thumbnailImage) + let resizedImage = resizeImage(image: image, targetSize: .init(squareSize: 50)) + return resizedImage + } catch { return nil } } + + func getUrl(for image: UIImage?, name: String) throws -> URL { + guard let data = image?.jpegData(compressionQuality: 1.0) else { + throw FileValidationError.fileNotFound + } + + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("cachePath") + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(name) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + return fileURL + } + + func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let size = image.size + + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + + var newSize: CGSize + if(widthRatio > heightRatio) { + newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) + } else { + newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) + } + + let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage! + } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 1c00eb935..706abc10d 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -6,11 +6,13 @@ import UIKit import FilesNetworkManagerKit public final class FilesStorageKit { + typealias UploadResult = (id: String, nonce: String) + private let adamantCore = NativeAdamantCore() private let networkFileManager = FilesNetworkManager() private var cachedFiles: [String: URL] = [:] - - public init() { + + public init() { try? loadCache() } @@ -34,32 +36,61 @@ public final class FilesStorageKit { _ file: FileResult, recipientPublicKey: String, senderPrivateKey: String - ) async throws -> (id: String, nonce: String) { - _ = file.url.startAccessingSecurityScopedResource() - - let data = try Data(contentsOf: file.url) - - let encodedResult = adamantCore.encodeData( - data, + ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) { + let result = try await uploadFile( + url: file.url, recipientPublicKey: recipientPublicKey, - privateKey: senderPrivateKey + senderPrivateKey: senderPrivateKey ) - guard let encodedData = encodedResult?.data, - let nonce = encodedResult?.nonce - else { - throw FileManagerError.cantEnctryptFile - } - - let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) + var resultPreview: UploadResult? - try cacheFile(id: id, data: data) + if let url = file.previewUrl { + resultPreview = try? await uploadFile( + url: url, + recipientPublicKey: recipientPublicKey, + senderPrivateKey: senderPrivateKey + ) + } - file.url.stopAccessingSecurityScopedResource() - return (id: id, nonce: nonce) + return (id: result.id, nonce: result.nonce, idPreview: resultPreview?.id, noncePreview: resultPreview?.nonce) } public func downloadFile( + id: String, + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String, + previewId: String?, + previewNonce: String? + ) async throws { + if let previewId = previewId, + let previewNonce = previewNonce { + try? await downloadFile( + id: previewId, + storage: storage, + fileType: fileType, + senderPublicKey: senderPublicKey, + recipientPrivateKey: recipientPrivateKey, + nonce: previewNonce + ) + } + + return try await downloadFile( + id: id, + storage: storage, + fileType: fileType, + senderPublicKey: senderPublicKey, + recipientPrivateKey: recipientPrivateKey, + nonce: nonce + ) + } +} + +private extension FilesStorageKit { + func downloadFile( id: String, storage: String, fileType: String?, @@ -81,9 +112,36 @@ public final class FilesStorageKit { return try cacheFile(id: id, data: decodedData) } -} - -private extension FilesStorageKit { + + func uploadFile( + url: URL, + recipientPublicKey: String, + senderPrivateKey: String + ) async throws -> UploadResult { + _ = 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.cantEnctryptFile + } + + let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) + + try cacheFile(id: id, data: data) + + url.stopAccessingSecurityScopedResource() + return (id: id, nonce: nonce) + } + func loadCache() throws { let folder = try FileManager.default.url( for: .cachesDirectory, @@ -136,11 +194,16 @@ private extension FilesStorageKit { private func getPreview(for type: String, url: URL?) -> URL? { switch type.uppercased() { - case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "PSD", "RAW", "BMP", "HEIF", "INDD": + case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "RAW", "BMP", "HEIF", "INDD": if let url = url { return url } return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") + case "MOV", "MP4", "AVI", "WEBM": + if let url = url { + return url + } + return getLocalImageUrl(by: "file-default-box", withExtension: "png") case "PDF": return getLocalImageUrl(by: "file-pdf-box", withExtension: "jpg") default: From 21d352c87cb19e066e1c2048b02f9823ecb94da9 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 15 Mar 2024 13:58:53 +0200 Subject: [PATCH 023/123] [trello.com/c/uxBZaznD] feat: connect context menu --- .../FixedTextMessageSizeCalculator.swift | 2 +- .../Subviews/ChatMedia/ChatMediaCell.swift | 8 +- .../Container/ChatMediaContainerView.swift | 115 ++++++++++++++++-- .../Content/ChatMediaContnentView+Model.swift | 4 +- .../Content/ChatMediaContnentView.swift | 24 +++- .../FilesToolbarCollectionViewCell.swift | 4 +- .../Chat/ViewModel/ChatMessageFactory.swift | 3 +- 7 files changed, 141 insertions(+), 19 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index b88e42c1d..09ac747ab 100644 --- a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift +++ b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift @@ -81,7 +81,7 @@ if case let .file(model) = getMessages()[indexPath.section].fullModel.content { let contentViewHeight: CGFloat = model.value.height() - messageContainerSize.width = 260 + messageContainerSize.width = maxWidth messageContainerSize.height = contentViewHeight + messageInsets.vertical + additionalHeight diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index e7fdfe11c..0a550e7b4 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -29,10 +29,7 @@ final class ChatMediaCell: MessageContentCell { override var isSelected: Bool { didSet { - messageContainerView.animateIsSelected( - isSelected, - originalColor: messageContainerView.backgroundColor - ) + containerMediaView.isSelected = isSelected } } @@ -42,12 +39,15 @@ final class ChatMediaCell: MessageContentCell { 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.frame = messageContainerView.frame containerMediaView.layoutIfNeeded() } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 3585ac077..3de02120a 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -16,8 +16,22 @@ final class ChatMediaContainerView: UIView, ChatModelView { return view }() - private lazy var contentView = ChatMediaContentView() + private let spacingView: UIView = { + let view = UIView() + view.setContentCompressionResistancePriority(.dragThatCanResizeScene, for: .horizontal) + return view + }() + + private let horizontalStack: UIStackView = { + let stack = UIStackView() + stack.alignment = .center + stack.axis = .horizontal + return stack + }() + private lazy var contentView = ChatMediaContentView() + private lazy var chatMenuManager = ChatMenuManager(delegate: self) + // MARK: Proprieties var subscription: AnyCancellable? @@ -30,6 +44,12 @@ final class ChatMediaContainerView: UIView, ChatModelView { didSet { contentView.actionHandler = actionHandler } } + var isSelected: Bool = false { + didSet { + contentView.isSelected = isSelected + } + } + override init(frame: CGRect) { super.init(frame: frame) configure() @@ -42,22 +62,25 @@ final class ChatMediaContainerView: UIView, ChatModelView { } extension ChatMediaContainerView { - func configure() { + func configure() { addSubview(swipeView) swipeView.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } - addSubview(contentView) - contentView.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(12) - $0.leading.trailing.equalToSuperview().inset(12) + addSubview(horizontalStack) + horizontalStack.snp.makeConstraints { + $0.verticalEdges.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(4) } swipeView.swipeStateAction = { [actionHandler] state in actionHandler(.swipeState(state: state)) } - // chatMenuManager.setup(for: contentView) + + contentView.snp.makeConstraints { $0.width.equalTo(contentWidth) } + + chatMenuManager.setup(for: contentView) } func update() { @@ -66,6 +89,82 @@ extension ChatMediaContainerView { swipeView.didSwipeAction = { [actionHandler, model] in actionHandler(.reply(message: model)) } + + updateLayout() + } + + func updateLayout() { + var viewsList = [spacingView, contentView] + + viewsList = model.isFromCurrentSender + ? viewsList + : viewsList.reversed() + + guard horizontalStack.arrangedSubviews != viewsList else { return } + horizontalStack.arrangedSubviews.forEach(horizontalStack.removeArrangedSubview) + viewsList.forEach(horizontalStack.addArrangedSubview) + } +} + +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: nil, + 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)) + } + + return AMenuSection([reply, report, remove]) + } +} + +extension ChatMediaContainerView { + func copy(with model: Model) -> ChatMediaContainerView? { + let view = ChatMediaContainerView(frame: frame) + view.contentView.model = model.content + return view } } @@ -74,3 +173,5 @@ extension ChatMediaContainerView.Model { content.height() } } + +private let contentWidth: CGFloat = 260 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift index a565e5136..7f214775b 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -19,6 +19,7 @@ extension ChatMediaContentView { let replyMessage: NSAttributedString let replyId: String let comment: NSAttributedString + let backgroundColor: ChatMessageBackgroundColor static let `default` = Self( id: "", @@ -28,7 +29,8 @@ extension ChatMediaContentView { isReply: false, replyMessage: NSAttributedString(string: .empty), replyId: .empty, - comment: NSAttributedString(string: .empty) + comment: NSAttributedString(string: .empty), + backgroundColor: .failed ) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 83434b3ae..d0af9cfb8 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -65,7 +65,7 @@ final class ChatMediaContentView: UIView { private lazy var verticalStack: UIStackView = { let stack = UIStackView(arrangedSubviews: [replyView, commentLabel, filesStack]) stack.axis = .vertical - stack.spacing = .zero + stack.spacing = verticalStackSpacing return stack }() @@ -74,7 +74,7 @@ final class ChatMediaContentView: UIView { stack.axis = .vertical stack.spacing = verticalStackSpacing - for _ in 0...FilesConstants.maxFilesCount { + for _ in 0.. Void = { _ in } override init(frame: CGRect) { @@ -104,13 +113,19 @@ final class ChatMediaContentView: UIView { private extension ChatMediaContentView { func configure() { + layer.cornerRadius = 16 + addSubview(verticalStack) verticalStack.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() + make.verticalEdges.equalToSuperview().inset(8) + make.horizontalEdges.equalToSuperview().inset(12) } } func update() { + alpha = model.isHidden ? .zero : 1.0 + backgroundColor = model.backgroundColor.uiColor + commentLabel.attributedText = model.comment commentLabel.isHidden = model.comment.string.isEmpty replyView.isHidden = !model.isReply @@ -178,7 +193,7 @@ extension ChatMediaContentView.Model { return imageSize * CGFloat(filesCount) + stackSpacingCount * verticalStackSpacing - + labelSize(for: comment, considering: 260).height + + labelSize(for: comment, considering: contentWidth).height + replyViewDynamicHeight } @@ -211,3 +226,4 @@ private let commentFont = UIFont.systemFont(ofSize: 14) private let verticalStackSpacing: CGFloat = 6 private let verticalInsets: CGFloat = 8 private let replyViewHeight: CGFloat = 25 +private let contentWidth: CGFloat = 260 diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index e8342dfa9..20be7972d 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -74,7 +74,9 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { } func update(_ file: FileResult, tag: Int) { - imageView.image = file.preview ?? .asset(named: "file-jpg-box") + imageView.image = file.preview ?? defaultImage removeBtn.tag = tag } } + +private let defaultImage: UIImage? = .asset(named: "file-default-box") diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index e96f1987c..48761fc84 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -355,7 +355,8 @@ private extension ChatMessageFactory { isReply: transaction.isFileReply(), replyMessage: decodedMessageMarkDown, replyId: replyId, - comment: Self.markdownParser.parse(comment) + comment: Self.markdownParser.parse(comment), + backgroundColor: backgroundColor ), address: address, opponentAddress: opponentAddress From f2b42da59f3fc9cc8e973c555f8d6cabbaf41fa9 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 15 Mar 2024 14:33:43 +0200 Subject: [PATCH 024/123] [trello.com/c/uxBZaznD] feat: implemented reactions --- .../View/Managers/ChatDataSourceManager.swift | 1 + .../Container/ChatMediaContainerView.swift | 137 +++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index a26f102b6..2e2ba91a6 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -158,6 +158,7 @@ final class ChatDataSourceManager: MessagesDataSource { } 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) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 3de02120a..1ae25bd95 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -26,12 +26,74 @@ final class ChatMediaContainerView: UIView, ChatModelView { let stack = UIStackView() stack.alignment = .center stack.axis = .horizontal + stack.spacing = 12 + 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 = 12 + + stack.addArrangedSubview(ownReactionLabel) + stack.addArrangedSubview(opponentReactionLabel) return stack }() private lazy var contentView = ChatMediaContentView() private lazy var chatMenuManager = ChatMenuManager(delegate: self) + // MARK: Dependencies + + var chatMessagesListViewModel: ChatMessagesListViewModel? + // MARK: Proprieties var subscription: AnyCancellable? @@ -50,6 +112,12 @@ final class ChatMediaContainerView: UIView, ChatModelView { } } + private let ownReactionSize = CGSize(width: 40, height: 27) + private let opponentReactionSize = CGSize(width: 55, height: 27) + private let opponentReactionImageSize = CGSize(width: 12, height: 12) + + // MARK: - Init + override init(frame: CGRect) { super.init(frame: frame) configure() @@ -91,10 +159,17 @@ extension ChatMediaContainerView { } updateLayout() + + reactionsStack.snp.makeConstraints { $0.width.equalTo(50) } + + ownReactionLabel.isHidden = getReaction(for: model.address) == nil + opponentReactionLabel.isHidden = getReaction(for: model.opponentAddress) == nil + updateOwnReaction() + updateOpponentReaction() } func updateLayout() { - var viewsList = [spacingView, contentView] + var viewsList = [spacingView, reactionsStack, contentView] viewsList = model.isFromCurrentSender ? viewsList @@ -104,6 +179,64 @@ extension ChatMediaContainerView { 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 { @@ -125,7 +258,7 @@ extension ChatMediaContainerView: ChatMenuManagerDelegate { tapLocation: tapLocation, messageId: model.id, menu: makeContextMenu(), - selectedEmoji: nil, + selectedEmoji: getReaction(for: model.address), getPositionOnScreen: getPositionOnScreen ) actionHandler(.presentMenu(arg: arguments)) From c41035d203cc53b493ba6025a6e164bfc4eb9c65 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 15 Mar 2024 16:06:42 +0200 Subject: [PATCH 025/123] [trello.com/c/uxBZaznD] fix: get preview from file image --- .../FilesToolBarView/FilesToolbarView.swift | 7 +- .../Assets/File-icons/file-image-box.jpg | Bin 103412 -> 25815 bytes .../Pickers/DocumentPickerService.swift | 80 +++++++++++++++++- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift index 20b0facdf..e993be33e 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -68,8 +68,8 @@ final class FilesToolbarView: UIView { iv.tintColor = .adamant.active iv.snp.makeConstraints { make in - make.height.equalTo(30) - make.width.equalTo(27) + make.height.equalTo(27) + make.width.equalTo(24) } return iv @@ -118,7 +118,7 @@ final class FilesToolbarView: UIView { addSubview(horizontalStack) horizontalStack.snp.makeConstraints { $0.top.bottom.equalToSuperview().inset(verticalInsets) - $0.leading.trailing.equalToSuperview().inset(15) + $0.leading.trailing.equalToSuperview().inset(horizontalInsets) } } @@ -171,3 +171,4 @@ extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource private let horizontalStackSpacing: CGFloat = 25 private let verticalInsets: CGFloat = 8 +private let horizontalInsets: CGFloat = 12 diff --git a/CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg b/CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg index 6f0b551f6061c2251fb1a34e56f9e6c3450c1737..9320ebf5a8da492b6fdd554397e52f829d0514db 100644 GIT binary patch literal 25815 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 PtK6)4ECn;3FDw58w-52{ literal 103412 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 TOa|cjzP@xb9*-OUKQsRiuIWlv diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index 946560af4..42b0e0978 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -8,6 +8,7 @@ import Foundation import UIKit import CommonKit +import MobileCoreServices final class DocumentPickerService: NSObject, FilePickerProtocol { let documentPicker = UIDocumentPickerViewController( @@ -32,11 +33,13 @@ extension DocumentPickerService: UIDocumentPickerDelegate { didPickDocumentsAt urls: [URL] ) { let files = urls.compactMap { - FileResult.init( + let preview = getPreview(for: $0) + + return FileResult.init( url: $0, type: .other, - preview: nil, - previewUrl: nil, + preview: preview.image, + previewUrl: preview.url, size: (try? getFileSize(from: $0)) ?? .zero, name: $0.lastPathComponent, extenstion: $0.pathExtension @@ -59,4 +62,75 @@ private extension DocumentPickerService { fileURL.stopAccessingSecurityScopedResource() return fileSize } + + func isImage(atURL fileURL: URL) -> Bool { + guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileURL.pathExtension as CFString, nil)?.takeRetainedValue() else { + return false + } + + return UTTypeConformsTo(uti, kUTTypeImage) + } + + func getPreview(for url: URL) -> (image: UIImage?, url: URL?) { + defer { + url.stopAccessingSecurityScopedResource() + } + + _ = url.startAccessingSecurityScopedResource() + + guard isImage(atURL: url), + let image = UIImage(contentsOfFile: url.path) + else { + return (image: nil, url: nil) + } + + let resizedImage = resizeImage(image: image, targetSize: .init(squareSize: 50)) + let imageURL = try? getUrl(for: resizedImage, name: url.lastPathComponent) + + return (image: resizedImage, url: imageURL) + } + + func getUrl(for image: UIImage?, name: String) throws -> URL { + guard let data = image?.jpegData(compressionQuality: 1.0) else { + throw FileValidationError.fileNotFound + } + + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("cachePath") + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(name) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + return fileURL + } + + func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let size = image.size + + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + + var newSize: CGSize + if(widthRatio > heightRatio) { + newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) + } else { + newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) + } + + let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage! + } } From b6d6e907004ab132fbab173d1e9ed4e17c1d5f0b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 18 Mar 2024 12:42:48 +0200 Subject: [PATCH 026/123] [trello.com/c/uxBZaznD] fix: files vertical stack spacing --- .../Subviews/ChatMedia/Content/ChatMediaContnentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index d0af9cfb8..a88b38d37 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -219,11 +219,11 @@ extension ChatMediaContentView.Model { private let nameFont = UIFont.systemFont(ofSize: 15) private let sizeFont = UIFont.systemFont(ofSize: 13) -private let imageSize: CGFloat = 90 +private let imageSize: CGFloat = 70 private typealias TransactionsDiffableDataSource = UITableViewDiffableDataSource private let cellIdentifier = "cell" private let commentFont = UIFont.systemFont(ofSize: 14) -private let verticalStackSpacing: CGFloat = 6 +private let verticalStackSpacing: CGFloat = 10 private let verticalInsets: CGFloat = 8 private let replyViewHeight: CGFloat = 25 private let contentWidth: CGFloat = 260 From cc41db19d2899044712d776a083cecabce93a7cf Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 20 Mar 2024 16:13:21 +0200 Subject: [PATCH 027/123] [trello.com/c/uxBZaznD] feat: new photo file preview in chat --- Adamant.xcodeproj/project.pbxproj | 30 +++- .../Subviews/ChatMedia/ChatMediaCell.swift | 11 +- .../Container/ChatMediaContainerView.swift | 9 +- .../Content/ChatMediaContnentView+Model.swift | 16 ++- .../Content/ChatMediaContnentView.swift | 76 +++++----- .../ChatFileView.swift | 17 ++- .../FileContainerView.swift | 78 +++++++++++ .../MediaContainerView.swift | 130 ++++++++++++++++++ .../MediaContainerView/MediaContentView.swift | 117 ++++++++++++++++ .../Chat/ViewModel/ChatMessageFactory.swift | 14 +- .../Chat/ViewModel/ChatViewModel.swift | 18 +-- .../FilesStorageKit/FilesStorageKit.swift | 5 +- 12 files changed, 453 insertions(+), 68 deletions(-) rename Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/{ => ChatFileContainerView}/ChatFileView.swift (90%) create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift create mode 100644 Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index fb578bdb3..a3e95723b 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ 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 /* FileContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */; }; + 3AA6DF462BA9BEB700EA2E16 /* MediaContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */; }; 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */; }; 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF08D5E2B4EB3A200EB82B1 /* LanguageService.swift */; }; 3AF08D612B4EB3C400EB82B1 /* LanguageStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */; }; @@ -707,6 +710,9 @@ 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 /* FileContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContainerView.swift; sourceTree = ""; }; + 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContentView.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 = ""; }; @@ -1380,7 +1386,8 @@ 3A299C792B85EAA900B54C61 /* Views */ = { isa = PBXGroup; children = ( - 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, + 3AA6DF422BA9943500EA2E16 /* MediaContainerView */, + 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */, ); path = Views; sourceTree = ""; @@ -1419,6 +1426,24 @@ path = PartnerQR; sourceTree = ""; }; + 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */ = { + isa = PBXGroup; + children = ( + 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, + 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */, + ); + path = ChatFileContainerView; + sourceTree = ""; + }; + 3AA6DF422BA9943500EA2E16 /* MediaContainerView */ = { + isa = PBXGroup; + children = ( + 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */, + 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */, + ); + path = MediaContainerView; + sourceTree = ""; + }; 3AFE7E502B1F6AFE00718739 /* WalletsService */ = { isa = PBXGroup; children = ( @@ -3130,6 +3155,7 @@ 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 */, @@ -3295,6 +3321,7 @@ 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 */, @@ -3366,6 +3393,7 @@ 93ADC17F2B083D7A00F2DF77 /* NodesAdditionalParamsStorage.swift in Sources */, E9B1AA5B21283E0F00080A2A /* AdmTransferViewController.swift in Sources */, 648C697322916192006645F5 /* DashTransactionsViewController.swift in Sources */, + 3AA6DF442BA997C000EA2E16 /* FileContainerView.swift in Sources */, 93E8EDCF2AF1CD9F003E163C /* NodeStatusInfo.swift in Sources */, 55E69E172868D7920025D82E /* CheckmarkView.swift in Sources */, 93B28EC02B076667007F268B /* APIResponseModel.swift in Sources */, diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index 0a550e7b4..36c570a1e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -48,14 +48,19 @@ final class ChatMediaCell: MessageContentCell { ) { super.layoutMessageContainerView(with: attributes) - containerMediaView.frame = messageContainerView.frame - containerMediaView.layoutIfNeeded() + 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.frame = messageContainerView.frame + containerMediaView.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 1ae25bd95..7f2a1377d 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -26,7 +26,7 @@ final class ChatMediaContainerView: UIView, ChatModelView { let stack = UIStackView() stack.alignment = .center stack.axis = .horizontal - stack.spacing = 12 + stack.spacing = horizontalStackSpace return stack }() @@ -146,8 +146,7 @@ extension ChatMediaContainerView { actionHandler(.swipeState(state: state)) } - contentView.snp.makeConstraints { $0.width.equalTo(contentWidth) } - + reactionsStack.snp.makeConstraints { $0.width.equalTo(reactionsWidth) } chatMenuManager.setup(for: contentView) } @@ -160,8 +159,6 @@ extension ChatMediaContainerView { updateLayout() - reactionsStack.snp.makeConstraints { $0.width.equalTo(50) } - ownReactionLabel.isHidden = getReaction(for: model.address) == nil opponentReactionLabel.isHidden = getReaction(for: model.opponentAddress) == nil updateOwnReaction() @@ -308,3 +305,5 @@ extension ChatMediaContainerView.Model { } private let contentWidth: CGFloat = 260 +private let reactionsWidth: CGFloat = 50 +private let horizontalStackSpace: CGFloat = 5 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift index 7f214775b..c7bedbbb6 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -12,7 +12,7 @@ import CommonKit extension ChatMediaContentView { struct Model: Equatable { let id: String - var files: [ChatFile] + var fileModel: FileModel var isHidden: Bool let isFromCurrentSender: Bool let isReply: Bool @@ -23,7 +23,7 @@ extension ChatMediaContentView { static let `default` = Self( id: "", - files: [], + fileModel: .default, isHidden: false, isFromCurrentSender: false, isReply: false, @@ -33,4 +33,16 @@ extension ChatMediaContentView { backgroundColor: .failed ) } + + struct FileModel: Equatable { + var files: [ChatFile] + var isMediaFilesOnly: Bool + let isFromCurrentSender: Bool + + static let `default` = Self( + files: [], + isMediaFilesOnly: false, + isFromCurrentSender: false + ) + } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index a88b38d37..aca84e1bf 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -63,25 +63,15 @@ final class ChatMediaContentView: UIView { }() private lazy var verticalStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [replyView, commentLabel, filesStack]) + let stack = UIStackView(arrangedSubviews: [replyView, commentLabel]) stack.axis = .vertical stack.spacing = verticalStackSpacing return stack }() - private lazy var filesStack: UIStackView = { - let stack = UIStackView() - stack.axis = .vertical - stack.spacing = verticalStackSpacing - - for _ in 0.. CGFloat { let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : 0 - let filesCount = files.count > FilesConstants.maxFilesCount - ? FilesConstants.maxFilesCount - : files.count + var rowCount: CGFloat = 1 - let stackSpacingCount: CGFloat = isReply - ? CGFloat(filesCount) + 1 - : CGFloat(filesCount) - - return imageSize * CGFloat(filesCount) - + stackSpacingCount * verticalStackSpacing + if isReply { + rowCount += 1 + } + + if !comment.string.isEmpty { + rowCount += 1 + } + + return fileModel.height() + + rowCount * verticalStackSpacing + labelSize(for: comment, considering: contentWidth).height + replyViewDynamicHeight } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift similarity index 90% rename from Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift rename to Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 48d67bd43..65ef0224a 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -33,7 +33,8 @@ class ChatFileView: UIView { 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 @@ -86,10 +87,6 @@ class ChatFileView: UIView { iconImageView.layer.cornerRadius = 5 } - override func awakeFromNib() { - super.awakeFromNib() - // Initialization code - } @objc func tapBtnAction() { buttonActionHandler?() } @@ -106,6 +103,11 @@ private extension ChatFileView { 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) @@ -128,12 +130,15 @@ private extension ChatFileView { iconImageView.layer.cornerRadius = 5 iconImageView.layer.masksToBounds = true iconImageView.contentMode = .scaleAspectFill + additionalLabel.textAlignment = .center } func update() { if let url = model.previewDataURL { iconImageView.image = UIImage(contentsOfFile: url.path) + additionalLabel.isHidden = true } else { + additionalLabel.isHidden = false iconImageView.image = defaultImage } @@ -153,6 +158,7 @@ private extension ChatFileView { : "\(fileName.uppercased()).\(fileType.uppercased())" sizeLabel.text = formatSize(model.file.file_size) + additionalLabel.text = fileType.uppercased() } func formatSize(_ bytes: Int64) -> String { @@ -164,6 +170,7 @@ private extension ChatFileView { } } +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 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift new file mode 100644 index 000000000..d5f6533e3 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -0,0 +1,78 @@ +// +// FileContainerView.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 FileContainerView: UIView { + private lazy var filesStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 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 FileContainerView { + func configure() { + addSubview(filesStack) + filesStack.snp.makeConstraints { $0.directionalEdges.equalToSuperview() } + } + + func update() { + let fileList = model.files.prefix(FilesConstants.maxFilesCount) + + filesStack.arrangedSubviews.forEach { $0.isHidden = true } + + for (index, file) in fileList.enumerated() { + let view = filesStack.arrangedSubviews[index] as? ChatFileView + view?.isHidden = false + view?.model = file + view?.buttonActionHandler = { [actionHandler, file, model] in + actionHandler( + .processFile( + file: file, + isFromCurrentSender: model.isFromCurrentSender + ) + ) + } + } + } +} + +private let stackSpacing: CGFloat = 8 +private let cellSize: CGFloat = 70 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..41fdaeab9 --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -0,0 +1,130 @@ +// +// 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..<3 { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 1 + stackView.alignment = .fill + stackView.distribution = .fillEqually + + for file in 0..<2 { + let view = MediaContentView() + view.layer.masksToBounds = true + view.snp.makeConstraints { + $0.height.equalTo(rowHeight) + } + stackView.addArrangedSubview(view) + } + + stack.addArrangedSubview(stackView) + } + + return stack + }() + + // MARK: Proprieties + + var model: ChatMediaContentView.FileModel = .default { + didSet { update() } + } + + var actionHandler: (ChatAction) -> Void = { _ in } + + // 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() { + addSubview(filesStack) + filesStack.snp.makeConstraints { + $0.directionalEdges.equalToSuperview() + $0.width.equalTo(stackWidth) + } + } + + func update() { + let fileList = model.files.prefix(FilesConstants.maxFilesCount) + for (index, stackView) in filesStack.arrangedSubviews.enumerated() { + guard let horizontalStackView = stackView as? UIStackView else { continue } + + 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 = file + mediaView.buttonActionHandler = { [actionHandler, file, model] in + actionHandler( + .processFile( + file: file, + isFromCurrentSender: model.isFromCurrentSender + ) + ) + } + } else { + mediaView.isHidden = true + } + } + } + } +} + +private let stackSpacing: CGFloat = 1 +private let rowHeight: CGFloat = 290 +private let stackWidth: CGFloat = 280 + +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)]) + } + } +} + +extension ChatMediaContentView.FileModel { + func height() -> CGFloat { + let fileList = Array(files.prefix(FilesConstants.maxFilesCount)) + + guard isMediaFilesOnly else { + return 70 * CGFloat(fileList.count) + } + + let rowCount = fileList.chunked(into: 2).count + + return rowHeight * CGFloat(rowCount) + + stackSpacing * CGFloat(rowCount) + } +} 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..c1286a68f --- /dev/null +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -0,0 +1,117 @@ +// +// MediaContentView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 19.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import UIKit +import SnapKit + +final class MediaContentView: UIView { + private lazy var imageView: UIImageView = UIImageView() + private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) + + private lazy var spinner: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.isHidden = true + view.color = .black + return view + }() + + private lazy var tapBtn: UIButton = { + let btn = UIButton() + btn.addTarget(self, action: #selector(tapBtnAction), for: .touchUpInside) + return btn + }() + + var model: ChatFile = .default { + didSet { + update() + } + } + + var buttonActionHandler: (() -> Void)? + + init(model: ChatFile) { + 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() + + imageView.layer.cornerRadius = 5 + } + + @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) + } + + addSubview(downloadImageView) + downloadImageView.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.size.equalTo(imageSize / 1.3) + } + + addSubview(tapBtn) + tapBtn.snp.makeConstraints { make in + make.directionalEdges.equalToSuperview() + } + + imageView.layer.cornerRadius = 5 + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + } + + func update() { + if let url = model.previewDataURL { + imageView.image = UIImage(contentsOfFile: url.path) + } else { + imageView.image = defaultImage + } + + downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading + + if model.isDownloading || model.isUploading { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } +} + +private let imageSize: CGFloat = 70 +private let stackSpacing: CGFloat = 12 +private let verticalStackSpacing: CGFloat = 3 +private let defaultImage: UIImage? = .asset(named: "file-default-box") diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 48761fc84..188e03157 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -343,13 +343,25 @@ private extension ChatMessageFactory { ) } + let filesExtensions = chatFiles.map { $0.file.file_type } + let imageExtensions = ["JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "RAW", "BMP", "HEIF", "INDD"] + + let isMediaFilesOnly = filesExtensions.allSatisfy { elementA in + guard let elementA = elementA else { return false } + return imageExtensions.contains(elementA) + } + return .file(.init(value: .init( id: id, isFromCurrentSender: isFromCurrentSender, reactions: reactions, content: .init( id: id, - files: chatFiles, + fileModel: .init( + files: chatFiles, + isMediaFilesOnly: isMediaFilesOnly, + isFromCurrentSender: isFromCurrentSender + ), isHidden: false, isFromCurrentSender: isFromCurrentSender, isReply: transaction.isFileReply(), diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 7591fe82d..47b7b03bf 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -1259,18 +1259,18 @@ private extension ChatMessage { func getFiles() -> [ChatFile] { guard case let .file(model) = content else { return [] } - return model.value.content.files + return model.value.content.fileModel.files } mutating func setDownloading(for fileId: String, value: Bool) { guard case let .file(fileModel) = content else { return } var model = fileModel.value - guard let index = model.content.files.firstIndex( + guard let index = model.content.fileModel.files.firstIndex( where: { $0.file.file_id == fileId } ) else { return } - model.content.files[index].isDownloading = value + model.content.fileModel.files[index].isDownloading = value content = .file(.init(value: model)) } @@ -1279,11 +1279,11 @@ private extension ChatMessage { guard case let .file(fileModel) = content else { return } var model = fileModel.value - guard let index = model.content.files.firstIndex( + guard let index = model.content.fileModel.files.firstIndex( where: { $0.file.file_id == fileId } ) else { return } - model.content.files[index].isUploading = value + model.content.fileModel.files[index].isUploading = value content = .file(.init(value: model)) } @@ -1297,15 +1297,15 @@ private extension ChatMessage { guard case let .file(fileModel) = content else { return } var model = fileModel.value - guard let index = model.content.files.firstIndex( + guard let index = model.content.fileModel.files.firstIndex( where: { $0.file.file_id == oldId } ) else { return } if let newId = newId { - model.content.files[index].file.file_id = newId + model.content.fileModel.files[index].file.file_id = newId } - model.content.files[index].previewDataURL = preview - model.content.files[index].isCached = cached + model.content.fileModel.files[index].previewDataURL = preview + model.content.fileModel.files[index].isCached = cached content = .file(.init(value: model)) } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 706abc10d..ed0ba3e17 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -203,14 +203,13 @@ private extension FilesStorageKit { if let url = url { return url } - return getLocalImageUrl(by: "file-default-box", withExtension: "png") + return nil case "PDF": return getLocalImageUrl(by: "file-pdf-box", withExtension: "jpg") default: - return getLocalImageUrl(by: "file-default-box", withExtension: "png") + return nil } } } -private let defaultFileType = "" private let cachePath = "downloads" From 5f5124ddc91c56592a143cb01a296cfea9ade50f Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 20 Mar 2024 17:22:54 +0200 Subject: [PATCH 028/123] [trello.com/c/uxBZaznD] fix: make progress loading while wait a file & arch fixes --- .../Chat/View/ChatViewController.swift | 91 +++++++++++++ .../Chat/ViewModel/ChatViewModel.swift | 126 +++++++++--------- .../FilesPickerKit/FilesPickerKit.swift | 85 ------------ .../Helpers/FilesPickerKitHelper.swift | 64 +++++++++ .../FilesPickerKit/Models/Constants.swift | 32 ----- .../Pickers/DocumentInteractionService.swift | 71 +++------- .../Pickers/DocumentPickerService.swift | 77 +++-------- .../Pickers/MediaPickerService.swift | 84 +++--------- .../DocumentInteractionProtocol.swift | 12 -- .../Protocols/FilePickerProtocol.swift | 3 +- 10 files changed, 269 insertions(+), 376 deletions(-) delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 8f4685eb8..4b0525efb 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -13,6 +13,9 @@ import UIKit import SnapKit import CommonKit import FilesStorageKit +import PhotosUI +import FilesPickerKit +import QuickLook @MainActor final class ChatViewController: MessagesViewController { @@ -75,6 +78,10 @@ final class ChatViewController: MessagesViewController { return data }() + private lazy var mediaPickerDelegate = MediaPickerService() + private lazy var documentPickerDelegate = DocumentPickerService() + private lazy var documentViewerService = DocumentInteractionService() + init( viewModel: ChatViewModel, walletServiceCompose: WalletServiceCompose, @@ -382,6 +389,24 @@ private extension ChatViewController { 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] (url, file) in + self?.presentDocumentViewer(url: url, file: file) + } + .store(in: &subscriptions) } } @@ -473,6 +498,72 @@ private extension ChatViewController { messagesCollectionView.addGestureRecognizer(panGesture) messagesCollectionView.clipsToBounds = false } + + func presentMediaPicker() { + mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in + DispatchQueue.main.async { + self?.viewModel.presentDialog(progress: false) + self?.viewModel.processFileResult(result) + } + } + + mediaPickerDelegate.onPreparingDataCallback = { [weak self] in + DispatchQueue.main.async { + self?.viewModel.presentDialog(progress: true) + } + } + + var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) + phPickerConfig.selectionLimit = FilesConstants.maxFilesCount + phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) + + let phPickerVC = PHPickerViewController(configuration: phPickerConfig) + phPickerVC.delegate = mediaPickerDelegate + present(phPickerVC, animated: true) + } + + func presentDocumentPicker() { + documentPickerDelegate.onPreparedDataCallback = { [weak self] result in + DispatchQueue.main.async { + self?.viewModel.presentDialog(progress: false) + self?.viewModel.processFileResult(result) + } + } + + documentPickerDelegate.onPreparingDataCallback = { [weak self] in + DispatchQueue.main.async { + self?.viewModel.presentDialog(progress: true) + } + } + + let documentPicker = UIDocumentPickerViewController( + forOpeningContentTypes: [.data, .content], + asCopy: false + ) + documentPicker.allowsMultipleSelection = true + documentPicker.delegate = documentPickerDelegate + present(documentPicker, animated: true) + } + + func presentDocumentViewer(url: URL, file: ChatFile) { + documentViewerService.openFile( + url: url, + name: file.file.file_name ?? .empty, + size: file.file.file_size, + ext: file.file.file_type ?? .empty + ) + + let quickVC = QLPreviewController() + quickVC.delegate = documentViewerService + quickVC.dataSource = documentViewerService + quickVC.modalPresentationStyle = .fullScreen + + if let splitViewController = splitViewController { + splitViewController.present(quickVC, animated: true) + } else { + present(quickVC, animated: true) + } + } } // MARK: Tap on title view diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 47b7b03bf..3ab95fac1 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -82,6 +82,9 @@ final class ChatViewModel: NSObject { let layoutIfNeeded = ObservableSender() let presentFilePicker = ObservableSender() let presentSendTokensVC = ObservableSender() + let presentMediaPickerVC = ObservableSender() + let presentDocumentPickerVC = ObservableSender() + let presentDocumentViewerVC = ObservableSender<(URL, ChatFile)>() @ObservableValue private(set) var isHeaderLoading = false @ObservableValue private(set) var fullscreenLoading = false @@ -762,54 +765,48 @@ final class ChatViewModel: NSObject { guard let keyPair = accountService.keypair else { return } Task { - if !file.isCached { - defer { - downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) - } - downloadingFilesID.append(file.file.file_id) - - do { - try await filesStorage.downloadFile( - id: file.file.file_id, - storage: file.storage, - fileType: file.file.file_type ?? .empty, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: file.nonce, - previewId: file.file.preview_id, - previewNonce: file.file.preview_nonce - ) - - let previewID: String - if let id = file.file.preview_id { - previewID = id - } else { - previewID = file.file.file_id - } - - let preview = filesStorage.getPreview( - for: previewID, - type: file.file.file_type ?? "" - ) - - let cached = filesStorage.isCached(file.file.file_id) - - updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) - } catch { - dialog.send(.alert(error.localizedDescription)) - } - + guard !file.isCached else { + let url = try filesStorage.getFileURL(with: file.file.file_id) + presentDocumentViewerVC.send((url, file)) return } - let data = try filesStorage.getFileURL(with: file.file.file_id) + defer { + downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) + } - FilesPickerKit.shared.openFile( - url: data, - name: file.file.file_name ?? .empty, - size: file.file.file_size, - ext: file.file.file_type ?? .empty - ) + downloadingFilesID.append(file.file.file_id) + + do { + try await filesStorage.downloadFile( + id: file.file.file_id, + storage: file.storage, + fileType: file.file.file_type ?? .empty, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + nonce: file.nonce, + previewId: file.file.preview_id, + previewNonce: file.file.preview_nonce + ) + + let previewID: String + if let id = file.file.preview_id { + previewID = id + } else { + previewID = file.file.file_id + } + + let preview = filesStorage.getPreview( + for: previewID, + type: file.file.file_type ?? "" + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) + } catch { + dialog.send(.alert(error.localizedDescription)) + } } } @@ -820,31 +817,30 @@ final class ChatViewModel: NSObject { func didSelectMenuAction(_ action: ShareType) { if case(.sendTokens) = action { presentSendTokensVC.send() - return } - Task { - do { - var result: [FileResult] = [] - // dialog.send(.progress(true)) - - if case(.uploadFile) = action { - result = try await FilesPickerKit.shared.presentDocumentPicker() - } - if case(.uploadMedia) = action { - result = try await FilesPickerKit.shared.presentImagePicker() - } - - presentFilePicker.send(action) - - dialog.send(.progress(false)) - filesPicked = result - } catch { - dialog.send(.progress(false)) - dialog.send(.alert(error.localizedDescription)) - } + 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): + filesPicked = files + case .failure(let error): + dialog.send(.alert(error.localizedDescription)) } } + + func presentDialog(progress: Bool) { + dialog.send(.progress(progress)) + } } extension ChatViewModel { diff --git a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift deleted file mode 100644 index 3af08c62b..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/FilesPickerKit.swift +++ /dev/null @@ -1,85 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -import CommonKit -import UIKit -import SwiftUI - -public final class FilesPickerKit: NSObject { - public static let shared = FilesPickerKit() - - private let mediaPicker: FilePickerProtocol - private let documentPicker: FilePickerProtocol - private let documentInteration: DocumentInteractionProtocol - - public override init() { - mediaPicker = MediaPickerService() - documentPicker = DocumentPickerService() - documentInteration = DocumentInteractionService() - } - - @MainActor - public func presentImagePicker() async throws -> [FileResult] { - try await withUnsafeThrowingContinuation { continuation in - mediaPicker.startPicker { [weak self] data in - do { - try self?.validateFiles(data) - } catch { - continuation.resume(throwing: error) - return - } - - continuation.resume(returning: data) - } - } - } - - @MainActor - public func presentDocumentPicker() async throws -> [FileResult] { - try await withUnsafeThrowingContinuation { continuation in - documentPicker.startPicker { [weak self] data in - do { - try self?.validateFiles(data) - } catch { - continuation.resume(throwing: error) - return - } - - continuation.resume(returning: data) - } - } - } - - public func openFile(url: URL, name: String, size: Int64, ext: String) { - let fullName = name.contains(ext) - ? name - : "\(name).\(ext)" - - var copyURL = URL(fileURLWithPath: url.deletingLastPathComponent().path) - copyURL.appendPathComponent(fullName) - - if FileManager.default.fileExists(atPath: copyURL.path) { - try? FileManager.default.removeItem(at: copyURL) - } - - try? FileManager.default.copyItem(at: url, to: copyURL) - - documentInteration.open(url: copyURL, name: fullName) { [copyURL] in - try? FileManager.default.removeItem(at: copyURL) - } - } -} - -private extension FilesPickerKit { - 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 - } - } - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift new file mode 100644 index 000000000..e345675c6 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -0,0 +1,64 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +import CommonKit +import UIKit +import SwiftUI + +final class FilesPickerKitHelper { + 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 + } + } + } + + func getUrl(for image: UIImage?, name: String) throws -> URL { + guard let data = image?.jpegData(compressionQuality: 1.0) else { + throw FileValidationError.fileNotFound + } + + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("cachePath") + + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + let fileURL = folder.appendingPathComponent(name) + + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + + return fileURL + } + + func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + let size = image.size + + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + + var newSize: CGSize + if(widthRatio > heightRatio) { + newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) + } else { + newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) + } + + let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) + + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage! + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 7b3915c06..0a7c97957 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -12,35 +12,3 @@ public final class FilesConstants { public static let maxFilesCount = 5 static let maxFileSize: Int64 = 10 * 1024 * 1024 } - -extension UIApplication { - func topViewController() -> UIViewController? { - var topViewController: UIViewController? = nil - if #available(iOS 13, *) { - for scene in connectedScenes { - if let windowScene = scene as? UIWindowScene { - for window in windowScene.windows { - if window.isKeyWindow { - topViewController = window.rootViewController - } - } - } - } - } else { - topViewController = keyWindow?.rootViewController - } - while true { - if let presented = topViewController?.presentedViewController { - topViewController = presented - } else if let navController = topViewController as? UINavigationController { - topViewController = navController.topViewController - } else if let tabBarController = topViewController as? UITabBarController { - topViewController = tabBarController.selectedViewController - } else { - // Handle any other third party container in `else if` if required - break - } - } - return topViewController - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index 8a0e88f49..bc7d1bfa6 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -12,55 +12,38 @@ import SwiftUI import WebKit import QuickLook -final class DocumentInteractionService: NSObject, DocumentInteractionProtocol { - private var documentInteractionController: UIDocumentInteractionController? - private var completion: (() -> Void)? - +public final class DocumentInteractionService: NSObject { private var url: URL! - func open(url: URL, name: String, completion: (() -> Void)?) { - self.completion = completion - self.url = url - -// documentInteractionController = UIDocumentInteractionController(url: url) -// documentInteractionController?.delegate = self -// -// guard isMacOS else { -// documentInteractionController?.presentPreview(animated: true) -// return -// } - - let vc = UIApplication.shared.topViewController()! + public func openFile(url: URL, name: String, size: Int64, ext: String) { + let fullName = name.contains(ext) + ? name + : "\(name).\(ext)" -// guard let uiImage = UIImage(contentsOfFile: url.path) else { -// documentInteractionController?.presentOpenInMenu(from: vc.view.frame, in: vc.view, animated: true) -// return -// } + var copyURL = URL(fileURLWithPath: url.deletingLastPathComponent().path) + copyURL.appendPathComponent(fullName) -// let view = ImageViewer(image: uiImage, caption: name) -// present(view: view) + if FileManager.default.fileExists(atPath: copyURL.path) { + try? FileManager.default.removeItem(at: copyURL) + } - let quickVC = QLPreviewController() - quickVC.delegate = self - quickVC.dataSource = self - quickVC.modalPresentationStyle = .fullScreen - vc.present(quickVC, animated: true) + try? FileManager.default.copyItem(at: url, to: copyURL) - documentInteractionController = nil + self.url = copyURL } } extension DocumentInteractionService: QLPreviewControllerDelegate, QLPreviewControllerDataSource { - func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { QLPreviewItemEq(url: url) } - func previewControllerDidDismiss(_ controller: QLPreviewController) { - completion?() + public func previewControllerDidDismiss(_ controller: QLPreviewController) { + try? FileManager.default.removeItem(at: url) } } @@ -71,25 +54,3 @@ final class QLPreviewItemEq: NSObject, QLPreviewItem { previewItemURL = url } } - -private extension DocumentInteractionService { - func present(view: some View) { - let vc = UIHostingController( - rootView: view - ) - vc.modalPresentationStyle = .overCurrentContext - vc.view.backgroundColor = .clear - UIApplication.shared.topViewController()?.present(vc, animated: false) - } -} - -extension DocumentInteractionService: UIDocumentInteractionControllerDelegate { - public func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { - return UIApplication.shared.topViewController()! - } - - public func documentInteractionControllerDidEndPreview(_ controller: UIDocumentInteractionController) { - documentInteractionController = nil - completion?() - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index 42b0e0978..c87710d2d 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -10,25 +10,17 @@ import UIKit import CommonKit import MobileCoreServices -final class DocumentPickerService: NSObject, FilePickerProtocol { - let documentPicker = UIDocumentPickerViewController( - forOpeningContentTypes: [.data, .content], - asCopy: false - ) +public final class DocumentPickerService: NSObject, FilePickerProtocol { + private var helper = FilesPickerKitHelper() - private var onPreparedDataCallback: (([FileResult]) -> Void)? - - func startPicker(completion: (([FileResult]) -> Void)?) { - onPreparedDataCallback = completion - - documentPicker.allowsMultipleSelection = true - documentPicker.delegate = self - UIApplication.shared.topViewController()?.present(documentPicker, animated: true) - } + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + + public override init() { } } extension DocumentPickerService: UIDocumentPickerDelegate { - func documentPicker( + public func documentPicker( _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL] ) { @@ -46,7 +38,12 @@ extension DocumentPickerService: UIDocumentPickerDelegate { ) } - onPreparedDataCallback?(files) + do { + try helper.validateFiles(files) + onPreparedDataCallback?(.success(files)) + } catch { + onPreparedDataCallback?(.failure(error)) + } } } @@ -84,53 +81,9 @@ private extension DocumentPickerService { return (image: nil, url: nil) } - let resizedImage = resizeImage(image: image, targetSize: .init(squareSize: 50)) - let imageURL = try? getUrl(for: resizedImage, name: url.lastPathComponent) + let resizedImage = helper.resizeImage(image: image, targetSize: .init(squareSize: 50)) + let imageURL = try? helper.getUrl(for: resizedImage, name: url.lastPathComponent) return (image: resizedImage, url: imageURL) } - - func getUrl(for image: UIImage?, name: String) throws -> URL { - guard let data = image?.jpegData(compressionQuality: 1.0) else { - throw FileValidationError.fileNotFound - } - - let folder = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ).appendingPathComponent("cachePath") - - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - - let fileURL = folder.appendingPathComponent(name) - - try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - - return fileURL - } - - func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { - let size = image.size - - let widthRatio = targetSize.width / size.width - let heightRatio = targetSize.height / size.height - - var newSize: CGSize - if(widthRatio > heightRatio) { - newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) - } else { - newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) - } - - let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: rect) - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage! - } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 71fbe3b65..56960f0d8 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -10,28 +10,23 @@ import UIKit import Photos import PhotosUI -final class MediaPickerService: NSObject, FilePickerProtocol { - private var onPreparedDataCallback: (([FileResult]) -> Void)? - - func startPicker(completion: (([FileResult]) -> Void)?) { - onPreparedDataCallback = completion - - var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) - phPickerConfig.selectionLimit = FilesConstants.maxFilesCount - phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) - - let phPickerVC = PHPickerViewController(configuration: phPickerConfig) - phPickerVC.delegate = self - UIApplication.shared.topViewController()?.present(phPickerVC, animated: true) - } +public final class MediaPickerService: NSObject, FilePickerProtocol { + private var helper = FilesPickerKitHelper() + + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + + public override init() { } } extension MediaPickerService: PHPickerViewControllerDelegate { - func picker( + public func picker( _ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult] ) { picker.dismiss(animated: true, completion: .none) + onPreparingDataCallback?() + Task { await processResults(results) } @@ -55,9 +50,9 @@ private extension MediaPickerService { let fileSize = try? getFileSize(from: url) else { continue } - let resizedPreview = self.resizeImage(image: preview, targetSize: .init(squareSize: 50)) + let resizedPreview = helper.resizeImage(image: preview, targetSize: .init(squareSize: 50)) - let previewUrl = try? getUrl(for: resizedPreview, name: url.lastPathComponent) + let previewUrl = try? helper.getUrl(for: resizedPreview, name: url.lastPathComponent) dataArray.append( .init( @@ -78,7 +73,7 @@ private extension MediaPickerService { else { continue } let preview = getThumbnailImage(forUrl: url) - let previewUrl = try? getUrl(for: preview, name: url.lastPathComponent) + let previewUrl = try? helper.getUrl(for: preview, name: url.lastPathComponent) dataArray.append( .init( @@ -94,7 +89,12 @@ private extension MediaPickerService { } } - onPreparedDataCallback?(dataArray) + do { + try helper.validateFiles(dataArray) + onPreparedDataCallback?(.success(dataArray)) + } catch { + onPreparedDataCallback?(.failure(error)) + } } func getFileSize(from fileURL: URL) throws -> Int64 { @@ -173,54 +173,10 @@ private extension MediaPickerService { let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) let image = UIImage(cgImage: thumbnailImage) - let resizedImage = resizeImage(image: image, targetSize: .init(squareSize: 50)) + let resizedImage = helper.resizeImage(image: image, targetSize: .init(squareSize: 50)) return resizedImage } catch { return nil } } - - func getUrl(for image: UIImage?, name: String) throws -> URL { - guard let data = image?.jpegData(compressionQuality: 1.0) else { - throw FileValidationError.fileNotFound - } - - let folder = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ).appendingPathComponent("cachePath") - - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - - let fileURL = folder.appendingPathComponent(name) - - try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - - return fileURL - } - - func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { - let size = image.size - - let widthRatio = targetSize.width / size.width - let heightRatio = targetSize.height / size.height - - var newSize: CGSize - if(widthRatio > heightRatio) { - newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) - } else { - newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) - } - - let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: rect) - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage! - } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift deleted file mode 100644 index 0f263fc7c..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/DocumentInteractionProtocol.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// DocumentInteractionProtocol.swift -// -// -// Created by Stanislav Jelezoglo on 14.03.2024. -// - -import Foundation - -protocol DocumentInteractionProtocol { - func open(url: URL, name: String, completion: (() -> Void)?) -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift index f15f75062..7c3609d70 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift @@ -10,5 +10,6 @@ import UIKit import CommonKit protocol FilePickerProtocol { - func startPicker(completion: (([FileResult]) -> Void)?) + var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? { get set } + var onPreparingDataCallback: (() -> Void)? { get set } } From e186cec4cd4e9f1ef7a84de22677fdcfc788a2ad Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 21 Mar 2024 11:45:37 +0200 Subject: [PATCH 029/123] [trello.com/c/uxBZaznD] fix: do not open file while is uploading --- Adamant/Modules/Chat/View/Managers/ChatAction.swift | 2 +- .../Chat/View/Managers/ChatDataSourceManager.swift | 4 ++-- .../ChatMedia/Content/ChatMediaContnentView+Model.swift | 2 ++ .../Views/ChatFileContainerView/FileContainerView.swift | 3 ++- .../Views/MediaContainerView/MediaContainerView.swift | 3 ++- Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift | 1 + Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 8 ++++++-- 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index 46207b4fe..1af759a0b 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -20,5 +20,5 @@ enum ChatAction { case remove(id: String) case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) - case processFile(file: ChatFile, isFromCurrentSender: Bool) + case openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 2e2ba91a6..52ca95c61 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -192,8 +192,8 @@ private extension ChatDataSourceManager { viewModel.reactAction(id, emoji: emoji) case let .presentMenu(arg): viewModel.presentMenu(arg: arg) - case let .processFile(file, isFromCurrentSender): - viewModel.processFile(file: file, isFromCurrentSender: isFromCurrentSender) + case let .openFile(messageId, file, isFromCurrentSender): + viewModel.openFile(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift index c7bedbbb6..aef97170d 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -35,11 +35,13 @@ extension ChatMediaContentView { } struct FileModel: Equatable { + let messageId: String var files: [ChatFile] var isMediaFilesOnly: Bool let isFromCurrentSender: Bool static let `default` = Self( + messageId: .empty, files: [], isMediaFilesOnly: false, isFromCurrentSender: false diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index d5f6533e3..c7fe71172 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -64,7 +64,8 @@ private extension FileContainerView { view?.model = file view?.buttonActionHandler = { [actionHandler, file, model] in actionHandler( - .processFile( + .openFile( + messageId: model.messageId, file: file, isFromCurrentSender: model.isFromCurrentSender ) 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 index 41fdaeab9..cf1a01d5f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -88,7 +88,8 @@ private extension MediaContainerView { mediaView.model = file mediaView.buttonActionHandler = { [actionHandler, file, model] in actionHandler( - .processFile( + .openFile( + messageId: model.messageId, file: file, isFromCurrentSender: model.isFromCurrentSender ) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 188e03157..0bc2fd2dd 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -358,6 +358,7 @@ private extension ChatMessageFactory { content: .init( id: id, fileModel: .init( + messageId: id, files: chatFiles, isMediaFilesOnly: isMediaFilesOnly, isFromCurrentSender: isFromCurrentSender diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 3ab95fac1..c52bda397 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -761,8 +761,12 @@ final class ChatViewModel: NSObject { return true } - func processFile(file: ChatFile, isFromCurrentSender: Bool) { - guard let keyPair = accountService.keypair else { return } + func openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) { + let tx = chatTransactions.first(where: { $0.txId == messageId }) + + guard let keyPair = accountService.keypair, + tx?.statusEnum == .delivered + else { return } Task { guard !file.isCached else { From cf25959c89f385d9d14d42bb38511597722d3e3e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 21 Mar 2024 12:26:21 +0200 Subject: [PATCH 030/123] [trello.com/c/uxBZaznD] feat: inc preview resolution --- .../Sources/FilesPickerKit/Models/Constants.swift | 1 + .../FilesPickerKit/Pickers/DocumentPickerService.swift | 5 ++++- .../FilesPickerKit/Pickers/MediaPickerService.swift | 10 ++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 0a7c97957..4d6c10ba2 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -11,4 +11,5 @@ import UIKit public final class FilesConstants { public static let maxFilesCount = 5 static let maxFileSize: Int64 = 10 * 1024 * 1024 + static let previewSize: CGSize = .init(squareSize: 300) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index c87710d2d..086cb4b06 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -81,7 +81,10 @@ private extension DocumentPickerService { return (image: nil, url: nil) } - let resizedImage = helper.resizeImage(image: image, targetSize: .init(squareSize: 50)) + let resizedImage = helper.resizeImage( + image: image, + targetSize: FilesConstants.previewSize + ) let imageURL = try? helper.getUrl(for: resizedImage, name: url.lastPathComponent) return (image: resizedImage, url: imageURL) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 56960f0d8..c9af84a0d 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -50,7 +50,10 @@ private extension MediaPickerService { let fileSize = try? getFileSize(from: url) else { continue } - let resizedPreview = helper.resizeImage(image: preview, targetSize: .init(squareSize: 50)) + let resizedPreview = helper.resizeImage( + image: preview, + targetSize: FilesConstants.previewSize + ) let previewUrl = try? helper.getUrl(for: resizedPreview, name: url.lastPathComponent) @@ -173,7 +176,10 @@ private extension MediaPickerService { let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) let image = UIImage(cgImage: thumbnailImage) - let resizedImage = helper.resizeImage(image: image, targetSize: .init(squareSize: 50)) + let resizedImage = helper.resizeImage( + image: image, + targetSize: FilesConstants.previewSize + ) return resizedImage } catch { return nil From 533aa8cb53aeb1a48a94c769c32e8e7721851c8b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 21 Mar 2024 13:34:45 +0200 Subject: [PATCH 031/123] [trello.com/c/uxBZaznD] fix: media extensions for preview --- .../Chat/ViewModel/ChatMessageFactory.swift | 5 ++-- .../Helpers/FilesPickerKitHelper.swift | 19 +++++++++++++ .../Pickers/DocumentPickerService.swift | 28 ++++++++++++++----- .../Pickers/MediaPickerService.swift | 22 ++------------- .../FilesStorageKit/FilesStorageKit.swift | 5 ++-- 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 0bc2fd2dd..ae648000c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -344,11 +344,10 @@ private extension ChatMessageFactory { } let filesExtensions = chatFiles.map { $0.file.file_type } - let imageExtensions = ["JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "RAW", "BMP", "HEIF", "INDD"] let isMediaFilesOnly = filesExtensions.allSatisfy { elementA in guard let elementA = elementA else { return false } - return imageExtensions.contains(elementA) + return mediaExtensions.contains(elementA.uppercased()) } return .file(.init(value: .init( @@ -524,3 +523,5 @@ private extension ChatSender { ) } } + +private let mediaExtensions = ["JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2", "MOV", "MP4"] diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index e345675c6..29d22c5e0 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -4,6 +4,7 @@ import CommonKit import UIKit import SwiftUI +import AVFoundation final class FilesPickerKitHelper { func validateFiles(_ files: [FileResult]) throws { @@ -61,4 +62,22 @@ final class FilesPickerKitHelper { return newImage! } + + func getThumbnailImage(forUrl url: URL) -> UIImage? { + let asset: AVAsset = AVAsset(url: url) + let imageGenerator = AVAssetImageGenerator(asset: asset) + + do { + let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) + + let image = UIImage(cgImage: thumbnailImage) + let resizedImage = resizeImage( + image: image, + targetSize: FilesConstants.previewSize + ) + return resizedImage + } catch { + return nil + } + } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index 086cb4b06..e0a8909a1 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import CommonKit import MobileCoreServices +import AVFoundation public final class DocumentPickerService: NSObject, FilePickerProtocol { private var helper = FilesPickerKitHelper() @@ -60,12 +61,17 @@ private extension DocumentPickerService { return fileSize } - func isImage(atURL fileURL: URL) -> Bool { - guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, fileURL.pathExtension as CFString, nil)?.takeRetainedValue() else { - return false + func isFileType(format: UTType, atURL fileURL: URL) -> Bool { + var mimeType: String? + + let pathExtension = fileURL.pathExtension + if let type = UTType(filenameExtension: pathExtension) { + mimeType = type.preferredMIMEType } - return UTTypeConformsTo(uti, kUTTypeImage) + guard let mimeType = mimeType else { return false } + + return UTType(mimeType: mimeType)?.conforms(to: format) ?? false } func getPreview(for url: URL) -> (image: UIImage?, url: URL?) { @@ -75,9 +81,17 @@ private extension DocumentPickerService { _ = url.startAccessingSecurityScopedResource() - guard isImage(atURL: url), - let image = UIImage(contentsOfFile: url.path) - else { + var image: UIImage? + + if isFileType(format: .image, atURL: url) { + image = UIImage(contentsOfFile: url.path) + } + + if isFileType(format: .movie, atURL: url) { + image = helper.getThumbnailImage(forUrl: url) + } + + guard let image = image else { return (image: nil, url: nil) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index c9af84a0d..a78564829 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -65,7 +65,7 @@ private extension MediaPickerService { previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, - extenstion: "JPG" + extenstion: url.pathExtension ) ) } @@ -75,7 +75,7 @@ private extension MediaPickerService { let fileSize = try? getFileSize(from: url) else { continue } - let preview = getThumbnailImage(forUrl: url) + let preview = helper.getThumbnailImage(forUrl: url) let previewUrl = try? helper.getUrl(for: preview, name: url.lastPathComponent) dataArray.append( @@ -167,22 +167,4 @@ private extension MediaPickerService { } } } - - func getThumbnailImage(forUrl url: URL) -> UIImage? { - let asset: AVAsset = AVAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - - do { - let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) - - let image = UIImage(cgImage: thumbnailImage) - let resizedImage = helper.resizeImage( - image: image, - targetSize: FilesConstants.previewSize - ) - return resizedImage - } catch { - return nil - } - } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index ed0ba3e17..b296eb977 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -194,12 +194,13 @@ private extension FilesStorageKit { private func getPreview(for type: String, url: URL?) -> URL? { switch type.uppercased() { - case "JPG", "JPEG", "PNG", "JPEG2000", "GIF", "WEBP", "TIF", "TIFF", "RAW", "BMP", "HEIF", "INDD": + case "JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2": if let url = url { return url } + return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") - case "MOV", "MP4", "AVI", "WEBM": + case "MOV", "MP4": if let url = url { return url } From b61f63ee946b1c49fecc3c3801275d7c81b5f70e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 21 Mar 2024 16:19:32 +0200 Subject: [PATCH 032/123] [trello.com/c/uxBZaznD] feat: new design for presend files preview --- .../Chat/View/ChatViewController.swift | 33 +++++- .../FilesToolbarCollectionViewCell.swift | 110 ++++++++++++++---- .../FilesToolBarView/FilesToolbarView.swift | 46 +++----- .../Pickers/DocumentInteractionService.swift | 10 +- 4 files changed, 138 insertions(+), 61 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 4b0525efb..7e1fdd3e7 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -468,7 +468,7 @@ private extension ChatViewController { func configureFilesToolbarView() { filesToolbarView.snp.makeConstraints { make in - make.height.equalTo(70) + make.height.equalTo(140) } filesToolbarView.closeAction = { [weak self] in @@ -478,6 +478,10 @@ private extension ChatViewController { filesToolbarView.updatedDataAction = { [weak self] data in self?.viewModel.updateFiles(data) } + + filesToolbarView.openFileAction = { [weak self] data in + self?.presentDocumentViewer(url: data.url) + } } func configureGestures() { @@ -564,6 +568,21 @@ private extension ChatViewController { present(quickVC, animated: true) } } + + func presentDocumentViewer(url: URL) { + documentViewerService.openFile(url: url) + + let quickVC = QLPreviewController() + quickVC.delegate = documentViewerService + quickVC.dataSource = documentViewerService + quickVC.modalPresentationStyle = .fullScreen + + if let splitViewController = splitViewController { + splitViewController.present(quickVC, animated: true) + } else { + present(quickVC, animated: true) + } + } } // MARK: Tap on title view @@ -781,8 +800,10 @@ private extension ChatViewController { func closeReplyView() { replyView.removeFromSuperview() - messageInputBar.invalidateIntrinsicContentSize() - messageInputBar.layoutContainerViewIfNeeded() + + // TODO: Fix it later + // There's an issue: if the text in inputTextView is changed while replyView is positioned on the topStackView of the messageInputBar, removing it causes an incorrect height for the messageInputBar. Reinstalling the text will help recalculate the height. + messageInputBar.inputTextView.text = messageInputBar.inputTextView.text } func processFileToolbarView(_ data: [FileResult]?) { @@ -813,8 +834,10 @@ private extension ChatViewController { func closeFileToolbarView() { filesToolbarView.removeFromSuperview() - messageInputBar.invalidateIntrinsicContentSize() - messageInputBar.layoutContainerViewIfNeeded() + + // TODO: Fix it later + // There's an issue: if the text in inputTextView is changed while filesToolbarView is positioned on the topStackView of the messageInputBar, removing it causes an incorrect height for the messageInputBar. Reinstalling the text will help recalculate the height. + messageInputBar.inputTextView.text = messageInputBar.inputTextView.text } func didTapTransfer(id: String) { diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index 20be7972d..5ff11fab9 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -14,38 +14,49 @@ import CommonKit final class FilesToolbarCollectionViewCell: UICollectionViewCell { private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) - lazy var containerView: UIView = { + 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(removeBtn) - - imageView.layer.masksToBounds = true - imageView.contentMode = .scaleAspectFill - imageView.layer.cornerRadius = 5 - + view.addSubview(nameLabel) + view.addSubview(additionalLabel) + imageView.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(nameLabel.snp.top).offset(-7) } - removeBtn.snp.makeConstraints { make in - make.top.equalToSuperview().offset(-15) - make.trailing.equalToSuperview().offset(15) + + 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) } + return view }() private lazy var removeBtn: UIButton = { let btn = UIButton() + let config = UIImage.SymbolConfiguration(pointSize: 30) btn.setImage( - UIImage(systemName: "xmark.app.fill")?.withTintColor(.adamant.alert), + UIImage( + systemName: "checkmark.circle.fill", + withConfiguration: config + )?.withTintColor(.adamant.active), for: .normal ) + btn.tintColor = .adamant.active btn.addTarget(self, action: #selector(didTapRemoveBtn), for: .touchUpInside) - - btn.snp.makeConstraints { make in - make.size.equalTo(40) - } return btn }() @@ -55,20 +66,13 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) - setupView() + configure() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func setupView() { - addSubview(containerView) - containerView.snp.makeConstraints { make in - make.directionalEdges.equalToSuperview() - } - } - @objc private func didTapRemoveBtn() { buttonActionHandler?(removeBtn.tag) } @@ -76,7 +80,65 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { 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 + + 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) + } + + removeBtn.layer.shadowColor = UIColor.black.cgColor + removeBtn.layer.shadowOffset = .zero + removeBtn.layer.shadowOpacity = 0.45 + removeBtn.layer.shadowRadius = 3.0 + removeBtn.layer.masksToBounds = false + removeBtn.layer.cornerRadius = 4.0 + + imageView.layer.masksToBounds = true + imageView.contentMode = .scaleAspectFill + nameLabel.textAlignment = .center + nameLabel.lineBreakMode = .byTruncatingMiddle + } + + 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: "file-default-box") +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 index e993be33e..62729a095 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -46,35 +46,6 @@ final class FilesToolbarView: UIView { return view }() - private lazy var iconView: UIView = { - let view = UIView() - view.addSubview(iconIV) - iconIV.snp.makeConstraints { make in - make.center.equalToSuperview() - } - - view.snp.makeConstraints { make in - make.width.equalTo(27) - } - return view - }() - - private var iconIV: UIImageView = { - let iv = UIImageView( - image: UIImage( - systemName: "square.and.arrow.up" - )?.withTintColor(.adamant.active) - ) - - iv.tintColor = .adamant.active - iv.snp.makeConstraints { make in - make.height.equalTo(27) - make.width.equalTo(24) - } - - return iv - }() - private lazy var closeBtn: UIButton = { let btn = UIButton() btn.setImage( @@ -90,7 +61,7 @@ final class FilesToolbarView: UIView { }() private lazy var horizontalStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [iconView, containerView, closeBtn]) + let stack = UIStackView(arrangedSubviews: [containerView, closeBtn]) stack.axis = .horizontal stack.spacing = horizontalStackSpacing return stack @@ -101,6 +72,7 @@ final class FilesToolbarView: UIView { private var data: [FileResult] = [] var closeAction: (() -> Void)? var updatedDataAction: (([FileResult]) -> Void)? + var openFileAction: ((FileResult) -> Void)? // MARK: Init @@ -142,7 +114,7 @@ extension FilesToolbarView { } } -extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource { +extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView( _ collectionView: UICollectionView, numberOfItemsInSection section: Int @@ -167,6 +139,18 @@ extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource } return cell } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + .init(width: self.frame.height - 10, height: self.frame.height - 10) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + openFileAction?(data[indexPath.row]) + } } private let horizontalStackSpacing: CGFloat = 25 diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index bc7d1bfa6..9c7efa9db 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -14,6 +14,7 @@ import QuickLook public final class DocumentInteractionService: NSObject { private var url: URL! + private var needToDelete = false public func openFile(url: URL, name: String, size: Int64, ext: String) { let fullName = name.contains(ext) @@ -29,7 +30,13 @@ public final class DocumentInteractionService: NSObject { try? FileManager.default.copyItem(at: url, to: copyURL) - self.url = copyURL + self.url = copyURL + needToDelete = true + } + + public func openFile(url: URL) { + self.url = url + needToDelete = false } } @@ -43,6 +50,7 @@ extension DocumentInteractionService: QLPreviewControllerDelegate, QLPreviewCont } public func previewControllerDidDismiss(_ controller: QLPreviewController) { + guard needToDelete else { return } try? FileManager.default.removeItem(at: url) } } From 8d7af4498624f3839ce77f4888b5f7b0d2116646 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 21 Mar 2024 16:32:38 +0200 Subject: [PATCH 033/123] [trello.com/c/uxBZaznD] fix: preview cells height --- .../FileContainerView.swift | 10 +++++----- .../MediaContainerView/MediaContainerView.swift | 13 +++---------- .../CommonKit/Helpers/Array+adamant.swift | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Helpers/Array+adamant.swift diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index c7fe71172..1afd72e49 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -16,17 +16,20 @@ final class FileContainerView: UIView { private lazy var filesStack: UIStackView = { let stack = UIStackView() stack.axis = .vertical - stack.spacing = stackSpacing + stack.spacing = Self.stackSpacing for _ in 0.. [[Element]] { - return stride(from: 0, to: count, by: size).map { - Array(self[$0 ..< Swift.min($0 + size, count)]) - } - } -} - extension ChatMediaContentView.FileModel { func height() -> CGFloat { let fileList = Array(files.prefix(FilesConstants.maxFilesCount)) guard isMediaFilesOnly else { - return 70 * CGFloat(fileList.count) + return FileContainerView.cellSize * CGFloat(fileList.count) + + FileContainerView.stackSpacing * CGFloat(fileList.count) } let rowCount = fileList.chunked(into: 2).count 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)]) + } + } +} From eb3cdc80169ec5012bd9a83de7ecaf1f45482ae8 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 26 Mar 2024 12:23:19 +0200 Subject: [PATCH 034/123] [trello.com/c/uxBZaznD] fix: design for preview & video cells --- .../View/Helpers/AdamantCellAnimation.swift | 16 +++++++++++++ .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +++- .../ChatFileContainerView/ChatFileView.swift | 20 ++++++++++++++-- .../MediaContainerView.swift | 1 + .../MediaContainerView/MediaContentView.swift | 23 +++++++++++++------ .../FilesToolbarCollectionViewCell.swift | 23 +++++++++++-------- .../FilesToolBarView/FilesToolbarView.swift | 15 +++--------- .../Chat/ViewModel/ChatMessageFactory.swift | 4 +++- Adamant/SharedViews/ReplyView.swift | 5 ++-- .../FilesStorageKit/FilesStorageKit.swift | 3 ++- 10 files changed, 79 insertions(+), 35 deletions(-) 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/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index ee5d3b433..358e6bb58 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -19,6 +19,7 @@ struct ChatFile: Equatable, Hashable { var storage: String var nonce: String var isFromCurrentSender: Bool + var isVideo: Bool static let `default` = Self( file: .init([:]), @@ -28,6 +29,7 @@ struct ChatFile: Equatable, Hashable { isCached: false, storage: .empty, nonce: .empty, - isFromCurrentSender: false + isFromCurrentSender: false, + isVideo: false ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 65ef0224a..7dba4cc6f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -12,7 +12,8 @@ import CommonKit class ChatFileView: UIView { private lazy var iconImageView: UIImageView = UIImageView() private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) - + private lazy var videoIconIV = UIImageView(image: .init(systemName: "play.circle")) + private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) view.isHidden = true @@ -119,6 +120,12 @@ private extension ChatFileView { 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() @@ -130,7 +137,11 @@ private extension ChatFileView { iconImageView.layer.cornerRadius = 5 iconImageView.layer.masksToBounds = true iconImageView.contentMode = .scaleAspectFill - additionalLabel.textAlignment = .center + additionalLabel.textAlignment = .center + videoIconIV.tintColor = .adamant.active + + videoIconIV.addShadow() + downloadImageView.addShadow() } func update() { @@ -159,6 +170,11 @@ private extension ChatFileView { sizeLabel.text = formatSize(model.file.file_size) additionalLabel.text = fileType.uppercased() + + videoIconIV.isHidden = !(model.isCached + && !model.isDownloading + && !model.isUploading + && model.isVideo) } func formatSize(_ bytes: Int64) -> String { 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 index 8fdcd3069..f7bfcfe0b 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -19,6 +19,7 @@ final class MediaContainerView: UIView { stack.alignment = .fill stack.distribution = .fill stack.layer.masksToBounds = true + stack.layer.cornerRadius = 7 for chunk in 0..<3 { let stackView = UIStackView() 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 index c1286a68f..7690aa167 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -13,6 +13,7 @@ import SnapKit 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: .init(systemName: "play.circle")) private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) @@ -55,12 +56,6 @@ final class MediaContentView: UIView { configure() } - override func layoutSubviews() { - super.layoutSubviews() - - imageView.layer.cornerRadius = 5 - } - @objc func tapBtnAction() { buttonActionHandler?() } @@ -89,9 +84,18 @@ private extension MediaContentView { make.directionalEdges.equalToSuperview() } - imageView.layer.cornerRadius = 5 + addSubview(videoIconIV) + videoIconIV.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.size.equalTo(imageSize / 1.6) + } + imageView.layer.masksToBounds = true imageView.contentMode = .scaleAspectFill + videoIconIV.tintColor = .adamant.active + + videoIconIV.addShadow() + downloadImageView.addShadow() } func update() { @@ -103,6 +107,11 @@ private extension MediaContentView { downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading + videoIconIV.isHidden = !(model.isCached + && !model.isDownloading + && !model.isUploading + && model.isVideo) + if model.isDownloading || model.isUploading { spinner.startAnimating() } else { diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index 5ff11fab9..c656319b7 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -13,7 +13,7 @@ import CommonKit final class FilesToolbarCollectionViewCell: UICollectionViewCell { private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) - + private lazy var videoIconIV = UIImageView(image: .init(systemName: "play.circle")) private lazy var nameLabel = UILabel(font: nameFont, textColor: .adamant.textColor) private let additionalLabel = UILabel(font: additionalFont, textColor: .adamant.cellColor) @@ -26,7 +26,8 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { 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) @@ -42,6 +43,11 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { make.center.equalTo(imageView.snp.center) } + videoIconIV.snp.makeConstraints { make in + make.center.equalTo(imageView.snp.center) + make.size.equalTo(30) + } + return view }() @@ -91,6 +97,7 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { additionalLabel.text = fileType.uppercased() additionalLabel.isHidden = file.preview != nil + videoIconIV.isHidden = file.type != .video layoutConstraints(file) } } @@ -109,17 +116,15 @@ private extension FilesToolbarCollectionViewCell { make.size.equalTo(25) } - removeBtn.layer.shadowColor = UIColor.black.cgColor - removeBtn.layer.shadowOffset = .zero - removeBtn.layer.shadowOpacity = 0.45 - removeBtn.layer.shadowRadius = 3.0 - removeBtn.layer.masksToBounds = false - removeBtn.layer.cornerRadius = 4.0 - 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) { diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift index 62729a095..70bd90e82 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -29,19 +29,10 @@ final class FilesToolbarView: UIView { private lazy var containerView: UIView = { let view = UIView() - let colorView = UIView() - colorView.backgroundColor = .adamant.active - - view.addSubview(colorView) view.addSubview(collectionView) - colorView.snp.makeConstraints { - $0.top.leading.bottom.equalToSuperview() - $0.width.equalTo(2) - } collectionView.snp.makeConstraints { - $0.top.bottom.trailing.equalToSuperview() - $0.leading.equalTo(colorView.snp.trailing).offset(5) + $0.directionalEdges.equalToSuperview() } return view }() @@ -89,8 +80,8 @@ final class FilesToolbarView: UIView { func configure() { addSubview(horizontalStack) horizontalStack.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(verticalInsets) - $0.leading.trailing.equalToSuperview().inset(horizontalInsets) + $0.verticalEdges.equalToSuperview().inset(verticalInsets) + $0.horizontalEdges.equalToSuperview().inset(horizontalInsets) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index ae648000c..985a3249f 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -339,7 +339,8 @@ private extension ChatMessageFactory { isCached: filesStorage.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, - isFromCurrentSender: isFromCurrentSender + isFromCurrentSender: isFromCurrentSender, + isVideo: videoExtensions.contains(($0[RichContentKeys.file.file_type] as? String)?.uppercased() ?? .empty) ) } @@ -525,3 +526,4 @@ private extension ChatSender { } private let mediaExtensions = ["JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2", "MOV", "MP4"] +private let videoExtensions = ["MOV", "MP4"] diff --git a/Adamant/SharedViews/ReplyView.swift b/Adamant/SharedViews/ReplyView.swift index 3f958646d..ac730090b 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) } } @@ -127,3 +127,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/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index b296eb977..28abd0726 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -204,7 +204,8 @@ private extension FilesStorageKit { if let url = url { return url } - return nil + + return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") case "PDF": return getLocalImageUrl(by: "file-pdf-box", withExtension: "jpg") default: From 1b1798d3aa2f3cd414f274f4b2969db2a5bfe5b0 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 26 Mar 2024 17:21:14 +0200 Subject: [PATCH 035/123] [trello.com/c/uxBZaznD] feat: implemented file resolution in IAP & new design for preview in chat --- .../MediaContainerView.swift | 101 +++++++++++++++++- .../Chat/ViewModel/ChatViewModel.swift | 3 +- .../Helpers/UIHelpers/CGSize+adamant.swift | 7 ++ .../Sources/CommonKit/Models/FileResult.swift | 5 +- .../CommonKit/Models/RichMessage.swift | 20 +++- .../Helpers/FilesPickerKitHelper.swift | 6 +- .../Pickers/DocumentPickerService.swift | 9 +- .../Pickers/MediaPickerService.swift | 22 +++- 8 files changed, 151 insertions(+), 22 deletions(-) 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 index f7bfcfe0b..c515519b0 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -24,9 +24,9 @@ final class MediaContainerView: UIView { for chunk in 0..<3 { let stackView = UIStackView() stackView.axis = .horizontal - stackView.spacing = 1 + stackView.spacing = stackSpacing stackView.alignment = .fill - stackView.distribution = .fillEqually + stackView.distribution = .fill for file in 0..<2 { let view = MediaContentView() @@ -75,9 +75,12 @@ private extension MediaContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) + 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 } @@ -96,16 +99,87 @@ private extension MediaContainerView { ) ) } + + if let resolution = file.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 = filesStack.bounds.width == .zero + ? stackWidth + : filesStack.bounds.width + + 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.file.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.file.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) + $0.height.equalTo(height) + } + } else { + mediaView.snp.remakeConstraints { + $0.height.equalTo(height) + $0.width.equalTo((filesStackWidth - stackSpacing) / 2) + } + } } } + + func calculateMinimumWidth(availableWidth: CGFloat) -> CGFloat { + return (availableWidth - stackSpacing) * 0.3 + } + + func calculateMaximumWidth(availableWidth: CGFloat) -> CGFloat { + return (availableWidth - stackSpacing) * 0.7 + } } private let stackSpacing: CGFloat = 1 private let rowHeight: CGFloat = 240 +private let rowVerticalHeight: CGFloat = 200 +private let rowHorizontalHeight: CGFloat = 150 private let stackWidth: CGFloat = 280 extension ChatMediaContentView.FileModel { @@ -117,9 +191,26 @@ extension ChatMediaContentView.FileModel { + FileContainerView.stackSpacing * CGFloat(fileList.count) } - let rowCount = fileList.chunked(into: 2).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.file_resolution, + resolution.width > resolution.height { + isHorizontal = true + } + } + + let height: CGFloat = isHorizontal + ? rowHorizontalHeight + : rows.count == 1 ? rowVerticalHeight * 2 : rowVerticalHeight + + totalHeight += height + } - return rowHeight * CGFloat(rowCount) - + stackSpacing * CGFloat(rowCount) + return totalHeight + + stackSpacing * CGFloat(rows.count) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index c52bda397..be742b103 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -313,7 +313,8 @@ final class ChatViewModel: NSObject { preview_id: $0.previewUrl?.absoluteString, preview_nonce: nil, file_name: $0.name, - nonce: .empty + nonce: .empty, + file_resolution: $0.resolution ) } 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/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index a9ec011e5..47613bec5 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -21,6 +21,7 @@ public struct FileResult { public let size: Int64 public let name: String? public let extenstion: String? + public let resolution: CGSize? public init( url: URL, @@ -29,7 +30,8 @@ public struct FileResult { previewUrl: URL?, size: Int64, name: String?, - extenstion: String? + extenstion: String?, + resolution: CGSize? ) { self.url = url self.type = type @@ -38,5 +40,6 @@ public struct FileResult { self.name = name self.extenstion = extenstion self.preview = preview + self.resolution = resolution } } diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index e5ac6c5a3..c6d029f7f 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -58,6 +58,7 @@ public enum RichContentKeys { public static let file_name = "file_name" public static let nonce = "nonce" public static let preview_nonce = "preview_nonce" + public static let file_resolution = "file_resolution" } } @@ -99,6 +100,7 @@ public struct RichMessageFile: RichMessage { public var file_name: String? public var nonce: String public var preview_nonce: String? + public var file_resolution: CGSize? public init( file_id: String, @@ -107,7 +109,8 @@ public struct RichMessageFile: RichMessage { preview_id: String? = nil, preview_nonce: String? = nil, file_name: String? = nil, - nonce: String + nonce: String, + file_resolution: CGSize? = nil ) { self.file_id = file_id self.file_type = file_type @@ -116,6 +119,7 @@ public struct RichMessageFile: RichMessage { self.file_name = file_name self.nonce = nonce self.preview_nonce = preview_nonce + self.file_resolution = file_resolution } public init(_ data: [String: Any]) { @@ -126,6 +130,16 @@ public struct RichMessageFile: RichMessage { self.file_name = data[RichContentKeys.file.file_name] as? String self.nonce = data[RichContentKeys.file.nonce] as? String ?? .empty self.preview_nonce = data[RichContentKeys.file.preview_nonce] as? String ?? .empty + if let resolution = data[RichContentKeys.file.file_resolution] as? [CGFloat] { + self.file_resolution = .init( + width: resolution.first ?? .zero, + height: resolution.last ?? .zero + ) + } else if let resolution = data[RichContentKeys.file.file_resolution] as? CGSize { + self.file_resolution = resolution + } else { + self.file_resolution = nil + } } public func content() -> [String: Any] { @@ -149,6 +163,10 @@ public struct RichMessageFile: RichMessage { contentDict[RichContentKeys.file.file_name] = file_name } + if let file_resolution = file_resolution { + contentDict[RichContentKeys.file.file_resolution] = file_resolution + } + return contentDict } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 29d22c5e0..207350e5d 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -71,11 +71,7 @@ final class FilesPickerKitHelper { let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) let image = UIImage(cgImage: thumbnailImage) - let resizedImage = resizeImage( - image: image, - targetSize: FilesConstants.previewSize - ) - return resizedImage + return image } catch { return nil } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index e0a8909a1..acb77be02 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -35,7 +35,8 @@ extension DocumentPickerService: UIDocumentPickerDelegate { previewUrl: preview.url, size: (try? getFileSize(from: $0)) ?? .zero, name: $0.lastPathComponent, - extenstion: $0.pathExtension + extenstion: $0.pathExtension, + resolution: preview.resolution ) } @@ -74,7 +75,7 @@ private extension DocumentPickerService { return UTType(mimeType: mimeType)?.conforms(to: format) ?? false } - func getPreview(for url: URL) -> (image: UIImage?, url: URL?) { + func getPreview(for url: URL) -> (image: UIImage?, url: URL?, resolution: CGSize?) { defer { url.stopAccessingSecurityScopedResource() } @@ -92,7 +93,7 @@ private extension DocumentPickerService { } guard let image = image else { - return (image: nil, url: nil) + return (image: nil, url: nil, resolution: nil) } let resizedImage = helper.resizeImage( @@ -101,6 +102,6 @@ private extension DocumentPickerService { ) let imageURL = try? helper.getUrl(for: resizedImage, name: url.lastPathComponent) - return (image: resizedImage, url: imageURL) + return (image: resizedImage, url: imageURL, resolution: image.size) } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index a78564829..fd2f70e54 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -65,7 +65,8 @@ private extension MediaPickerService { previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, - extenstion: url.pathExtension + extenstion: url.pathExtension, + resolution: preview.size ) ) } @@ -75,18 +76,29 @@ private extension MediaPickerService { let fileSize = try? getFileSize(from: url) else { continue } - let preview = helper.getThumbnailImage(forUrl: url) - let previewUrl = try? helper.getUrl(for: preview, name: url.lastPathComponent) + let thumbnailImage = helper.getThumbnailImage(forUrl: url) + + var resizedPreviewImage: UIImage? + + if let preview = thumbnailImage { + resizedPreviewImage = helper.resizeImage( + image: preview, + targetSize: FilesConstants.previewSize + ) + } + + let previewUrl = try? helper.getUrl(for: resizedPreviewImage, name: url.lastPathComponent) dataArray.append( .init( url: url, type: .video, - preview: preview, + preview: resizedPreviewImage, previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, - extenstion: url.pathExtension + extenstion: url.pathExtension, + resolution: thumbnailImage?.size ) ) } From 26be8a0b383c812da6384d002143335a0e4dc3c0 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 27 Mar 2024 15:49:38 +0200 Subject: [PATCH 036/123] [trello.com/c/uxBZaznD] feat: implemented drop file interaction for macOS & iOS --- Adamant.xcodeproj/project.pbxproj | 4 + .../Chat/View/ChatViewController.swift | 43 +++++- .../Chat/View/Helpers/ChatDropView.swift | 54 ++++++++ .../Chat/ViewModel/ChatViewModel.swift | 5 + .../files/uploadIcon.imageset/Contents.json | 24 ++++ .../uploadIcon.imageset/cloud-computing.png | Bin 0 -> 6664 bytes .../Helpers/FilesPickerKitHelper.swift | 125 ++++++++++++++++++ .../Pickers/DocumentPickerService.swift | 70 +--------- .../Pickers/DropInteractionService.swift | 87 ++++++++++++ .../Pickers/MediaPickerService.swift | 53 +------- .../Protocols/FilePickerProtocol.swift | 1 + .../FilesStorageKit/FilesStorageKit.swift | 4 +- 12 files changed, 351 insertions(+), 119 deletions(-) create mode 100644 Adamant/Modules/Chat/View/Helpers/ChatDropView.swift create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/uploadIcon.imageset/cloud-computing.png create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index a3e95723b..a6061e52a 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 269E13522B594B2D008D1CA7 /* AccountFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269E13512B594B2D008D1CA7 /* AccountFooterView.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 */; }; 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 */; }; @@ -671,6 +672,7 @@ 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 = ""; }; 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 = ""; }; @@ -1480,6 +1482,7 @@ children = ( 41330F7529F1509400CB587C /* AdamantCellAnimation.swift */, 416380E02A51765F00F90E6D /* ChatReactionsView.swift */, + 3A2478AD2BB42967009D89E9 /* ChatDropView.swift */, 3A299C7C2B85F98700B54C61 /* ChatFile.swift */, ); path = Helpers; @@ -3145,6 +3148,7 @@ 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 */, diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 7e1fdd3e7..8ee4ccf29 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -49,6 +49,7 @@ final class ChatViewController: MessagesViewController { private lazy var chatMessagesCollectionView = makeChatMessagesCollectionView() private lazy var replyView = ReplyView() private lazy var filesToolbarView = FilesToolbarView() + private lazy var chatDropView = ChatDropView() private var sendTransaction: SendTransaction @@ -81,7 +82,8 @@ final class ChatViewController: MessagesViewController { private lazy var mediaPickerDelegate = MediaPickerService() private lazy var documentPickerDelegate = DocumentPickerService() private lazy var documentViewerService = DocumentInteractionService() - + private lazy var dropInteractionService = DropInteractionService() + init( viewModel: ChatViewModel, walletServiceCompose: WalletServiceCompose, @@ -118,6 +120,7 @@ final class ChatViewController: MessagesViewController { configureReplyView() configureFilesToolbarView() configureGestures() + configureDropFiles() setupObservers() viewModel.loadFirstMessagesIfNeeded() } @@ -407,12 +410,44 @@ private extension ChatViewController { self?.presentDocumentViewer(url: url, file: file) } .store(in: &subscriptions) + + viewModel.presentDropView + .sink { [weak self] in self?.presentDropView($0) } + .store(in: &subscriptions) } } // 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: dropInteractionService)) + + dropInteractionService.onPreparedDataCallback = { [weak self] result in + DispatchQueue.main.async { + self?.viewModel.dropSessionUpdated(false) + self?.viewModel.presentDialog(progress: false) + self?.viewModel.processFileResult(result) + } + } + + dropInteractionService.onPreparingDataCallback = { [weak self] in + DispatchQueue.main.async { + self?.viewModel.presentDialog(progress: true) + } + } + + dropInteractionService.onSessionCallback = { [weak self] fileOnScreen in + self?.viewModel.dropSessionUpdated(fileOnScreen) + } + } + func configureLayout() { view.addSubview(scrollDownButton) scrollDownButton.snp.makeConstraints { [unowned inputBar] in @@ -583,6 +618,12 @@ private extension ChatViewController { 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 diff --git a/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift new file mode 100644 index 000000000..76e7111b6 --- /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 Foundation +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 = "Drop files here" + 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) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index be742b103..b440eb4d6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -85,6 +85,7 @@ final class ChatViewModel: NSObject { let presentMediaPickerVC = ObservableSender() let presentDocumentPickerVC = ObservableSender() let presentDocumentViewerVC = ObservableSender<(URL, ChatFile)>() + let presentDropView = ObservableSender() @ObservableValue private(set) var isHeaderLoading = false @ObservableValue private(set) var fullscreenLoading = false @@ -846,6 +847,10 @@ final class ChatViewModel: NSObject { func presentDialog(progress: Bool) { dialog.send(.progress(progress)) } + + func dropSessionUpdated(_ value: Bool) { + presentDropView.send(value) + } } extension ChatViewModel { 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 0000000000000000000000000000000000000000..9c3f420d0c171ff1a01bc0425f7058b4d20afe6a GIT binary patch literal 6664 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 Int64 { + 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 fileSize + } catch { + throw error + } + } + + func getFileResult(for url: URL) throws -> FileResult { + let preview = getPreview(for: url) + let fileSize = try getFileSize(from: url) + return FileResult( + url: url, + type: .other, + preview: preview.image, + previewUrl: preview.url, + size: fileSize, + name: url.lastPathComponent, + extenstion: url.pathExtension, + resolution: preview.resolution + ) + } + + @MainActor + func getUrl(for itemProvider: NSItemProvider) async throws -> URL { + guard let type = itemProvider.registeredTypeIdentifiers.first + else { + throw FileValidationError.fileNotFound + } + + return try await withUnsafeThrowingContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + if let error = error { + continuation.resume(throwing: error) + return + } + + guard let url = url else { + continuation.resume(throwing: FileValidationError.tooManyFiles) + return + } + + do { + let folder = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("cachePath") + + try FileManager.default.createDirectory( + at: folder, + withIntermediateDirectories: true + ) + + let targetURL = folder.appendingPathComponent(url.lastPathComponent) + + if FileManager.default.fileExists(atPath: targetURL.path) { + try FileManager.default.removeItem(at: targetURL) + } + + try FileManager.default.copyItem(at: url, to: targetURL) + + continuation.resume(returning: targetURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func isFileType(format: UTType, atURL fileURL: URL) -> Bool { + var mimeType: String? + + let pathExtension = fileURL.pathExtension + if let type = UTType(filenameExtension: pathExtension) { + mimeType = type.preferredMIMEType + } + + guard let mimeType = mimeType 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? getUrl(for: resizedImage, name: url.lastPathComponent) + + return (image: resizedImage, url: imageURL, resolution: image.size) + } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index acb77be02..7455923f2 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -26,18 +26,7 @@ extension DocumentPickerService: UIDocumentPickerDelegate { didPickDocumentsAt urls: [URL] ) { let files = urls.compactMap { - let preview = getPreview(for: $0) - - return FileResult.init( - url: $0, - type: .other, - preview: preview.image, - previewUrl: preview.url, - size: (try? getFileSize(from: $0)) ?? .zero, - name: $0.lastPathComponent, - extenstion: $0.pathExtension, - resolution: preview.resolution - ) + try? helper.getFileResult(for: $0) } do { @@ -48,60 +37,3 @@ extension DocumentPickerService: UIDocumentPickerDelegate { } } } - -private extension DocumentPickerService { - func getFileSize(from fileURL: URL) throws -> Int64 { - _ = fileURL.startAccessingSecurityScopedResource() - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - - guard let fileSize = fileAttributes[.size] as? Int64 else { - throw FileValidationError.fileNotFound - } - - fileURL.stopAccessingSecurityScopedResource() - return fileSize - } - - func isFileType(format: UTType, atURL fileURL: URL) -> Bool { - var mimeType: String? - - let pathExtension = fileURL.pathExtension - if let type = UTType(filenameExtension: pathExtension) { - mimeType = type.preferredMIMEType - } - - guard let mimeType = mimeType 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 = helper.getThumbnailImage(forUrl: url) - } - - guard let image = image else { - return (image: nil, url: nil, resolution: nil) - } - - let resizedImage = helper.resizeImage( - image: image, - targetSize: FilesConstants.previewSize - ) - let imageURL = try? helper.getUrl(for: resizedImage, name: url.lastPathComponent) - - return (image: resizedImage, url: imageURL, resolution: image.size) - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift new file mode 100644 index 000000000..4dca7807c --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift @@ -0,0 +1,87 @@ +// +// DropInteractionService.swift +// +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// + +import Foundation +import CommonKit +import UIKit +import UniformTypeIdentifiers + +@MainActor +public final class DropInteractionService: NSObject { + private var helper = FilesPickerKitHelper() + + public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? + public var onSessionCallback: ((Bool) -> Void)? + public var onPreparingDataCallback: (() -> Void)? + + public override 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 index fd2f70e54..ae2ccdd92 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -10,6 +10,7 @@ import UIKit import Photos import PhotosUI +@MainActor public final class MediaPickerService: NSObject, FilePickerProtocol { private var helper = FilesPickerKitHelper() @@ -45,9 +46,9 @@ private extension MediaPickerService { else { continue } if utType.conforms(to: .image) { - guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), + guard let url = try? await helper.getUrl(for: itemProvider), let preview = try? await getPhoto(from: itemProvider), - let fileSize = try? getFileSize(from: url) + let fileSize = try? helper.getFileSize(from: url) else { continue } let resizedPreview = helper.resizeImage( @@ -72,8 +73,8 @@ private extension MediaPickerService { } if utType.conforms(to: .movie) { - guard let url = try? await getUrl(from: itemProvider, typeIdentifier: typeIdentifier), - let fileSize = try? getFileSize(from: url) + guard let url = try? await helper.getUrl(for: itemProvider), + let fileSize = try? helper.getFileSize(from: url) else { continue } let thumbnailImage = helper.getThumbnailImage(forUrl: url) @@ -112,16 +113,6 @@ private extension MediaPickerService { } } - func getFileSize(from fileURL: URL) throws -> Int64 { - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - - guard let fileSize = fileAttributes[.size] as? Int64 else { - throw FileValidationError.fileNotFound - } - - return fileSize - } - func getPhoto(from itemProvider: NSItemProvider) async throws -> UIImage { let objectType: NSItemProviderReading.Type = UIImage.self @@ -145,38 +136,4 @@ private extension MediaPickerService { } } } - - func getUrl( - from itemProvider: NSItemProvider, - typeIdentifier: String - ) async throws -> URL { - try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in - if let error = error { - continuation.resume(throwing: error) - return - } - - guard let url = url else { - continuation.resume(throwing: FileValidationError.tooManyFiles) - return - } - - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - guard let targetURL = documentsDirectory?.appendingPathComponent(url.lastPathComponent) else { return } - - do { - if FileManager.default.fileExists(atPath: targetURL.path) { - try FileManager.default.removeItem(at: targetURL) - } - - try FileManager.default.copyItem(at: url, to: targetURL) - - continuation.resume(returning: targetURL) - } catch { - continuation.resume(throwing: error) - } - } - } - } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift index 7c3609d70..2de4153f1 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import CommonKit +@MainActor protocol FilePickerProtocol { var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? { get set } var onPreparingDataCallback: (() -> Void)? { get set } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 28abd0726..37d0a6718 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -118,6 +118,9 @@ private extension FilesStorageKit { recipientPublicKey: String, senderPrivateKey: String ) async throws -> UploadResult { + defer { + url.stopAccessingSecurityScopedResource() + } _ = url.startAccessingSecurityScopedResource() let data = try Data(contentsOf: url) @@ -138,7 +141,6 @@ private extension FilesStorageKit { try cacheFile(id: id, data: data) - url.stopAccessingSecurityScopedResource() return (id: id, nonce: nonce) } From debd30ad029a2589ff3ba3f6d34476ea9f2b3664 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 28 Mar 2024 10:21:40 +0200 Subject: [PATCH 037/123] [trello.com/c/uxBZaznD] feat: screen for view & remove app storage --- Adamant.xcodeproj/project.pbxproj | 20 +++++ .../Account/AccountViewController.swift | 33 +++++++- .../ChatsList/ChatListViewController.swift | 13 +++ .../AdamantScreensFactory.swift | 8 +- .../ScreensFactory/ScreensFactory.swift | 1 + .../StorageUsage/StorageUsageFactory.swift | 37 +++++++++ .../StorageUsage/StorageUsageView.swift | 77 ++++++++++++++++++ .../StorageUsage/StorageUsageViewModel.swift | 70 ++++++++++++++++ .../FilesStorageProtocol.swift | 4 + .../Localization/de.lproj/Localizable.strings | 9 ++ .../Localization/en.lproj/Localizable.strings | 9 ++ .../Localization/ru.lproj/Localizable.strings | 9 ++ .../Localization/zh.lproj/Localizable.strings | 9 ++ .../Row/row_storage.imageset/Contents.json | 26 ++++++ .../Row/row_storage.imageset/storage_icon.png | Bin 0 -> 460 bytes .../row_storage.imageset/storage_icon@2x.png | Bin 0 -> 838 bytes .../row_storage.imageset/storage_icon@3x.png | Bin 0 -> 1180 bytes .../FilesStorageKit/FilesStorageKit.swift | 49 +++++++++++ 18 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 Adamant/Modules/StorageUsage/StorageUsageFactory.swift create mode 100644 Adamant/Modules/StorageUsage/StorageUsageView.swift create mode 100644 Adamant/Modules/StorageUsage/StorageUsageViewModel.swift create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_storage.imageset/storage_icon@3x.png diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index a6061e52a..56e64025d 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 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 */; }; 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 */; }; @@ -673,6 +676,9 @@ 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 = ""; }; 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 = ""; }; @@ -1347,6 +1353,16 @@ path = Models; sourceTree = ""; }; + 3A2478AF2BB45DE2009D89E9 /* StorageUsage */ = { + isa = PBXGroup; + children = ( + 3A2478B42BB46617009D89E9 /* StorageUsageFactory.swift */, + 3A2478B02BB45DF8009D89E9 /* StorageUsageView.swift */, + 3A2478B22BB461A7009D89E9 /* StorageUsageViewModel.swift */, + ); + path = StorageUsage; + sourceTree = ""; + }; 3A299C672B838A7800B54C61 /* ChatMedia */ = { isa = PBXGroup; children = ( @@ -2201,6 +2217,7 @@ E919479920000FFD001362F8 /* Modules */ = { isa = PBXGroup; children = ( + 3A2478AF2BB45DE2009D89E9 /* StorageUsage */, 9366588B2B0AB68300BDB2D3 /* CoinsNodesList */, 3AA50DED2AEBE61C00C58FC8 /* PartnerQR */, 93ADE06D2ACA66AF008ED641 /* TestVibration */, @@ -3140,6 +3157,7 @@ 649D6BF221C27D5C009E727B /* SearchResultsViewController.swift in Sources */, E9E7CD8D20026B6600DFC4DB /* DialogService.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 */, @@ -3330,6 +3348,7 @@ 93BF4A6C29E4B4BF00505CD0 /* DelegatesBottomPanel+Model.swift in Sources */, 93294B882AAD0E0A00911109 /* AdmWalletService.swift in Sources */, 648DD7A82239147800B811FD /* DogeWalletService+RichMessageProvider.swift in Sources */, + 3A2478B52BB46617009D89E9 /* StorageUsageFactory.swift in Sources */, E9E7CDC02003AF6D00DFC4DB /* AdamantCellFactory.swift in Sources */, E9DFB71C21624C9200CF8C7C /* AdmTransactionDetailsViewController.swift in Sources */, 6403F5E022723F6400D58779 /* DashWalletFactory.swift in Sources */, @@ -3495,6 +3514,7 @@ E94008852114EE7500CD2D67 /* LskWalletService.swift in Sources */, E96E86B821679C120061F80A /* EthTransactionDetailsViewController.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 */, diff --git a/Adamant/Modules/Account/AccountViewController.swift b/Adamant/Modules/Account/AccountViewController.swift index 0f8ebbcc6..7a37b31b4 100644 --- a/Adamant/Modules/Account/AccountViewController.swift +++ b/Adamant/Modules/Account/AccountViewController.swift @@ -60,7 +60,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 @@ -84,6 +84,7 @@ final class AccountViewController: FormViewController { case .contribute: return "contribute" case .coinsNodes: return "coinsNodes" case .language: return "language" + case .storage: return "storage" } } @@ -107,6 +108,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") } } @@ -131,6 +133,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? @@ -477,6 +480,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/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index aaccd8e1c..02cd385a2 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -301,6 +301,19 @@ final class ChatListViewController: KeyboardObservingViewController { self?.updateUITitles() } .store(in: &subscriptions) + + NotificationCenter.default + .publisher(for: .Storage.storageClear) + .receive(on: OperationQueue.main) + .sink { [weak self] _ in + guard let splitVC = self?.tabBarController?.viewControllers?.first as? UISplitViewController, + !splitVC.isCollapsed + else { return } + + splitVC.showDetailViewController(WelcomeViewController(), sender: nil) + + } + .store(in: &subscriptions) } private func updateUITitles() { diff --git a/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift b/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift index f4e734ee2..e135e7677 100644 --- a/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift +++ b/Adamant/Modules/ScreensFactory/AdamantScreensFactory.swift @@ -26,7 +26,8 @@ struct AdamantScreensFactory: ScreensFactory { private let vibrationSelectionFactory: VibrationSelectionFactory private let partnerQRFactory: PartnerQRFactory private let coinsNodesListFactory: CoinsNodesListFactory - + private let storageUsageFactory: StorageUsageFactory + init(assembler: Assembler) { admWalletFactory = .init(assembler: assembler) chatListFactory = .init(assembler: assembler) @@ -42,6 +43,7 @@ struct AdamantScreensFactory: ScreensFactory { vibrationSelectionFactory = .init(parent: assembler) partnerQRFactory = .init(parent: assembler) coinsNodesListFactory = .init(parent: assembler) + storageUsageFactory = .init(parent: assembler) walletFactoryCompose = AdamantWalletFactoryCompose( lskWalletFactory: .init(assembler: assembler), @@ -166,6 +168,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 628db804a..06e7a393f 100644 --- a/Adamant/Modules/ScreensFactory/ScreensFactory.swift +++ b/Adamant/Modules/ScreensFactory/ScreensFactory.swift @@ -58,6 +58,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..c60582134 --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift @@ -0,0 +1,37 @@ +// +// StorageUsageFactory.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Swinject +import SwiftUI + +struct StorageUsageFactory { + private let assembler: Assembler + + init(parent: Assembler) { + assembler = .init([StorageUsageAssembly()], parent: parent) + } + + func makeViewController() -> UIViewController { + UIHostingController( + rootView: StorageUsageView( + viewModel: 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)! + ) + }.inObjectScope(.weak) + } +} diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift new file mode 100644 index 000000000..e319c0931 --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -0,0 +1,77 @@ +// +// 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: StorageUsageViewModel) { + _viewModel = .init(wrappedValue: viewModel) + } + + var body: some View { + VStack { + List { + Section( + content: { + content + .listRowBackground(Color(uiColor: .adamant.cellColor)) + }, + footer: { Text(verbatim: description) } + ) + } + .listStyle(.insetGrouped) + .navigationTitle(title) + + Spacer() + + HStack { + Button { + viewModel.clearStorage() + } label: { + Text(clearTitle) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color(uiColor: UIColor.adamant.active)) + .clipShape(.rect(cornerRadius: 8.0)) + .padding() + } + } + .onAppear(perform: { + viewModel.updateCacheSize() + }) + .withoutListBackground() + .background(Color(.adamant.secondBackgroundColor)) + } +} + +private extension StorageUsageView { + var content: some View { + HStack { + Image(uiImage: image) + Text(verbatim: title) + Spacer() + if let storage = viewModel.storageUsedDescription { + Text(storage) + } else { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + } + } +} + +private let image: UIImage = .asset(named: "row_storage")! +private let description: String = .localized("StorageUsage.Description") +private let title: String = .localized("StorageUsage.Title") +private let clearTitle: String = .localized("StorageUsage.Clear.Title") diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift new file mode 100644 index 000000000..556db9a58 --- /dev/null +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -0,0 +1,70 @@ +// +// StorageUsageViewModel.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.03.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit +import SwiftUI + +public extension Notification.Name { + struct Storage { + public static let storageClear = Notification.Name("adamant.storage.clear") + } +} + +@MainActor +final class StorageUsageViewModel: ObservableObject { + private let filesStorage: FilesStorageProtocol + private let dialogService: DialogService + + @Published var storageUsedDescription: String? + + nonisolated init( + filesStorage: FilesStorageProtocol, + dialogService: DialogService + ) { + self.filesStorage = filesStorage + self.dialogService = dialogService + } + + func updateCacheSize() { + DispatchQueue.global().async { + let size = (try? self.filesStorage.getCacheSize()) ?? .zero + DispatchQueue.main.async { + self.storageUsedDescription = self.formatSize(size) + } + } + } + + func clearStorage() { + do { + dialogService.showProgress(withMessage: nil, userInteractionEnable: false) + try filesStorage.clearCache() + dialogService.dismissProgress() + dialogService.showSuccess(withMessage: .empty) + updateCacheSize() + NotificationCenter.default.post(name: .Storage.storageClear, object: nil) + } catch { + dialogService.dismissProgress() + dialogService.showError( + withMessage: error.localizedDescription, + supportEmail: false, + error: error + ) + } + } +} + +private extension StorageUsageViewModel { + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useKB] + formatter.countStyle = .file + + return formatter.string(fromByteCount: bytes) + } +} diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index eae3f8144..8a0566796 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -34,6 +34,10 @@ protocol FilesStorageProtocol { previewId: String?, previewNonce: String? ) async throws + + func getCacheSize() throws -> Int64 + + func clearCache() throws } extension FilesStorageKit: FilesStorageProtocol { } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 24b564a8f..a72dc55a8 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -727,6 +727,15 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Clear entire cache"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Gesamten Cache löschen"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "Dieses Feld zeigt die Gesamtmenge an Platz auf dem Gerät, die von Daten-Dateien belegt wird, die mit der Anwendung verbunden sind, wie Bilder, Videos, Audiodateien und Dokumente"; + /* 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."; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 11d7420aa..f6428f182 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -718,6 +718,15 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Clear entire cache"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Storage Usage"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "This field displays the total amount of space occupied on the device by data files associated with the application, such as images, videos, audio files, and documents"; + /* 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 98f2d1dac..6701ed7da 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -718,6 +718,15 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Некорректный адрес хоста"; +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "Очистите весь кэш"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "Использование хранилища"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "В этом поле отображается общий объем пространства, занимаемый на устройстве файлами, связанными с приложением, такими как изображения, видео, аудиофайлы и документы"; + /* 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 8e01b3fb1..73ce4f835 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -676,6 +676,15 @@ /* NodeList: Inform that default nodes was loaded, if user deleted all nodes */ "NodeList.DefaultNodesLoaded" = "已加载默认节点列表"; +/* Storage usage: Clear Title */ +"StorageUsage.Clear.Title" = "清除整个缓存"; + +/* Storage usage: Title */ +"StorageUsage.Title" = "存储使用"; + +/* Storage usage: Description */ +"StorageUsage.Description" = "该字段显示与应用程序关联的数据文件(如图像、视频、音频文件和文档)在设备上占用的总空间量"; + /* CoinsNodesList: Title */ "CoinsNodesList.Title" = "Coin和服务节点列表"; 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 0000000000000000000000000000000000000000..45f9033a06497c34609507d4517378d755266e94 GIT binary patch literal 460 zcmV;-0W=5T76gP2+PCD5i;839oy7mvW=fVlRgqQd7+5;a6 z?aS%8_uagDo|_`D08`!vx*W(cVG*Q!36m0n2LUA+y_nY2V!t-GnGUk7stHU zX$HFB1q_qKS<`KnBt5$TpI{TTEa@fn2m72d^=Yx8FG*U?noz%OvFJNh~t{-J}RtgZ6dl*+tH%74%U4JM!Gm`R%+K^+`%qdi^u>O4lg zjyMs?n^t2FPr11rouNqq&8PfNS}=XYbHb>l00RIqQcC;#NP!^$0000;u7%%ZqUd1y*Qz(Ftzf`5-PH2Y>1XMAv6$?+(eqo!Y(q%1sc?}+0M2IeVHO1VLL|` z8ba&{V{sLre7c~7HiR$30*fXIrz+d3k3Plm2;Q`bCi-j^)b)nn(kjgcBJnWO!C>fN z^l6Anw}k=ms)b?o@4Y;+95~Fun*&=n;$m9RDy)+5v2&{>iq7E|JRfmu7T(P>n(kiIduO2@nvms_eX#M00%O}3I3CV Q<^TWy07*qoM6N<$f?YC+ga7~l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3970c1abf953fd117192ec6cef5df16665df09da GIT binary patch literal 1180 zcmV;N1Y`S&P)j{0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU$K1oDDRCwC#TD?ycK@^``NsP_qYL( zb9QfLZ@*^oCBNL>=5BX?^FHRcGp``BV0{+*KSaCfqsAq37+?9AK@loQJPbAdy;`v(vzbi^iNi1Ph zQ0qv{m_~t%aJ~#Fc$v8bw=mt61P@$jnD@^R%ixo%X)ds_*%P zJDtB>_c#sx1FAGVjfkOnz8D}{Tr^s!$2s|Bmgl=7Uv}vCVpC%;9jhYeHbjmW0#s4b zc9O(RJW}DSDSN$>^bI{O`LH6U;q^I@+{Qyo;>{3MJS5~rN}eA{!}C?P7;z=cQ&P`I z2;`o8ndQ6qPyULK=lVAZyNC&=!eE=qWHBR7T^*N|ghkUrlH}(T)DK4FhY}gX+uk8PJYx;vb;m%%#?gP;3BTrZF1bT348iu*xf8TNd8fnw?jp~>vJJj>>)Y+ zW?S%R=s$6cc#)BB+mioG&P_QM`co|5JGSIoq(T^_=Jm2!UVAgY;&QR-N z$*-^}Z`J|wE6jP_u;k&74R+xy=l&~kxgmMo_5uaX3&FnVwVgz@PZx)x+ZI0UCZ={x zBMWbc-qO|`iJuu<q2KCe*vmmv1ULMr zMZ6J~c#!9X=rtKCgC(x#DMkP%uI;KEB@A7a9$) z$z}mClh#uk(n~&r3ZknXO0(00RK#C^l7ungoXc0000 Int64 { + let url = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + return try folderSize(at: url) + } + + public func clearCache() throws { + let url = try FileManager.default.url( + for: .cachesDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent(cachePath) + + try FileManager.default.removeItem(at: url) + + cachedFiles.removeAll() + } } private extension FilesStorageKit { @@ -214,6 +238,31 @@ private extension FilesStorageKit { return nil } } + + func folderSize(at url: URL) throws -> Int64 { + let fileManager = FileManager.default + + guard fileManager.fileExists(atPath: url.path) else { + throw FileValidationError.fileNotFound + } + + guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { + throw FileValidationError.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 folderSize + } } private let cachePath = "downloads" From 447fa5d5987e170fb3b39d1ddd9deecadfb267f5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 28 Mar 2024 12:41:29 +0200 Subject: [PATCH 038/123] [trello.com/c/uxBZaznD] fix: make file cells design like TG --- .../Content/ChatMediaContnentView.swift | 105 +++++++++++++++--- .../MediaContainerView.swift | 7 +- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index aca84e1bf..c2437f832 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -18,9 +18,11 @@ final class ChatMediaContentView: UIView { numberOfLines: .zero ) - var replyViewDynamicHeight: CGFloat { - model.isReply ? replyViewHeight : 0 - } + private let spacingView: UIView = { + let view = UIView() + view.snp.makeConstraints { $0.height.equalTo(verticalInsets) } + return view + }() private var replyMessageLabel = UILabel() @@ -62,16 +64,75 @@ final class ChatMediaContentView: UIView { 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.top.equalToSuperview().offset(verticalInsets) + make.horizontalEdges.equalToSuperview().inset(horizontalInsets) + make.bottom.equalToSuperview().offset(-verticalInsets) + } + + return view + }() + + private lazy var commentContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + + view.addSubview(commentLabel) + + commentLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(verticalInsets) + make.horizontalEdges.equalToSuperview().inset(horizontalInsets) + make.bottom.equalToSuperview().offset(-verticalInsets) + } + + return view + }() + private lazy var verticalStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [replyView, commentLabel]) + let stack = UIStackView(arrangedSubviews: [replyContainerView, commentContainerView]) stack.axis = .vertical - stack.spacing = verticalStackSpacing + stack.spacing = .zero + stack.layer.masksToBounds = true return stack }() private lazy var mediaContainerView = MediaContainerView() private lazy var fileContainerView = FileContainerView() + 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 } @@ -103,22 +164,26 @@ final class ChatMediaContentView: UIView { private extension ChatMediaContentView { func configure() { + layer.masksToBounds = true layer.cornerRadius = 16 + layer.borderWidth = 2.5 addSubview(verticalStack) verticalStack.snp.makeConstraints { make in - make.verticalEdges.equalToSuperview().inset(8) - make.horizontalEdges.equalToSuperview().inset(12) + make.directionalEdges.equalToSuperview() } } func update() { alpha = model.isHidden ? .zero : 1.0 backgroundColor = model.backgroundColor.uiColor + layer.borderColor = model.backgroundColor.uiColor.cgColor commentLabel.attributedText = model.comment commentLabel.isHidden = model.comment.string.isEmpty - replyView.isHidden = !model.isReply + commentContainerView.isHidden = model.comment.string.isEmpty + replyContainerView.isHidden = !model.isReply + spacingView.isHidden = !model.fileModel.isMediaFilesOnly if model.isReply { replyMessageLabel.attributedText = model.replyMessage @@ -126,10 +191,15 @@ private extension ChatMediaContentView { replyMessageLabel.attributedText = nil } - replyView.snp.updateConstraints { make in - make.height.equalTo(replyViewDynamicHeight) + 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() } @@ -137,11 +207,11 @@ private extension ChatMediaContentView { let viewsList: [UIView] if model.fileModel.isMediaFilesOnly { - viewsList = [replyView, commentLabel, mediaContainerView] + viewsList = [replyContainerView, spacingView, mediaContainerView, commentContainerView] mediaContainerView.model = model.fileModel mediaContainerView.actionHandler = actionHandler } else { - viewsList = [replyView, commentLabel, fileContainerView] + viewsList = [replyContainerView, listFileContainerView, commentContainerView] fileContainerView.model = model.fileModel fileContainerView.actionHandler = actionHandler } @@ -179,7 +249,7 @@ extension ChatMediaContentView.Model { func height() -> CGFloat { let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : 0 - var rowCount: CGFloat = 1 + var rowCount: CGFloat = fileModel.isMediaFilesOnly ? .zero : 1 if isReply { rowCount += 1 @@ -199,6 +269,8 @@ extension ChatMediaContentView.Model { for attributedText: NSAttributedString, considering maxWidth: CGFloat ) -> CGSize { + guard !attributedText.string.isEmpty else { return .zero } + let textContainer = NSTextContainer( size: CGSize(width: maxWidth, height: .greatestFiniteMagnitude) ) @@ -218,10 +290,9 @@ extension ChatMediaContentView.Model { private let nameFont = UIFont.systemFont(ofSize: 15) private let sizeFont = UIFont.systemFont(ofSize: 13) private let imageSize: CGFloat = 70 -private typealias TransactionsDiffableDataSource = UITableViewDiffableDataSource -private let cellIdentifier = "cell" private let commentFont = UIFont.systemFont(ofSize: 14) private let verticalStackSpacing: CGFloat = 10 private let verticalInsets: CGFloat = 8 +private let horizontalInsets: CGFloat = 12 private let replyViewHeight: CGFloat = 25 -private let contentWidth: CGFloat = 260 +private let contentWidth: CGFloat = 280 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 index c515519b0..df9f29008 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -19,7 +19,6 @@ final class MediaContainerView: UIView { stack.alignment = .fill stack.distribution = .fill stack.layer.masksToBounds = true - stack.layer.cornerRadius = 7 for chunk in 0..<3 { let stackView = UIStackView() @@ -66,6 +65,8 @@ final class MediaContainerView: UIView { private extension MediaContainerView { func configure() { + layer.masksToBounds = true + addSubview(filesStack) filesStack.snp.makeConstraints { $0.directionalEdges.equalToSuperview() @@ -122,9 +123,7 @@ private extension MediaContainerView { isHorizontal: Bool, fileList: [ChatFile] ) { - let filesStackWidth = filesStack.bounds.width == .zero - ? stackWidth - : filesStack.bounds.width + let filesStackWidth = stackWidth let minimumWidth = calculateMinimumWidth(availableWidth: filesStackWidth) let maximumWidth = calculateMaximumWidth(availableWidth: filesStackWidth) From d47567ce2aa61fa5862125d8bb196b95d0368c87 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 28 Mar 2024 12:41:48 +0200 Subject: [PATCH 039/123] [trello.com/c/uxBZaznD] fix: send original media --- .../FilesPickerKit/Helpers/FilesPickerKitHelper.swift | 5 ++++- .../Sources/FilesPickerKit/Models/Constants.swift | 1 + .../FilesPickerKit/Pickers/MediaPickerService.swift | 10 ++++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 80b300de3..561a20962 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -197,7 +197,10 @@ final class FilesPickerKitHelper { image: image, targetSize: FilesConstants.previewSize ) - let imageURL = try? getUrl(for: resizedImage, name: url.lastPathComponent) + let imageURL = try? getUrl( + for: resizedImage, + name: FilesConstants.previewTag + url.lastPathComponent + ) return (image: resizedImage, url: imageURL, resolution: image.size) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 4d6c10ba2..8442a83f7 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -12,4 +12,5 @@ public final class FilesConstants { public static let maxFilesCount = 5 static let maxFileSize: Int64 = 10 * 1024 * 1024 static let previewSize: CGSize = .init(squareSize: 300) + static let previewTag: String = "preview_" } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index ae2ccdd92..a5a4a1bf8 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -56,7 +56,10 @@ private extension MediaPickerService { targetSize: FilesConstants.previewSize ) - let previewUrl = try? helper.getUrl(for: resizedPreview, name: url.lastPathComponent) + let previewUrl = try? helper.getUrl( + for: resizedPreview, + name: FilesConstants.previewTag + url.lastPathComponent + ) dataArray.append( .init( @@ -88,7 +91,10 @@ private extension MediaPickerService { ) } - let previewUrl = try? helper.getUrl(for: resizedPreviewImage, name: url.lastPathComponent) + let previewUrl = try? helper.getUrl( + for: resizedPreviewImage, + name: FilesConstants.previewTag + url.lastPathComponent + ) dataArray.append( .init( From ed453208d7aa4788735542e1c3abacbe5671ff2a Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 29 Mar 2024 12:18:29 +0200 Subject: [PATCH 040/123] [trello.com/c/uxBZaznD] feat: auto download preview --- .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +- .../Chat/View/Managers/ChatAction.swift | 1 + .../View/Managers/ChatDataSourceManager.swift | 2 + .../ChatFileContainerView/ChatFileView.swift | 10 +- .../FileContainerView.swift | 8 ++ .../MediaContainerView.swift | 8 ++ .../MediaContainerView/MediaContentView.swift | 10 +- .../Chat/ViewModel/ChatMessageFactory.swift | 29 ++-- .../ViewModel/ChatMessagesListFactory.swift | 12 +- .../Chat/ViewModel/ChatViewModel.swift | 48 ++++++- .../StorageUsage/StorageUsageView.swift | 38 ++++- .../StorageUsage/StorageUsageViewModel.swift | 5 + .../FilesStorageProtocol.swift | 9 ++ .../Localization/de.lproj/Localizable.strings | 6 + .../Localization/en.lproj/Localizable.strings | 6 + .../Localization/ru.lproj/Localizable.strings | 6 + .../Localization/zh.lproj/Localizable.strings | 6 + .../Row/row_preview.imageset/Contents.json | 26 ++++ .../Row/row_preview.imageset/preview.png | Bin 0 -> 519 bytes .../Row/row_preview.imageset/preview@2x.png | Bin 0 -> 1088 bytes .../Row/row_preview.imageset/preview@3x.png | Bin 0 -> 1668 bytes .../Sources/CommonKit/Models/FileResult.swift | 12 ++ .../FilesStorageKit/FilesStorageKit.swift | 69 ++++----- .../FilesStorageKit/NetworkService.swift | 133 ++++++++++++++++++ 24 files changed, 378 insertions(+), 70 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/Row/row_preview.imageset/preview@3x.png create mode 100644 FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 358e6bb58..5ccaf956b 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -19,7 +19,7 @@ struct ChatFile: Equatable, Hashable { var storage: String var nonce: String var isFromCurrentSender: Bool - var isVideo: Bool + var fileType: FileType static let `default` = Self( file: .init([:]), @@ -30,6 +30,6 @@ struct ChatFile: Equatable, Hashable { storage: .empty, nonce: .empty, isFromCurrentSender: false, - isVideo: false + fileType: .other ) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index 1af759a0b..947640374 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -21,4 +21,5 @@ enum ChatAction { case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) case openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) + case downloadPreviewIfNeeded(messageId: String, file: ChatFile, isFromCurrentSender: Bool) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index 52ca95c61..26c4118d5 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -194,6 +194,8 @@ private extension ChatDataSourceManager { viewModel.presentMenu(arg: arg) case let .openFile(messageId, file, isFromCurrentSender): viewModel.openFile(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) + case let .downloadPreviewIfNeeded(messageId, file, isFromCurrentSender): + viewModel.downloadPreviewIfNeeded(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 7dba4cc6f..c75239d04 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -171,10 +171,12 @@ private extension ChatFileView { sizeLabel.text = formatSize(model.file.file_size) additionalLabel.text = fileType.uppercased() - videoIconIV.isHidden = !(model.isCached - && !model.isDownloading - && !model.isUploading - && model.isVideo) + videoIconIV.isHidden = !( + model.isCached + && !model.isDownloading + && !model.isUploading + && model.fileType == .video + ) } func formatSize(_ bytes: Int64) -> String { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index 1afd72e49..54c66e788 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -59,6 +59,14 @@ private extension FileContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) + fileList.forEach { file in + actionHandler(.downloadPreviewIfNeeded( + messageId: model.messageId, + file: file, + isFromCurrentSender: model.isFromCurrentSender + )) + } + filesStack.arrangedSubviews.forEach { $0.isHidden = true } for (index, file) in fileList.enumerated() { 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 index df9f29008..37d3deb8e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -77,6 +77,14 @@ private extension MediaContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) + fileList.forEach { file in + actionHandler(.downloadPreviewIfNeeded( + messageId: model.messageId, + file: file, + isFromCurrentSender: model.isFromCurrentSender + )) + } + for (index, stackView) in filesStack.arrangedSubviews.enumerated() { guard let horizontalStackView = stackView as? UIStackView else { continue } 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 index 7690aa167..255b8ec80 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -107,10 +107,12 @@ private extension MediaContentView { downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading - videoIconIV.isHidden = !(model.isCached - && !model.isDownloading - && !model.isUploading - && model.isVideo) + videoIconIV.isHidden = !( + model.isCached + && !model.isDownloading + && !model.isUploading + && model.fileType == .video + ) if model.isDownloading || model.isUploading { spinner.startAnimating() diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 985a3249f..4f951e664 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -77,7 +77,8 @@ struct ChatMessageFactory { currentSender: SenderType, dateHeaderOn: Bool, topSpinnerOn: Bool, - uploadingFilesIDs: [String] + uploadingFilesIDs: [String], + downloadingFilesIDs: [String] ) -> ChatMessage { let sentDate = transaction.sentDate ?? .now let senderModel = ChatSender(transaction: transaction) @@ -102,7 +103,8 @@ struct ChatMessageFactory { transaction, isFromCurrentSender: currentSender.senderId == senderModel.senderId, backgroundColor: backgroundColor, - uploadingFilesIDs: uploadingFilesIDs + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesIDs ), backgroundColor: backgroundColor, bottomString: makeBottomString( @@ -123,7 +125,8 @@ private extension ChatMessageFactory { _ transaction: ChatTransaction, isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, - uploadingFilesIDs: [String] + uploadingFilesIDs: [String], + downloadingFilesIDs: [String] ) -> ChatMessage.Content { switch transaction { case let transaction as MessageTransaction: @@ -150,7 +153,8 @@ private extension ChatMessageFactory { transaction, isFromCurrentSender: isFromCurrentSender, backgroundColor: backgroundColor, - uploadingFilesIDs: uploadingFilesIDs + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesIDs ) } @@ -305,7 +309,8 @@ private extension ChatMessageFactory { _ transaction: RichMessageTransaction, isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, - uploadingFilesIDs: [String] + uploadingFilesIDs: [String], + downloadingFilesIDs: [String] ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" @@ -334,21 +339,20 @@ private extension ChatMessageFactory { for: $0[RichContentKeys.file.preview_id] as? String ?? .empty, type: $0[RichContentKeys.file.file_type] as? String ?? .empty ), - isDownloading: false, + isDownloading: downloadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), isCached: filesStorage.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, isFromCurrentSender: isFromCurrentSender, - isVideo: videoExtensions.contains(($0[RichContentKeys.file.file_type] as? String)?.uppercased() ?? .empty) + fileType: FileType(raw: ($0[RichContentKeys.file.file_type] as? String ?? .empty)) ?? .other ) } - let filesExtensions = chatFiles.map { $0.file.file_type } + let filesExtensions = chatFiles.map { $0.fileType } - let isMediaFilesOnly = filesExtensions.allSatisfy { elementA in - guard let elementA = elementA else { return false } - return mediaExtensions.contains(elementA.uppercased()) + let isMediaFilesOnly = filesExtensions.allSatisfy { type in + return type == .image || type == .video } return .file(.init(value: .init( @@ -524,6 +528,3 @@ private extension ChatSender { ) } } - -private let mediaExtensions = ["JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2", "MOV", "MP4"] -private let videoExtensions = ["MOV", "MP4"] diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift index e656e505d..963b4586a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift @@ -23,7 +23,8 @@ actor ChatMessagesListFactory { sender: ChatSender, isNeedToLoadMoreMessages: Bool, expirationTimestamp minExpTimestamp: inout TimeInterval?, - uploadingFilesIDs: [String] + uploadingFilesIDs: [String], + downloadingFilesIDs: [String] ) -> [ChatMessage] { assert(!Thread.isMainThread, "Do not process messages on main thread") @@ -46,7 +47,8 @@ actor ChatMessagesListFactory { ), topSpinnerOn: isNeedToLoadMoreMessages && index == .zero, willExpireAfter: &expTimestamp, - uploadingFilesIDs: uploadingFilesIDs + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesIDs ) if let timestamp = expTimestamp, timestamp < minExpTimestamp ?? .greatestFiniteMagnitude { @@ -65,7 +67,8 @@ private extension ChatMessagesListFactory { dateHeaderOn: Bool, topSpinnerOn: Bool, willExpireAfter: inout TimeInterval?, - uploadingFilesIDs: [String] + uploadingFilesIDs: [String], + downloadingFilesIDs: [String] ) -> ChatMessage { var expireDate: Date? let message = chatMessageFactory.makeMessage( @@ -74,7 +77,8 @@ private extension ChatMessagesListFactory { currentSender: sender, dateHeaderOn: dateHeaderOn, topSpinnerOn: topSpinnerOn, - uploadingFilesIDs: uploadingFilesIDs + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesIDs ) willExpireAfter = expireDate?.timeIntervalSince1970 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index b440eb4d6..ee36d70a2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -767,7 +767,8 @@ final class ChatViewModel: NSObject { let tx = chatTransactions.first(where: { $0.txId == messageId }) guard let keyPair = accountService.keypair, - tx?.statusEnum == .delivered + tx?.statusEnum == .delivered, + !downloadingFilesID.contains(file.file.file_id) else { return } Task { @@ -816,6 +817,48 @@ final class ChatViewModel: NSObject { } } + func downloadPreviewIfNeeded( + messageId: String, + file: ChatFile, + isFromCurrentSender: Bool + ) { + let tx = chatTransactions.first(where: { $0.txId == messageId }) + + guard let keyPair = accountService.keypair, + tx?.statusEnum == .delivered, + !downloadingFilesID.contains(file.file.file_id), + let previewId = file.file.preview_id, + let previewNonce = file.file.preview_nonce, + !filesStorage.isCached(previewId) + else { return } + + downloadingFilesID.append(file.file.file_id) + + Task { + defer { + downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) + } + + try? await filesStorage.cachePreview( + storage: file.storage, + fileType: file.file.file_type ?? .empty, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + previewId: previewId, + previewNonce: previewNonce + ) + + let preview = filesStorage.getPreview( + for: previewId, + type: file.file.file_type ?? .empty + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) + } + } + func presentActionMenu() { dialog.send(.actionMenu) } @@ -973,7 +1016,8 @@ private extension ChatViewModel { sender: sender, isNeedToLoadMoreMessages: isNeedToLoadMoreMessages, expirationTimestamp: &expirationTimestamp, - uploadingFilesIDs: uploadingFilesIDs + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesID ) await setupNewMessages( diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index e319c0931..6bffd62be 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -25,11 +25,19 @@ struct StorageUsageView: View { content .listRowBackground(Color(uiColor: .adamant.cellColor)) }, - footer: { Text(verbatim: description) } + footer: { Text(verbatim: storageDescription) } + ) + + Section( + content: { + previewContent + .listRowBackground(Color(uiColor: .adamant.cellColor)) + }, + footer: { Text(verbatim: previewDescription) } ) } .listStyle(.insetGrouped) - .navigationTitle(title) + .navigationTitle(storageTitle) Spacer() @@ -58,8 +66,8 @@ struct StorageUsageView: View { private extension StorageUsageView { var content: some View { HStack { - Image(uiImage: image) - Text(verbatim: title) + Image(uiImage: storageImage) + Text(verbatim: storageTitle) Spacer() if let storage = viewModel.storageUsedDescription { Text(storage) @@ -69,9 +77,25 @@ private extension StorageUsageView { } } } + + var previewContent: some View { + Toggle(isOn: $viewModel.autoDownloadPreview) { + HStack { + Image(uiImage: previewImage) + Text(previewTitle) + } + .onLongPressGesture { + viewModel.togglePreviewContent() + } + } + .tint(.init(uiColor: .adamant.active)) + } } -private let image: UIImage = .asset(named: "row_storage")! -private let description: String = .localized("StorageUsage.Description") -private let title: String = .localized("StorageUsage.Title") +private let storageImage: UIImage = .asset(named: "row_storage")! +private let storageDescription: String = .localized("StorageUsage.Description") +private let storageTitle: String = .localized("StorageUsage.Title") private let clearTitle: String = .localized("StorageUsage.Clear.Title") +private let previewImage: UIImage = .asset(named: "row_preview")! +private let previewTitle: String = .localized("Storage.AutoDownloadPreview.Title") +private let previewDescription: String = .localized("Storage.AutoDownloadPreview.Description") diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 556db9a58..d4179ba13 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -22,6 +22,7 @@ final class StorageUsageViewModel: ObservableObject { private let dialogService: DialogService @Published var storageUsedDescription: String? + @Published var autoDownloadPreview: Bool = false nonisolated init( filesStorage: FilesStorageProtocol, @@ -57,6 +58,10 @@ final class StorageUsageViewModel: ObservableObject { ) } } + + func togglePreviewContent() { + + } } private extension StorageUsageViewModel { diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 8a0566796..a6778f4ea 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -38,6 +38,15 @@ protocol FilesStorageProtocol { func getCacheSize() throws -> Int64 func clearCache() throws + + func cachePreview( + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + previewId: String, + previewNonce: String + ) async throws } extension FilesStorageKit: FilesStorageProtocol { } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index a72dc55a8..1b2b5007d 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -727,6 +727,12 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Automatisches Herunterladen von Vorschaubildern"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "Automatisches Herunterladen von Vorschauen für Fotos und Videos: Wenn diese Option aktiviert ist, kann die Anwendung automatisch Vorschauen von Fotos und Videos herunterladen. Dies erleichtert das schnelle Anzeigen von Mediendateien, ohne auf den vollständigen Download der Datei warten zu müssen"; + /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Clear entire cache"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index f6428f182..cc45074da 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -718,6 +718,12 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Auto download previews"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "Automatically download previews for photo and video: When enabled, this option allows the application to automatically download previews of photos and videos. This helps in quickly previewing media content without having to wait for the full file to download"; + /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Clear entire cache"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 6701ed7da..2ac0d3473 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -718,6 +718,12 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Некорректный адрес хоста"; +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "Автоматически загружать превью"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "Автоматическая загрузка превью для фотографий и видео: При включении этой опции приложение может автоматически загружать превью фотографий и видео. Это помогает быстро просматривать медиа-контент без необходимости ожидания полной загрузки файла"; + /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Очистите весь кэш"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 73ce4f835..74f9243de 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -676,6 +676,12 @@ /* NodeList: Inform that default nodes was loaded, if user deleted all nodes */ "NodeList.DefaultNodesLoaded" = "已加载默认节点列表"; +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Title" = "自动下载预览"; + +/* Storage: Auto download preview */ +"Storage.AutoDownloadPreview.Description" = "自动下载照片和视频预览:当启用此选项时,应用程序可以自动下载照片和视频的预览。这有助于快速预览媒体内容,而无需等待完整文件下载"; + /* Storage usage: Clear Title */ "StorageUsage.Clear.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 0000000000000000000000000000000000000000..355cd01925100e041a6539485a4bddb385d2b5b1 GIT binary patch literal 519 zcmV+i0{H!jP)8g|6#SAvyYDa>(kk1J$~)I*51n}uT_g0e39^Vk5ViL>-6dG4{V|U`x3rZ zagHJ^xjX$<={3?-GtNxB6U#vThH1rOsp@Jg~t;Z04>3^RF$9^Pq zOxd*%xbVv-)Te~L=kc?UffyWO9y4AO{9(cd#C&B3n9|T{Oe0gm)L(3i**!-S){q-w zyz}L4=(xa~9j{Pl;0XR}-%oU49nJWy?TMJRf?q=2k6(;Dl;c?2%J2szI=MpIxAcyk z@mY3=JM^0lK6O3C1&;8D8>{a`%rgA*3hfv32{y{>3D$))%pn@%+Ws9aVos}J2~zf& zrl4I1CRt-*t?Oh=idpLfajj{v_w)*NsKg)^(ThuDBL{`(RBy`b=+wk7rY9D1&{buu;egYmdpZ?l{T` z-V67HX$V$*gkwT);g9gcb-%SiT^1&|Z>CC5gt@{7VYtvfLceapcz0te4W;UZRZ;p} zMJV0=Q**BRxYS~3&}uO>Xa%|IGoYcRGqeh0Va&oikV-6BLpgpjG&xKWa-arNtUVt> zGtznC0+LXohB9X_6^eu&*7i=g4vm$LcLzgDxdqm~M>vcCyqsl|+%*Hdm<~;G&W4TN zS_pmQZZT&s63&M8FB3-l%wLZf{zx=KH$!tY`1B%xOhGY)$o6x=h<(R`WYH6g|fK)0{_E^w$OdhCul5n#Ov?&8-BmT z-2^NCET2A64Lv8c6ZRrR zQ-pD25sI(OPL=kZUhNxU43j~b^3_%m+dcy4U3ZW#h7@EBzsSEVbp~1#5G87}uq9oA+h+JX2Fww4QM1^n*8c(U7 zP>&TNrm?G#9W~m}CgZ={DzCW~hzdi~80000j{00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPkRj7~9#k>v~qhE=4M+#?|JYB*is4eyr4l=*57v7GHP)hiD!b4p8 zKH;6h|4>`Z7Y;9acBAk%WPl{$2MbpU2M9ZaM+sM>_J|XnxLG(ADbYXS`Q}i;iEmLK z!7Y9kE;GMx72brDND|)8h#u4*e+rLA#vRZf%>ypKOgo^NgeQ_ZU^1#evk6bcb-)x< z(E-gUJdsHUG^_AL>JDgh;s17jX?o+@nyLdDUHFCW?-1ds33;mSfJPVo9h1IWcs%mX zmqk?_FaV8>8Ny?PM+mnOz9jrh_-{h~jf;Ly5&k1=jmzBVBTu*bGU40i*+#<65;!oX zj>N5SqmMknAj(AO=IV6e*248Y%8h__)R3eM%^*C%8SuKJFP=kwOCF16w)3gcCwy;3 zpYXjEeZu!v^a6Xy%hgN}G5*ibmukq0JE3a3M7N2bVHc(LWR!Ag%Bm&8viWA-}deg%gD53Kt_CsQr0QfI*BXKDQn8rpSc|gdECG z`yHW*$WF^Agj*mCxc+AseeYgqXH+%qlaNDc;radrlZR842ZlcDnlX4@cX>uQ)73-E z;A0i2pj%Pc0gocS1A+)ot^B-sPdj{$XT2v2e?XN&ECRs671O27bSCuS9opa`2wuFc}c=B zj@3^;wFXN$sW__x+<$BlngzdZo$3hK%s2 zm+tAMD75+Hr^3tO=N{AKE-_`P?8c&ozCFoH11n6v?_48%Kp}S*9uB`wi*OqBDr)r2 zWr!C%_ARHiH&^o8(E_-S^=0(sM#zQC658E4N)NTJ=+?&lW? zPk|~gf^O_B!uNy+A{(3t4QTSNebnAI_ub`4fh;09G))ts0gcFWM-7w zCwFzvc)#Wjs89bJQX;D`7M%0JYu4pi+B8I1TCuWrh%O zdkVzp%CpdQc^2~1mKf<+4(!Mmp`pWAYq*c0`=}$~Da8L9gu% z;pYg9TAkIkI})_<3_0;qAx$EEeL5;#s4?ns6^+VjIZ~P_50v#5leGO$$eXQ^XuhLt zUOvpZ^bbjyl7!;+Gdd}u@N_UL2{6(maxTasks: 5) + + @Atomic private var cachedFiles: [String: URL] = [:] + public init() { try? loadCache() } + public func cachePreview( + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + previewId: String, + previewNonce: String + ) async throws { + await taskQueue.enqueue { + try? await self.downloadFile( + id: previewId, + storage: storage, + fileType: fileType, + senderPublicKey: senderPublicKey, + recipientPrivateKey: recipientPrivateKey, + nonce: previewNonce + ) + } + } + public func getPreview(for id: String, type: String) -> URL? { getPreview(for: type, url: cachedFiles[id]) } @@ -122,17 +145,14 @@ private extension FilesStorageKit { recipientPrivateKey: String, nonce: String ) async throws { - let encodedData = try await networkFileManager.downloadFile(id, type: storage) - - guard let decodedData = adamantCore.decodeData( - encodedData, - rawNonce: nonce, + let decodedData = try await networkService.downloadFile( + id: id, + storage: storage, + fileType: fileType, senderPublicKey: senderPublicKey, - privateKey: recipientPrivateKey + recipientPrivateKey: recipientPrivateKey, + nonce: nonce ) - else { - throw FileValidationError.fileNotFound - } return try cacheFile(id: id, data: decodedData) } @@ -142,30 +162,15 @@ private extension FilesStorageKit { recipientPublicKey: String, senderPrivateKey: String ) async throws -> UploadResult { - defer { - url.stopAccessingSecurityScopedResource() - } - _ = url.startAccessingSecurityScopedResource() - - let data = try Data(contentsOf: url) - - let encodedResult = adamantCore.encodeData( - data, + let result = try await networkService.uploadFile( + url: url, recipientPublicKey: recipientPublicKey, - privateKey: senderPrivateKey + senderPrivateKey: senderPrivateKey ) - guard let encodedData = encodedResult?.data, - let nonce = encodedResult?.nonce - else { - throw FileManagerError.cantEnctryptFile - } - - let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) - - try cacheFile(id: id, data: data) + try cacheFile(id: result.id, data: result.data) - return (id: id, nonce: nonce) + return (id: result.id, nonce: result.nonce) } func loadCache() throws { @@ -232,8 +237,6 @@ private extension FilesStorageKit { } return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") - case "PDF": - return getLocalImageUrl(by: "file-pdf-box", withExtension: "jpg") default: return nil } diff --git a/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift b/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift new file mode 100644 index 000000000..11635ce3b --- /dev/null +++ b/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift @@ -0,0 +1,133 @@ +// +// NetworkService.swift +// +// +// Created by Stanislav Jelezoglo on 28.03.2024. +// + +import Foundation +import CommonKit +import UIKit +import FilesNetworkManagerKit +import Combine + +final class NetworkService { + typealias UploadResult = (id: String, nonce: String, data: Data) + + private let adamantCore = NativeAdamantCore() + private let networkFileManager = FilesNetworkManager() + + func downloadFile( + id: String, + storage: String, + fileType: String?, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) async throws -> Data { + let encodedData = try await networkFileManager.downloadFile(id, type: storage) + + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) + else { + throw FileValidationError.fileNotFound + } + + return decodedData + } + + func uploadFile( + url: URL, + recipientPublicKey: String, + senderPrivateKey: String + ) 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.cantEnctryptFile + } + + let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) + + return (id: id, nonce: nonce, data: data) + } +} + +func makePublisher( + operation: @escaping () async -> Output +) -> some Publisher { + Future { promise in + Task { + let output = await operation() + promise(.success(output)) + } + } +} + +actor TaskQueue { + private struct Effect { + private let f: () -> AnyPublisher + let continuation: CheckedContinuation + + init( + operation: @escaping () async -> Value, + continuation: CheckedContinuation + ) { + self.f = { makePublisher(operation: operation).eraseToAnyPublisher() } + self.continuation = continuation + } + + func invoke() -> AnyPublisher { + f() + } + } + + typealias Operation = () async -> Value + typealias Continuation = CheckedContinuation + + private var cancellable: AnyCancellable! + private var input = PassthroughSubject() + + init(maxTasks: Int = 1, bufferSize: Int = 100) { + self.cancellable = input + .buffer(size: bufferSize, prefetch: .keepFull, whenFull: .dropOldest) + .flatMap(maxPublishers: .max(maxTasks)) { effect in + effect.invoke().map { [continuation = effect.continuation] in ($0, continuation) } + } + .sink { value, continuation in + continuation.resume(returning: value) + } + } + + func enqueue(_ operation: @escaping Operation) async -> Value { + await withCheckedContinuation { continuation in + self.send(operation: operation, continuation: continuation) + } + } + + private func send(operation: @escaping Operation, continuation: Continuation) { + self.input.send( + Effect( + operation: operation, + continuation: continuation + ) + ) + } +} From 04badd85f1a8e024dcbc6e330a7ed92449c9394e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 1 Apr 2024 15:58:11 +0300 Subject: [PATCH 041/123] [trello.com/c/uxBZaznD] feat: improved chat performance --- Adamant.xcodeproj/project.pbxproj | 2 +- .../Modules/Chat/View/Helpers/ChatFile.swift | 4 +- .../Container/ChatMediaContainerView.swift | 2 +- .../Content/ChatMediaContnentView.swift | 20 +--- .../ChatFileContainerView/ChatFileView.swift | 15 ++- .../MediaContainerView.swift | 4 +- .../MediaContainerView/MediaContentView.swift | 14 ++- .../Chat/ViewModel/ChatMessageFactory.swift | 86 +++++++++++------- .../Chat/ViewModel/ChatViewModel.swift | 6 +- .../FilesStorageProtocol.swift | 2 +- CommonKit/Package.swift | 5 - .../Assets/File-icons/file-default-box.png | Bin 929 -> 0 bytes .../Assets/File-icons/file-pdf-box.jpg | Bin 5321 -> 0 bytes .../file-image-box.imageset/Contents.json | 21 +++++ .../file-image-box.jpg | Bin .../Pickers/MediaPickerService.swift | 13 +-- .../FilesStorageKit/FilesStorageKit.swift | 35 +++++-- 17 files changed, 145 insertions(+), 84 deletions(-) delete mode 100644 CommonKit/Sources/CommonKit/Assets/File-icons/file-default-box.png delete mode 100644 CommonKit/Sources/CommonKit/Assets/File-icons/file-pdf-box.jpg create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json rename CommonKit/Sources/CommonKit/Assets/{File-icons => Shared.xcassets/files/file-image-box.imageset}/file-image-box.jpg (100%) diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 56e64025d..5194c2b9d 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -1447,8 +1447,8 @@ 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */ = { isa = PBXGroup; children = ( - 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */, + 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, ); path = ChatFileContainerView; sourceTree = ""; diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 5ccaf956b..d909f099f 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -12,7 +12,7 @@ import UIKit struct ChatFile: Equatable, Hashable { var file: RichMessageFile.File - var previewDataURL: URL? + var previewImage: UIImage? var isDownloading: Bool var isUploading: Bool var isCached: Bool @@ -23,7 +23,7 @@ struct ChatFile: Equatable, Hashable { static let `default` = Self( file: .init([:]), - previewDataURL: nil, + previewImage: nil, isDownloading: false, isUploading: false, isCached: false, diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 7f2a1377d..83298f9b9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -22,7 +22,7 @@ final class ChatMediaContainerView: UIView, ChatModelView { return view }() - private let horizontalStack: UIStackView = { + private lazy var horizontalStack: UIStackView = { let stack = UIStackView() stack.alignment = .center stack.axis = .horizontal diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index c2437f832..7fa4df2ba 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -113,7 +113,7 @@ final class ChatMediaContentView: UIView { }() private lazy var verticalStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [replyContainerView, commentContainerView]) + let stack = UIStackView(arrangedSubviews: [replyContainerView, spacingView, mediaContainerView, listFileContainerView, commentContainerView]) stack.axis = .vertical stack.spacing = .zero stack.layer.masksToBounds = true @@ -204,21 +204,9 @@ private extension ChatMediaContentView { } func updateStackLayout() { - let viewsList: [UIView] - - if model.fileModel.isMediaFilesOnly { - viewsList = [replyContainerView, spacingView, mediaContainerView, commentContainerView] - mediaContainerView.model = model.fileModel - mediaContainerView.actionHandler = actionHandler - } else { - viewsList = [replyContainerView, listFileContainerView, commentContainerView] - fileContainerView.model = model.fileModel - fileContainerView.actionHandler = actionHandler - } - - guard verticalStack.arrangedSubviews != viewsList else { return } - verticalStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - viewsList.forEach(verticalStack.addArrangedSubview) + spacingView.isHidden = !model.fileModel.isMediaFilesOnly + mediaContainerView.isHidden = !model.fileModel.isMediaFilesOnly + listFileContainerView.isHidden = model.fileModel.isMediaFilesOnly if model.fileModel.isMediaFilesOnly { mediaContainerView.model = model.fileModel diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index c75239d04..ef7d02bd9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -145,12 +145,20 @@ private extension ChatFileView { } func update() { - if let url = model.previewDataURL { - iconImageView.image = UIImage(contentsOfFile: url.path) + let image: UIImage? + if let previewImage = model.previewImage { + image = previewImage additionalLabel.isHidden = true } else { + image = model.fileType == .image || model.fileType == .video + ? defaultMediaImage + : defaultImage + additionalLabel.isHidden = false - iconImageView.image = defaultImage + } + + if iconImageView.image != image { + iconImageView.image = image } downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading @@ -195,3 +203,4 @@ private let imageSize: CGFloat = 70 private let stackSpacing: CGFloat = 12 private let verticalStackSpacing: CGFloat = 3 private let defaultImage: UIImage? = .asset(named: "file-default-box") +private let defaultMediaImage: UIImage? = .asset(named: "file-image-box") 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 index 37d3deb8e..4439abbc1 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -162,7 +162,7 @@ private extension MediaContainerView { width = min(width, maximumWidth) mediaView.snp.remakeConstraints { - $0.width.equalTo(width) + $0.width.equalTo(width - stackSpacing) $0.height.equalTo(height) } } else { @@ -212,7 +212,7 @@ extension ChatMediaContentView.FileModel { let height: CGFloat = isHorizontal ? rowHorizontalHeight - : rows.count == 1 ? rowVerticalHeight * 2 : rowVerticalHeight + : fileList.count == 1 ? rowVerticalHeight * 2 : rowVerticalHeight totalHeight += height } 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 index 255b8ec80..2173b4c6f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -99,10 +99,17 @@ private extension MediaContentView { } func update() { - if let url = model.previewDataURL { - imageView.image = UIImage(contentsOfFile: url.path) + let image: UIImage? + if let previewImage = model.previewImage { + image = previewImage } else { - imageView.image = defaultImage + image = model.fileType == .image || model.fileType == .video + ? defaultMediaImage + : defaultImage + } + + if imageView.image != image { + imageView.image = image } downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading @@ -126,3 +133,4 @@ private let imageSize: CGFloat = 70 private let stackSpacing: CGFloat = 12 private let verticalStackSpacing: CGFloat = 3 private let defaultImage: UIImage? = .asset(named: "file-default-box") +private let defaultMediaImage: UIImage? = .asset(named: "file-image-box") diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 4f951e664..b997a6a75 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -314,14 +314,12 @@ private extension ChatMessageFactory { ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" - let decodedMessage: String = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." - let decodedMessageMarkDown = Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() - let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] - let storage: String = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty - let comment: String = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty - let replyId = transaction.getRichValue(for: RichContentKeys.reply.replyToId) ?? "" + let decodedMessage = decodeMessage(transaction) + let storage = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty + let comment = 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 address = transaction.isOutgoing @@ -332,45 +330,36 @@ private extension ChatMessageFactory { ? transaction.recipientAddress : transaction.senderAddress - let chatFiles = files.map { - ChatFile.init( - file: RichMessageFile.File.init($0), - previewDataURL: filesStorage.getPreview( - for: $0[RichContentKeys.file.preview_id] as? String ?? .empty, - type: $0[RichContentKeys.file.file_type] as? String ?? .empty - ), - isDownloading: downloadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), - isUploading: uploadingFilesIDs.contains($0[RichContentKeys.file.file_id] as? String ?? .empty), - isCached: filesStorage.isCached($0[RichContentKeys.file.file_id] as? String ?? .empty), - storage: storage, - nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, - isFromCurrentSender: isFromCurrentSender, - fileType: FileType(raw: ($0[RichContentKeys.file.file_type] as? String ?? .empty)) ?? .other - ) - } - - let filesExtensions = chatFiles.map { $0.fileType } + let chatFiles = makeChatFiles( + from: files, + uploadingFilesIDs: uploadingFilesIDs, + downloadingFilesIDs: downloadingFilesIDs, + isFromCurrentSender: isFromCurrentSender, + storage: storage + ) - let isMediaFilesOnly = filesExtensions.allSatisfy { type in - return type == .image || type == .video + let isMediaFilesOnly = chatFiles.allSatisfy { + $0.fileType == .image || $0.fileType == .video } + let fileModel = ChatMediaContentView.FileModel( + messageId: id, + files: chatFiles, + isMediaFilesOnly: isMediaFilesOnly, + isFromCurrentSender: isFromCurrentSender + ) + return .file(.init(value: .init( id: id, isFromCurrentSender: isFromCurrentSender, reactions: reactions, content: .init( id: id, - fileModel: .init( - messageId: id, - files: chatFiles, - isMediaFilesOnly: isMediaFilesOnly, - isFromCurrentSender: isFromCurrentSender - ), + fileModel: fileModel, isHidden: false, isFromCurrentSender: isFromCurrentSender, isReply: transaction.isFileReply(), - replyMessage: decodedMessageMarkDown, + replyMessage: decodedMessage, replyId: replyId, comment: Self.markdownParser.parse(comment), backgroundColor: backgroundColor @@ -380,6 +369,37 @@ private extension ChatMessageFactory { ))) } + private func decodeMessage(_ transaction: RichMessageTransaction) -> NSMutableAttributedString { + let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." + return Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() + } + + private func makeChatFiles( + from files: [[String: Any]], + uploadingFilesIDs: [String], + downloadingFilesIDs: [String], + isFromCurrentSender: Bool, + storage: String + ) -> [ChatFile] { + return files.map { + let previewId = $0[RichContentKeys.file.preview_id] as? String ?? "" + let fileType = $0[RichContentKeys.file.file_type] as? String ?? "" + let fileId = $0[RichContentKeys.file.file_id] as? String ?? "" + + return ChatFile( + file: RichMessageFile.File($0), + previewImage: filesStorage.getPreview(for: previewId, type: fileType), + isDownloading: downloadingFilesIDs.contains(fileId), + isUploading: uploadingFilesIDs.contains(fileId), + isCached: filesStorage.isCached(fileId), + storage: storage, + nonce: $0[RichContentKeys.file.nonce] as? String ?? "", + isFromCurrentSender: isFromCurrentSender, + fileType: FileType(raw: fileType) ?? .other + ) + } + } + func makeContent( _ transaction: TransferTransaction, isFromCurrentSender: Bool, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index ee36d70a2..2c07339e0 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -1252,7 +1252,7 @@ private extension ChatViewModel { _ messages: inout [ChatMessage], id oldId: String, newId: String? = nil, - preview: URL?, + preview: UIImage?, cached: Bool ) { messages.indices.forEach { index in @@ -1341,7 +1341,7 @@ private extension ChatMessage { mutating func updateFields( id oldId: String, newId: String? = nil, - preview: URL?, + preview: UIImage?, cached: Bool ) { guard case let .file(fileModel) = content else { return } @@ -1354,7 +1354,7 @@ private extension ChatMessage { if let newId = newId { model.content.fileModel.files[index].file.file_id = newId } - model.content.fileModel.files[index].previewDataURL = preview + model.content.fileModel.files[index].previewImage = preview model.content.fileModel.files[index].isCached = cached content = .file(.init(value: model)) diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index a6778f4ea..d00d26368 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -12,7 +12,7 @@ import CommonKit import FilesStorageKit protocol FilesStorageProtocol { - func getPreview(for id: String, type: String) -> URL? + func getPreview(for id: String, type: String) -> UIImage? func isCached(_ id: String) -> Bool diff --git a/CommonKit/Package.swift b/CommonKit/Package.swift index 035f4853e..54fb7ce47 100644 --- a/CommonKit/Package.swift +++ b/CommonKit/Package.swift @@ -60,11 +60,6 @@ let package = Package( "MarkdownKit", "KeychainAccess", "RNCryptor" - ], - resources: [ - .process("Assets/File-icons/file-default-box.png"), - .process("Assets/File-icons/file-image-box.jpg"), - .process("Assets/File-icons/file-pdf-box.jpg") ] ), .testTarget( diff --git a/CommonKit/Sources/CommonKit/Assets/File-icons/file-default-box.png b/CommonKit/Sources/CommonKit/Assets/File-icons/file-default-box.png deleted file mode 100644 index 8d219f70193c0b82ce2722b51d0a58fac926a37f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 929 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 KX;y34MgI?2jTmwO diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json new file mode 100644 index 000000000..13d41a723 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "file-image-box.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/file-image-box.jpg similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/File-icons/file-image-box.jpg rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/file-image-box.jpg diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index a5a4a1bf8..7f6a1eae3 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -25,12 +25,13 @@ extension MediaPickerService: PHPickerViewControllerDelegate { _ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult] ) { - picker.dismiss(animated: true, completion: .none) - onPreparingDataCallback?() - - Task { - await processResults(results) - } + picker.dismiss(animated: true, completion: { [weak self] in + self?.onPreparingDataCallback?() + + Task { + await self?.processResults(results) + } + }) } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 372b8f8eb..99f585f87 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -13,7 +13,8 @@ public final class FilesStorageKit { private let networkService = NetworkService() private let taskQueue = TaskQueue(maxTasks: 5) - @Atomic private var cachedFiles: [String: URL] = [:] + @Atomic private var cachedFilesUrl: [String: URL] = [:] + private var cachedFiles: NSCache = NSCache() public init() { try? loadCache() @@ -39,16 +40,26 @@ public final class FilesStorageKit { } } - public func getPreview(for id: String, type: String) -> URL? { - getPreview(for: type, url: cachedFiles[id]) + public func getPreview(for id: String, type: String) -> UIImage? { + if let image = cachedFiles.object(forKey: id as NSString) { + return image + } + + guard let url = cachedFilesUrl[id], + let image = UIImage(contentsOfFile: url.path) else { + return nil + } + + cachedFiles.setObject(image, forKey: id as NSString) + return image } public func isCached(_ id: String) -> Bool { - cachedFiles[id] != nil + cachedFilesUrl[id] != nil } public func getFileURL(with id: String) throws -> URL { - guard let url = cachedFiles[id] else { + guard let url = cachedFilesUrl[id] else { throw FileValidationError.fileNotFound } @@ -132,7 +143,8 @@ public final class FilesStorageKit { try FileManager.default.removeItem(at: url) - cachedFiles.removeAll() + cachedFiles.removeAllObjects() + cachedFilesUrl.removeAll() } } @@ -184,7 +196,11 @@ private extension FilesStorageKit { let files = getFiles(at: folder) files.forEach { url in - cachedFiles[url.lastPathComponent] = url + cachedFilesUrl[url.lastPathComponent] = url + + if let data = UIImage(contentsOfFile: url.path) { + self.cachedFiles.setObject(data, forKey: url.lastPathComponent as NSString) + } } } @@ -220,7 +236,10 @@ private extension FilesStorageKit { try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - cachedFiles[id] = fileURL + cachedFilesUrl[id] = fileURL + if let uiImage = UIImage(data: data) { + cachedFiles.setObject(uiImage, forKey: id as NSString) + } } private func getPreview(for type: String, url: URL?) -> URL? { From fece8adcde6d3da0a1b324087cb275d21afcb93d Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 2 Apr 2024 15:38:58 +0300 Subject: [PATCH 042/123] [trello.com/c/uxBZaznD] feat: make chat file interaction as separate service --- Adamant.xcodeproj/project.pbxproj | 4 + Adamant/App/DI/AppAssembly.swift | 10 +- Adamant/Modules/Chat/ChatFactory.swift | 5 +- .../FileContainerView.swift | 4 +- .../MediaContainerView.swift | 4 +- .../Chat/ViewModel/ChatFileService.swift | 305 +++++++++++++++ .../Chat/ViewModel/ChatViewModel.swift | 370 ++++++------------ 7 files changed, 446 insertions(+), 256 deletions(-) create mode 100644 Adamant/Modules/Chat/ViewModel/ChatFileService.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 5194c2b9d..1d5711a79 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */; }; 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 */; }; 3AFE7E412B18D88B00718739 /* WalletService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFE7E402B18D88B00718739 /* WalletService.swift */; }; @@ -726,6 +727,7 @@ 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 = ""; }; 3AFE7E402B18D88B00718739 /* WalletService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletService.swift; sourceTree = ""; }; @@ -1806,6 +1808,7 @@ 93A118522993241D00E144CC /* ChatMessagesListFactory.swift */, 9322E87A2970431200B8357C /* ChatMessageFactory.swift */, 9399F5EC29A85A48006C3E30 /* ChatCacheService.swift */, + 3AF0A6C92BBAF5850019FF47 /* ChatFileService.swift */, 3A9015A82A615893002A2464 /* ChatMessagesListViewModel.swift */, ); path = ViewModel; @@ -3380,6 +3383,7 @@ E90847392196FEF50095825D /* BaseTransaction+TransactionDetails.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 */, E9204B5220C9762400F3B9AB /* MessageStatus.swift in Sources */, 3A770E4C2AE14F130008D98F /* SimpleTransactionDetails+Hashable.swift in Sources */, diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index b7d00067e..2fe0baf9d 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -17,7 +17,7 @@ struct AppAssembly: Assembly { // MARK: AdamantCore container.register(AdamantCore.self) { _ in NativeAdamantCore() }.inObjectScope(.container) - // MARK: AdamantCore + // MARK: FilesStorageProtocol container.register(FilesStorageProtocol.self) { _ in FilesStorageKit() }.inObjectScope(.container) // MARK: CellFactory @@ -268,6 +268,14 @@ 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)!) + }.inObjectScope(.container) + // MARK: Chats container.register(ChatsProvider.self) { r in AdamantChatsProvider( diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 3e8fc7385..457bdb8df 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -28,6 +28,7 @@ struct ChatFactory { let emojiService: EmojiService let walletServiceCompose: WalletServiceCompose let filesStorage: FilesStorageProtocol + let chatFileService: ChatFileProtocol nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! @@ -42,6 +43,7 @@ struct ChatFactory { emojiService = assembler.resolve(EmojiService.self)! walletServiceCompose = assembler.resolve(WalletServiceCompose.self)! filesStorage = assembler.resolve(FilesStorageProtocol.self)! + chatFileService = assembler.resolve(ChatFileProtocol.self)! } func makeViewController(screensFactory: ScreensFactory) -> ChatViewController { @@ -115,7 +117,8 @@ private extension ChatFactory { emojiService: emojiService ), emojiService: emojiService, - filesStorage: filesStorage + filesStorage: filesStorage, + chatFileService: chatFileService ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index 54c66e788..3c4ae97e9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -73,8 +73,8 @@ private extension FileContainerView { let view = filesStack.arrangedSubviews[index] as? ChatFileView view?.isHidden = false view?.model = file - view?.buttonActionHandler = { [actionHandler, file, model] in - actionHandler( + 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/MediaContainerView/MediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift index 4439abbc1..2a1844f81 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -99,8 +99,8 @@ private extension MediaContainerView { let file = fileList[fileOverallIndex] mediaView.isHidden = false mediaView.model = file - mediaView.buttonActionHandler = { [actionHandler, file, model] in - actionHandler( + mediaView.buttonActionHandler = { [weak self, file, model] in + self?.actionHandler( .openFile( messageId: model.messageId, file: file, diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift new file mode 100644 index 000000000..e8e1838ac --- /dev/null +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -0,0 +1,305 @@ +// +// ChatFileService.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 01.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import CommonKit +import FilesNetworkManagerKit +import UIKit +import Combine + +protocol ChatFileProtocol { + var downloadingFilesIDs: Published<[String]>.Publisher { + get + } + + var uploadingFilesIDs: Published<[String]>.Publisher { + get + } + + var updateFileFields: PassthroughSubject<(id: String, newId: String?, preview: UIImage?, cached: Bool), Never> { + get + } + + func sendFile( + text: String?, + chatroom: Chatroom?, + filesPicked: [FileResult]?, + replyMessage: MessageModel? + ) async throws + + func downloadFile( + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom? + ) async throws + + func downloadPreviewIfNeeded( + messageId: String, + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom? + ) +} + +final class ChatFileService: ChatFileProtocol { + // MARK: Dependencies + + private let accountService: AccountService + private let filesStorage: FilesStorageProtocol + private let chatsProvider: ChatsProvider + + @Published private var downloadingFilesIDsArray: [String] = [] + @Published private var uploadingFilesIDsArray: [String] = [] + + var downloadingFilesIDs: Published<[String]>.Publisher { + $downloadingFilesIDsArray + } + + var uploadingFilesIDs: Published<[String]>.Publisher { + $uploadingFilesIDsArray + } + + let updateFileFields = ObservableSender<(id: String, newId: String?, preview: UIImage?, cached: Bool)>() + + init( + accountService: AccountService, + filesStorage: FilesStorageProtocol, + chatsProvider: ChatsProvider + ) { + self.accountService = accountService + self.filesStorage = filesStorage + self.chatsProvider = chatsProvider + } + + func sendFile( + text: String?, + chatroom: Chatroom?, + filesPicked: [FileResult]?, + replyMessage: MessageModel? + ) async throws { + guard let partnerAddress = chatroom?.partner?.address, + let files = filesPicked, + let keyPair = accountService.keypair + else { return } + + guard chatroom?.partner?.isDummy != true else { + return + } + + let replyMessage = replyMessage + + var richFiles: [RichMessageFile.File] = files.compactMap { + RichMessageFile.File.init( + file_id: $0.url.absoluteString, + file_type: $0.extenstion, + file_size: $0.size, + preview_id: $0.previewUrl?.absoluteString, + preview_nonce: nil, + file_name: $0.name, + nonce: .empty, + file_resolution: $0.resolution + ) + } + + let messageLocally: AdamantMessage + + if let replyMessage = replyMessage { + messageLocally = .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + ) + } else { + messageLocally = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + } + + let txLocally = try await chatsProvider.sendFileMessageLocally( + messageLocally, + recipientId: partnerAddress, + from: chatroom + ) + + richFiles.forEach { file in + uploadingFilesIDsArray.append(file.file_id) + } + + for file in files { + let result = try await filesStorage.uploadFile( + file, + recipientPublicKey: chatroom?.partner?.publicKey ?? "", + senderPrivateKey: keyPair.privateKey + ) + + let oldId = file.url.absoluteString + uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) + + let previewID: String + if let id = result.idPreview { + previewID = id + } else { + previewID = result.id + } + + let preview = filesStorage.getPreview( + for: previewID, + type: file.extenstion ?? "" + ) + + let cached = filesStorage.isCached(result.id) + + updateFileFields.send(( + id: oldId, + newId: result.id, + preview: preview, + cached: cached + )) + + if let index = richFiles.firstIndex( + where: { $0.file_id == oldId } + ) { + richFiles[index].file_id = result.id + richFiles[index].nonce = result.nonce + richFiles[index].preview_id = result.idPreview + richFiles[index].preview_nonce = result.noncePreview + } + } + + let message: AdamantMessage + + if let replyMessage = replyMessage { + message = .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + ) + } else { + message = .richMessage( + payload: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + } + + _ = try await chatsProvider.sendFileMessage( + message, + recipientId: partnerAddress, + transactionLocaly: txLocally.0, + context: txLocally.1, + from: chatroom + ) + } + + func downloadFile( + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom? + ) async throws { + guard let keyPair = accountService.keypair + else { return } + + defer { + downloadingFilesIDsArray.removeAll(where: { $0 == file.file.file_id }) + } + downloadingFilesIDsArray.append(file.file.file_id) + + try await filesStorage.downloadFile( + id: file.file.file_id, + storage: file.storage, + fileType: file.file.file_type ?? .empty, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + nonce: file.nonce, + previewId: nil, + previewNonce: nil + ) + + let previewID: String + if let id = file.file.preview_id { + previewID = id + } else { + previewID = file.file.file_id + } + + let preview = filesStorage.getPreview( + for: previewID, + type: file.file.file_type ?? "" + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields.send(( + id: file.file.file_id, + newId: nil, + preview: preview, + cached: cached + )) + } + + func downloadPreviewIfNeeded( + messageId: String, + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom? + ) { + guard let keyPair = accountService.keypair, + !downloadingFilesIDsArray.contains(file.file.file_id), + let previewId = file.file.preview_id, + let previewNonce = file.file.preview_nonce, + !filesStorage.isCached(previewId) + else { return } + + downloadingFilesIDsArray.append(file.file.file_id) + + Task { + defer { + downloadingFilesIDsArray.removeAll(where: { $0 == file.file.file_id }) + } + + try? await filesStorage.cachePreview( + storage: file.storage, + fileType: file.file.file_type ?? .empty, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + previewId: previewId, + previewNonce: previewNonce + ) + + let preview = filesStorage.getPreview( + for: previewId, + type: file.file.file_type ?? .empty + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields.send(( + id: file.file.file_id, + newId: nil, + preview: preview, + cached: cached + )) + } + } +} diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 2c07339e0..27d59b86c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -34,6 +34,7 @@ final class ChatViewModel: NSObject { private let avatarService: AvatarService private let emojiService: EmojiService private let filesStorage: FilesStorageProtocol + private let chatFileService: ChatFileProtocol let chatMessagesListViewModel: ChatMessagesListViewModel @@ -153,7 +154,8 @@ final class ChatViewModel: NSObject { avatarService: AvatarService, chatMessagesListViewModel: ChatMessagesListViewModel, emojiService: EmojiService, - filesStorage: FilesStorageProtocol + filesStorage: FilesStorageProtocol, + chatFileService: ChatFileProtocol ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -170,6 +172,7 @@ final class ChatViewModel: NSObject { self.chatMessagesListViewModel = chatMessagesListViewModel self.emojiService = emojiService self.filesStorage = filesStorage + self.chatFileService = chatFileService super.init() setupObservers() @@ -245,11 +248,6 @@ final class ChatViewModel: NSObject { } func sendMessage(text: String) { - if filesPicked?.count ?? .zero > .zero { - sendFile(text: text) - return - } - guard let partnerAddress = chatroom?.partner?.address else { return } guard chatroom?.partner?.isDummy != true else { @@ -257,6 +255,29 @@ final class ChatViewModel: NSObject { return } + if filesPicked?.count ?? .zero > .zero { + Task { + let replyMessage = replyMessage + let filesPicked = filesPicked + + self.replyMessage = nil + self.filesPicked = nil + + do { + try await chatFileService.sendFile( + text: text, + chatroom: chatroom, + filesPicked: filesPicked, + replyMessage: replyMessage + ) + } catch { + await handleMessageSendingError(error: error, sentText: text) + } + + } + return + } + Task { let message: AdamantMessage @@ -289,144 +310,6 @@ final class ChatViewModel: NSObject { }.stored(in: tasksStorage) } - func sendFile(text: String?) { - guard let partnerAddress = chatroom?.partner?.address, - let files = filesPicked, - let keyPair = accountService.keypair - else { return } - - guard chatroom?.partner?.isDummy != true else { - dialog.send(.dummy(partnerAddress)) - return - } - - let replyMessage = replyMessage - - self.filesPicked = nil - self.replyMessage = nil - - Task { - var richFiles: [RichMessageFile.File] = files.compactMap { - RichMessageFile.File.init( - file_id: $0.url.absoluteString, - file_type: $0.extenstion, - file_size: $0.size, - preview_id: $0.previewUrl?.absoluteString, - preview_nonce: nil, - file_name: $0.name, - nonce: .empty, - file_resolution: $0.resolution - ) - } - - let messageLocally: AdamantMessage - - if let replyMessage = replyMessage { - messageLocally = .richMessage( - payload: RichFileReply( - replyto_id: replyMessage.id, - reply_message: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text - ) - ) - ) - } else { - messageLocally = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text - ) - ) - } - - guard await validateSendingMessage(message: messageLocally) else { return } - - do { - let txLocally = try await chatsProvider.sendFileMessageLocally( - messageLocally, - recipientId: partnerAddress, - from: chatroom - ) - - richFiles.forEach { file in - uploadingFilesIDs.append(file.file_id) - } - - for file in files { - let result = try await filesStorage.uploadFile( - file, - recipientPublicKey: chatroom?.partner?.publicKey ?? "", - senderPrivateKey: keyPair.privateKey - ) - - let oldId = file.url.absoluteString - uploadingFilesIDs.removeAll(where: { $0 == oldId }) - - let previewID: String - if let id = result.idPreview { - previewID = id - } else { - previewID = result.id - } - - let preview = filesStorage.getPreview( - for: previewID, - type: file.extenstion ?? "" - ) - - let cached = filesStorage.isCached(result.id) - - updateFileFields(&messages, id: oldId, newId: result.id, preview: preview, cached: cached) - - if let index = richFiles.firstIndex( - where: { $0.file_id == oldId } - ) { - richFiles[index].file_id = result.id - richFiles[index].nonce = result.nonce - richFiles[index].preview_id = result.idPreview - richFiles[index].preview_nonce = result.noncePreview - } - } - - let message: AdamantMessage - - if let replyMessage = replyMessage { - message = .richMessage( - payload: RichFileReply( - replyto_id: replyMessage.id, - reply_message: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text - ) - ) - ) - } else { - message = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text - ) - ) - } - - _ = try await chatsProvider.sendFileMessage( - message, - recipientId: partnerAddress, - transactionLocaly: txLocally.0, - context: txLocally.1, - from: chatroom - ) - } catch { - await handleMessageSendingError(error: error, sentText: text ?? .empty) - } - }.stored(in: tasksStorage) - } - func forceUpdateTransactionStatus(id: String) { Task { guard @@ -766,53 +649,30 @@ final class ChatViewModel: NSObject { func openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - guard let keyPair = accountService.keypair, - tx?.statusEnum == .delivered, + guard tx?.statusEnum == .delivered, !downloadingFilesID.contains(file.file.file_id) else { return } - Task { - guard !file.isCached else { + guard !file.isCached else { + do { let url = try filesStorage.getFileURL(with: file.file.file_id) presentDocumentViewerVC.send((url, file)) - return - } - - defer { - downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) + } catch { + dialog.send(.alert(error.localizedDescription)) } - downloadingFilesID.append(file.file.file_id) - + return + } + + Task { [weak self] in do { - try await filesStorage.downloadFile( - id: file.file.file_id, - storage: file.storage, - fileType: file.file.file_type ?? .empty, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: file.nonce, - previewId: file.file.preview_id, - previewNonce: file.file.preview_nonce - ) - - let previewID: String - if let id = file.file.preview_id { - previewID = id - } else { - previewID = file.file.file_id - } - - let preview = filesStorage.getPreview( - for: previewID, - type: file.file.file_type ?? "" + try await self?.chatFileService.downloadFile( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: self?.chatroom ) - - let cached = filesStorage.isCached(file.file.file_id) - - updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) } catch { - dialog.send(.alert(error.localizedDescription)) + self?.dialog.send(.alert(error.localizedDescription)) } } } @@ -824,39 +684,14 @@ final class ChatViewModel: NSObject { ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - guard let keyPair = accountService.keypair, - tx?.statusEnum == .delivered, - !downloadingFilesID.contains(file.file.file_id), - let previewId = file.file.preview_id, - let previewNonce = file.file.preview_nonce, - !filesStorage.isCached(previewId) - else { return } - - downloadingFilesID.append(file.file.file_id) + guard tx?.statusEnum == .delivered else { return } - Task { - defer { - downloadingFilesID.removeAll(where: { $0 == file.file.file_id }) - } - - try? await filesStorage.cachePreview( - storage: file.storage, - fileType: file.file.file_type ?? .empty, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - previewId: previewId, - previewNonce: previewNonce - ) - - let preview = filesStorage.getPreview( - for: previewId, - type: file.file.file_type ?? .empty - ) - - let cached = filesStorage.isCached(file.file.file_id) - - updateFileFields(&messages, id: file.file.file_id, preview: preview, cached: cached) - } + chatFileService.downloadPreviewIfNeeded( + messageId: messageId, + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom + ) } func presentActionMenu() { @@ -956,6 +791,35 @@ private extension ChatViewModel { .sink { [weak self] _ in self?.inputTextUpdated() } .store(in: &subscriptions) + chatFileService.downloadingFilesIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + self?.downloadingFilesID = data + } + .store(in: &subscriptions) + + chatFileService.uploadingFilesIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + self?.uploadingFilesIDs = data + } + .store(in: &subscriptions) + + chatFileService.updateFileFields + .receive(on: DispatchQueue.main) + .sink { [weak self] data in + guard let self = self else { return } + + self.updateFileFields( + &self.messages, + id: data.id, + preview: data.preview, + needToUpdatePeview: true, + cached: data.cached + ) + } + .store(in: &subscriptions) + NotificationCenter.default .publisher(for: .AdamantVisibleWalletsService.visibleWallets) .receive(on: RunLoop.main) @@ -1229,9 +1093,11 @@ private extension ChatViewModel { func updateDownloadingFiles(_ messages: inout [ChatMessage]) { messages.indices.forEach { index in messages[index].getFiles().forEach { file in - messages[index].setDownloading( - for: file.file.file_id, - value: downloadingFilesID.contains(file.file.file_id) + messages[index].updateFields( + id: file.file.file_id, + preview: nil, + needToUpdatePeview: false, + isDownloading: downloadingFilesID.contains(file.file.file_id) ) } } @@ -1240,9 +1106,11 @@ private extension ChatViewModel { func updateUploadingFiles(_ messages: inout [ChatMessage]) { messages.indices.forEach { index in messages[index].getFiles().forEach { file in - messages[index].setUploading( - for: file.file.file_id, - value: uploadingFilesIDs.contains(file.file.file_id) + messages[index].updateFields( + id: file.file.file_id, + preview: nil, + needToUpdatePeview: false, + isUploading: uploadingFilesIDs.contains(file.file.file_id) ) } } @@ -1253,10 +1121,21 @@ private extension ChatViewModel { id oldId: String, newId: String? = nil, preview: UIImage?, - cached: Bool + needToUpdatePeview: Bool, + cached: Bool? = nil, + isUploading: Bool? = nil, + isDownloading: Bool? = nil ) { messages.indices.forEach { index in - messages[index].updateFields(id: oldId, newId: newId, preview: preview, cached: cached) + messages[index].updateFields( + id: oldId, + newId: newId, + preview: preview, + needToUpdatePeview: needToUpdatePeview, + cached: cached, + isUploading: isUploading, + isDownloading: isDownloading + ) } } @@ -1312,37 +1191,14 @@ private extension ChatMessage { return model.value.content.fileModel.files } - mutating func setDownloading(for fileId: String, value: Bool) { - guard case let .file(fileModel) = content else { return } - var model = fileModel.value - - guard let index = model.content.fileModel.files.firstIndex( - where: { $0.file.file_id == fileId } - ) else { return } - - model.content.fileModel.files[index].isDownloading = value - - content = .file(.init(value: model)) - } - - mutating func setUploading(for fileId: String, value: Bool) { - guard case let .file(fileModel) = content else { return } - var model = fileModel.value - - guard let index = model.content.fileModel.files.firstIndex( - where: { $0.file.file_id == fileId } - ) else { return } - - model.content.fileModel.files[index].isUploading = value - - content = .file(.init(value: model)) - } - mutating func updateFields( id oldId: String, newId: String? = nil, preview: UIImage?, - cached: Bool + needToUpdatePeview: Bool, + cached: Bool? = nil, + isUploading: Bool? = nil, + isDownloading: Bool? = nil ) { guard case let .file(fileModel) = content else { return } var model = fileModel.value @@ -1354,9 +1210,23 @@ private extension ChatMessage { if let newId = newId { model.content.fileModel.files[index].file.file_id = newId } - model.content.fileModel.files[index].previewImage = preview - model.content.fileModel.files[index].isCached = cached + if let value = cached { + model.content.fileModel.files[index].isCached = value + } + if let value = isUploading { + model.content.fileModel.files[index].isUploading = value + } + if let value = isDownloading { + model.content.fileModel.files[index].isDownloading = value + } + if needToUpdatePeview { + model.content.fileModel.files[index].previewImage = preview + } + guard model != fileModel.value else { + return + } + content = .file(.init(value: model)) } } From e1be0a3b54394a6de5d3f9bbe86aa530e21349b5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 2 Apr 2024 17:03:04 +0300 Subject: [PATCH 043/123] [trello.com/c/uxBZaznD] fix: video icon & auto-download preview --- .../Content/ChatMediaContnentView.swift | 4 +-- .../ChatFileContainerView/ChatFileView.swift | 2 +- .../MediaContainerView/MediaContentView.swift | 2 +- .../FilesToolbarCollectionViewCell.swift | 2 +- .../playVideoIcon.imageset/Contents.json | 23 ++++++++++++++++++ .../playVideoIcon.imageset/playVideoIcon.png | Bin 0 -> 998 bytes .../playVideoIcon@2x.png | Bin 0 -> 1997 bytes .../playVideoIcon@3x.png | Bin 0 -> 3092 bytes 8 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/playVideoIcon.imageset/playVideoIcon@3x.png diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 7fa4df2ba..80a088c44 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -209,11 +209,11 @@ private extension ChatMediaContentView { listFileContainerView.isHidden = model.fileModel.isMediaFilesOnly if model.fileModel.isMediaFilesOnly { - mediaContainerView.model = model.fileModel mediaContainerView.actionHandler = actionHandler + mediaContainerView.model = model.fileModel } else { - fileContainerView.model = model.fileModel fileContainerView.actionHandler = actionHandler + fileContainerView.model = model.fileModel } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index ef7d02bd9..0f84ea5e5 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -12,7 +12,7 @@ import CommonKit class ChatFileView: UIView { private lazy var iconImageView: UIImageView = UIImageView() private lazy var downloadImageView = UIImageView(image: .asset(named: "downloadIcon")) - private lazy var videoIconIV = UIImageView(image: .init(systemName: "play.circle")) + private lazy var videoIconIV = UIImageView(image: .asset(named: "playVideoIcon")) private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) 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 index 2173b4c6f..c26fff842 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -13,7 +13,7 @@ import SnapKit 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: .init(systemName: "play.circle")) + private lazy var videoIconIV = UIImageView(image: .asset(named: "playVideoIcon")) private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index c656319b7..02406c2d5 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -13,7 +13,7 @@ import CommonKit final class FilesToolbarCollectionViewCell: UICollectionViewCell { private lazy var imageView = UIImageView(image: .init(systemName: "shareplay")) - private lazy var videoIconIV = UIImageView(image: .init(systemName: "play.circle")) + 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) 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 0000000000000000000000000000000000000000..25f68d69fcbd896e81054ce1838ffc80f6c424e8 GIT binary patch literal 998 zcmVGq)KgGwsZsna(`P2~3AF?>+b2Gv}T=25*cBiwm0)c3s$nu$XSV z*AsRwtSzje+dpAV{hsneRiMCGVYh@`g?l)Nim)2A0l31gXo|ffY#QnVHbQSx_%U3F zclzLQXku@}AF)>a5>|#5P6KfwfZn|?>;{6TC9ELqY|tOYFqlbU^UxOCD7XS`tuW?o z!)1_YF0zLhJ}|#wD9%9hx}|VCK^*M^1Q)+fU>aq5Y@2UbiDoF!!{Z1SI*eKBR9MG& z!z_%~^Uz`|3Qz}U{E|+yr36n0Hs7)3`_Frm&l;|1Z1 zBWht`hKrKX&=<`VrVGKL1txTWe@(toc)QS85#>vgdVo~JV5cE86Fb|0D6CUdHHpPew-Z-J?NAOU^ITbA_6bd_=o15Dn#5XDW2|QGCZbcK1 z@!qBAr|?kCsfdgwt~{Gm?K+=I=*XPAq3cXwa;C{|LJo@Vu=ROK;FW3kwj@%rfK zD6_P*v;+USXk^NI`UHwlAb%PcSwMmbo0k36%N@Z2!tHYmgYT2Pu%`4hL*x&lO}{N{ z+}N^f8QR;?0VVm1vAXN%*EbP8*uU;ag1N)=69MOK1=xr@Hhy4LN3)Z|j%nDvJi^)k z0#m7BCAtgk)lT7IXOkb$I;WSg+p)BD3#);{gzz0U?~g&-EXFqDi3@X-p~9Kw5r%CF z9=11>`Fk3pIT7IVzCt^1w8e=Kj-0PgPT3@af&crruy3&F{F`yR&{$NlBAkJL=ZKt1 zzBtaDeo6JgvdgM^V#^f9*{fm%C$?7KmuKQM4u*q}yL@`GtBY*t0$IPe;Gtyr4`Fc_ UF`NXlasU7T07*qoM6N<$f{KIH!T8 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 Date: Wed, 3 Apr 2024 12:12:14 +0300 Subject: [PATCH 044/123] [trello.com/c/uxBZaznD] fix: thumbnail preview for video --- .../Helpers/FilesPickerKitHelper.swift | 99 ++++++++++++++----- .../FilesPickerKit/Models/Constants.swift | 2 +- .../Pickers/MediaPickerService.swift | 20 ++-- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 561a20962..73d791538 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -5,6 +5,7 @@ import CommonKit import UIKit import SwiftUI import AVFoundation +import QuickLook final class FilesPickerKitHelper { func validateFiles(_ files: [FileResult]) throws { @@ -41,17 +42,7 @@ final class FilesPickerKitHelper { } func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { - let size = image.size - - let widthRatio = targetSize.width / size.width - let heightRatio = targetSize.height / size.height - - var newSize: CGSize - if(widthRatio > heightRatio) { - newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) - } else { - newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) - } + let newSize = getPreviewSize(from: image.size) let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) @@ -60,21 +51,42 @@ final class FilesPickerKitHelper { let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - return newImage! + return newImage ?? image } - func getThumbnailImage(forUrl url: URL) -> UIImage? { - let asset: AVAsset = AVAsset(url: url) - let imageGenerator = AVAssetImageGenerator(asset: asset) - - do { - let thumbnailImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 1, timescale: 60), actualTime: nil) - - let image = UIImage(cgImage: thumbnailImage) - return image - } catch { - return nil + 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)) + } + + func getThumbnailImage( + forUrl url: URL, + originalSize: CGSize? + ) async throws -> UIImage? { + var thumbnailSize: CGSize? + + if let size = originalSize { + thumbnailSize = getPreviewSize(from: size) } + + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: thumbnailSize ?? FilesConstants.previewSize, + scale: 1.0, + representationTypes: .thumbnail + ) + + let image = try await QLThumbnailGenerator.shared.generateBestRepresentation( + for: request + ).uiImage + + return image } func getFileSize(from fileURL: URL) throws -> Int64 { @@ -158,6 +170,33 @@ final class FilesPickerKitHelper { } } } +} + +private extension FilesPickerKitHelper { + func getPreviewSize(from originalSize: CGSize?) -> CGSize { + guard let size = originalSize else { return FilesConstants.previewSize } + + let width = abs(size.width) + let height = abs(size.height) + + let widthRatio = FilesConstants.previewSize.width / width + let heightRatio = FilesConstants.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 { var mimeType: String? @@ -204,4 +243,18 @@ final class FilesPickerKitHelper { 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 + } + } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift index 8442a83f7..22175905a 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift @@ -11,6 +11,6 @@ import UIKit public final class FilesConstants { public static let maxFilesCount = 5 static let maxFileSize: Int64 = 10 * 1024 * 1024 - static let previewSize: CGSize = .init(squareSize: 300) + static let previewSize: CGSize = .init(squareSize: 400) static let previewTag: String = "preview_" } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 7f6a1eae3..3c0ee72c8 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -81,19 +81,15 @@ private extension MediaPickerService { let fileSize = try? helper.getFileSize(from: url) else { continue } - let thumbnailImage = helper.getThumbnailImage(forUrl: url) + let originalSize = helper.getOriginalSize(for: url) - var resizedPreviewImage: UIImage? - - if let preview = thumbnailImage { - resizedPreviewImage = helper.resizeImage( - image: preview, - targetSize: FilesConstants.previewSize - ) - } + let thumbnailImage = try? await helper.getThumbnailImage( + forUrl: url, + originalSize: originalSize + ) let previewUrl = try? helper.getUrl( - for: resizedPreviewImage, + for: thumbnailImage, name: FilesConstants.previewTag + url.lastPathComponent ) @@ -101,12 +97,12 @@ private extension MediaPickerService { .init( url: url, type: .video, - preview: resizedPreviewImage, + preview: thumbnailImage, previewUrl: previewUrl, size: fileSize, name: itemProvider.suggestedName, extenstion: url.pathExtension, - resolution: thumbnailImage?.size + resolution: originalSize ) ) } From 7ad42ea1021ced18374866cf76469ccc6616276e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 3 Apr 2024 14:23:29 +0300 Subject: [PATCH 045/123] [trello.com/c/uxBZaznD] fix: structure of cached files --- .../Chat/View/ChatViewController.swift | 2 +- .../Chat/ViewModel/ChatFileService.swift | 19 ++- .../Chat/ViewModel/ChatViewModel.swift | 6 +- .../FilesStorageProtocol.swift | 10 +- .../Helpers/FilesPickerKitHelper.swift | 6 +- .../FilesStorageKit/FilesStorageKit.swift | 157 ++++++++++++------ 6 files changed, 140 insertions(+), 60 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 8ee4ccf29..daf0bb7f6 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -507,7 +507,7 @@ private extension ChatViewController { } filesToolbarView.closeAction = { [weak self] in - self?.viewModel.filesPicked = nil + self?.viewModel.updateFiles(nil) } filesToolbarView.updatedDataAction = { [weak self] data in diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index e8e1838ac..b2cd793eb 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -84,7 +84,8 @@ final class ChatFileService: ChatFileProtocol { ) async throws { guard let partnerAddress = chatroom?.partner?.address, let files = filesPicked, - let keyPair = accountService.keypair + let keyPair = accountService.keypair, + let ownerId = accountService.account?.address else { return } guard chatroom?.partner?.isDummy != true else { @@ -143,7 +144,9 @@ final class ChatFileService: ChatFileProtocol { let result = try await filesStorage.uploadFile( file, recipientPublicKey: chatroom?.partner?.publicKey ?? "", - senderPrivateKey: keyPair.privateKey + senderPrivateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: partnerAddress ) let oldId = file.url.absoluteString @@ -217,7 +220,9 @@ final class ChatFileService: ChatFileProtocol { isFromCurrentSender: Bool, chatroom: Chatroom? ) async throws { - guard let keyPair = accountService.keypair + guard let keyPair = accountService.keypair, + let ownerId = accountService.account?.address, + let recipientId = chatroom?.partner?.address else { return } defer { @@ -231,6 +236,8 @@ final class ChatFileService: ChatFileProtocol { fileType: file.file.file_type ?? .empty, senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId, nonce: file.nonce, previewId: nil, previewNonce: nil @@ -268,7 +275,9 @@ final class ChatFileService: ChatFileProtocol { !downloadingFilesIDsArray.contains(file.file.file_id), let previewId = file.file.preview_id, let previewNonce = file.file.preview_nonce, - !filesStorage.isCached(previewId) + !filesStorage.isCached(previewId), + let ownerId = accountService.account?.address, + let recipientId = chatroom?.partner?.address else { return } downloadingFilesIDsArray.append(file.file.file_id) @@ -283,6 +292,8 @@ final class ChatFileService: ChatFileProtocol { fileType: file.file.file_type ?? .empty, senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId, previewId: previewId, previewNonce: previewNonce ) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 27d59b86c..71b1ab925 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -773,8 +773,12 @@ extension ChatViewModel { dialog.send(.renameAlert) } - func updateFiles(_ data: [FileResult]) { + func updateFiles(_ data: [FileResult]?) { filesPicked = data + + if (data?.count ?? .zero) == .zero { + try? filesStorage.clearTempCache() + } } } diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index d00d26368..d19b5c04d 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -21,7 +21,9 @@ protocol FilesStorageProtocol { func uploadFile( _ file: FileResult, recipientPublicKey: String, - senderPrivateKey: String + senderPrivateKey: String, + ownerId: String, + recipientId: String ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) func downloadFile( @@ -30,6 +32,8 @@ protocol FilesStorageProtocol { fileType: String?, senderPublicKey: String, recipientPrivateKey: String, + ownerId: String, + recipientId: String, nonce: String, previewId: String?, previewNonce: String? @@ -39,11 +43,15 @@ protocol FilesStorageProtocol { func clearCache() throws + func clearTempCache() throws + func cachePreview( storage: String, fileType: String?, senderPublicKey: String, recipientPrivateKey: String, + ownerId: String, + recipientId: String, previewId: String, previewNonce: String ) async throws diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 73d791538..bbad8c713 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -30,7 +30,7 @@ final class FilesPickerKitHelper { in: .userDomainMask, appropriateFor: nil, create: true - ).appendingPathComponent("cachePath") + ).appendingPathComponent(cachePath) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) @@ -148,7 +148,7 @@ final class FilesPickerKitHelper { in: .userDomainMask, appropriateFor: nil, create: true - ).appendingPathComponent("cachePath") + ).appendingPathComponent(cachePath) try FileManager.default.createDirectory( at: folder, @@ -258,3 +258,5 @@ private extension FilesPickerKitHelper { } } } + +private let cachePath = "downloads/cache" diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 99f585f87..fd0b2ac68 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -25,6 +25,8 @@ public final class FilesStorageKit { fileType: String?, senderPublicKey: String, recipientPrivateKey: String, + ownerId: String, + recipientId: String, previewId: String, previewNonce: String ) async throws { @@ -35,7 +37,9 @@ public final class FilesStorageKit { fileType: fileType, senderPublicKey: senderPublicKey, recipientPrivateKey: recipientPrivateKey, - nonce: previewNonce + nonce: previewNonce, + ownerId: ownerId, + recipientId: recipientId ) } } @@ -69,12 +73,16 @@ public final class FilesStorageKit { public func uploadFile( _ file: FileResult, recipientPublicKey: String, - senderPrivateKey: String + senderPrivateKey: String, + ownerId: String, + recipientId: String ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) { let result = try await uploadFile( url: file.url, recipientPublicKey: recipientPublicKey, - senderPrivateKey: senderPrivateKey + senderPrivateKey: senderPrivateKey, + ownerId: ownerId, + recipientId: recipientId ) var resultPreview: UploadResult? @@ -83,7 +91,9 @@ public final class FilesStorageKit { resultPreview = try? await uploadFile( url: url, recipientPublicKey: recipientPublicKey, - senderPrivateKey: senderPrivateKey + senderPrivateKey: senderPrivateKey, + ownerId: ownerId, + recipientId: recipientId ) } @@ -96,6 +106,8 @@ public final class FilesStorageKit { fileType: String?, senderPublicKey: String, recipientPrivateKey: String, + ownerId: String, + recipientId: String, nonce: String, previewId: String?, previewNonce: String? @@ -108,7 +120,9 @@ public final class FilesStorageKit { fileType: fileType, senderPublicKey: senderPublicKey, recipientPrivateKey: recipientPrivateKey, - nonce: previewNonce + nonce: previewNonce, + ownerId: ownerId, + recipientId: recipientId ) } @@ -118,7 +132,9 @@ public final class FilesStorageKit { fileType: fileType, senderPublicKey: senderPublicKey, recipientPrivateKey: recipientPrivateKey, - nonce: nonce + nonce: nonce, + ownerId: ownerId, + recipientId: recipientId ) } @@ -134,18 +150,39 @@ public final class FilesStorageKit { } public func clearCache() throws { - let url = try FileManager.default.url( + let cacheUrl = try FileManager.default.url( for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true ).appendingPathComponent(cachePath) - try FileManager.default.removeItem(at: url) + if FileManager.default.fileExists( + atPath: cacheUrl.path + ) { + try FileManager.default.removeItem(at: cacheUrl) + } + + try clearTempCache() cachedFiles.removeAllObjects() cachedFilesUrl.removeAll() } + + 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) + } } private extension FilesStorageKit { @@ -155,7 +192,9 @@ private extension FilesStorageKit { fileType: String?, senderPublicKey: String, recipientPrivateKey: String, - nonce: String + nonce: String, + ownerId: String, + recipientId: String ) async throws { let decodedData = try await networkService.downloadFile( id: id, @@ -166,13 +205,20 @@ private extension FilesStorageKit { nonce: nonce ) - return try cacheFile(id: id, data: decodedData) + return try cacheFile( + id: id, + data: decodedData, + ownerId: ownerId, + recipientId: recipientId + ) } func uploadFile( url: URL, recipientPublicKey: String, - senderPrivateKey: String + senderPrivateKey: String, + ownerId: String, + recipientId: String ) async throws -> UploadResult { let result = try await networkService.uploadFile( url: url, @@ -180,7 +226,12 @@ private extension FilesStorageKit { senderPrivateKey: senderPrivateKey ) - try cacheFile(id: result.id, data: result.data) + try cacheFile( + id: result.id, + localUrl: url, + ownerId: ownerId, + recipientId: recipientId + ) return (id: result.id, nonce: result.nonce) } @@ -193,8 +244,8 @@ private extension FilesStorageKit { create: true ).appendingPathComponent(cachePath) - let files = getFiles(at: folder) - + let files = getAllFiles(in: folder) + files.forEach { url in cachedFilesUrl[url.lastPathComponent] = url @@ -204,60 +255,63 @@ private extension FilesStorageKit { } } - func getFiles(at url: URL) -> [URL] { + func getAllFiles(in directoryURL: URL) -> [URL] { + var fileURLs: [URL] = [] + let fileManager = FileManager.default - var isDirectory: ObjCBool = false - var subdirectoryNames: [URL] = [] - - guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { - return subdirectoryNames - } - - for item in contents { - if fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory) && !isDirectory.boolValue { - subdirectoryNames.append(item) + 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 subdirectoryNames + + return fileURLs } - - func cacheFile(id: String, data: Data) throws { + + func cacheFile( + id: String, + data: Data? = nil, + localUrl: URL? = nil, + ownerId: String, + recipientId: String + ) throws { let folder = try FileManager.default.url( for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true - ).appendingPathComponent(cachePath) + ).appendingPathComponent("\(cachePath)/\(ownerId)/\(recipientId)") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let fileURL = folder.appendingPathComponent(id) - try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - - cachedFilesUrl[id] = fileURL - if let uiImage = UIImage(data: data) { - cachedFiles.setObject(uiImage, forKey: id as NSString) - } - } - - private func getPreview(for type: String, url: URL?) -> URL? { - switch type.uppercased() { - case "JPG", "JPEG", "PNG", "GIF", "WEBP", "TIF", "TIFF", "BMP", "HEIF", "HEIC", "JP2": - if let url = url { - return url - } + if let data = data { + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") - case "MOV", "MP4": - if let url = url { - return url + cachedFilesUrl[id] = fileURL + if let uiImage = UIImage(data: data) { + cachedFiles.setObject(uiImage, forKey: id as NSString) } + } + + if let url = localUrl { + try FileManager.default.moveItem(at: url, to: fileURL) - return getLocalImageUrl(by: "file-image-box", withExtension: "jpg") - default: - return nil + cachedFilesUrl[id] = fileURL + if let uiImage = UIImage(contentsOfFile: fileURL.path) { + cachedFiles.setObject(uiImage, forKey: id as NSString) + } } } @@ -288,3 +342,4 @@ private extension FilesStorageKit { } private let cachePath = "downloads" +private let tempCachePath = "downloads/cache" From 6507792894e3f52189ce3147b9a8e67fcf9e293e Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 3 Apr 2024 15:11:26 +0300 Subject: [PATCH 046/123] [trello.com/c/uxBZaznD] fix: preview in chat when sending a file --- Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift | 8 ++++---- CommonKit/Sources/CommonKit/Models/RichMessage.swift | 6 ++++-- .../Sources/FilesStorageKit/FilesStorageKit.swift | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index b997a6a75..49ca1010d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -382,9 +382,9 @@ private extension ChatMessageFactory { storage: String ) -> [ChatFile] { return files.map { - let previewId = $0[RichContentKeys.file.preview_id] as? String ?? "" - let fileType = $0[RichContentKeys.file.file_type] as? String ?? "" - let fileId = $0[RichContentKeys.file.file_id] as? String ?? "" + let previewId = $0[RichContentKeys.file.preview_id] as? String ?? .empty + let fileType = $0[RichContentKeys.file.file_type] as? String ?? .empty + let fileId = $0[RichContentKeys.file.file_id] as? String ?? .empty return ChatFile( file: RichMessageFile.File($0), @@ -393,7 +393,7 @@ private extension ChatMessageFactory { isUploading: uploadingFilesIDs.contains(fileId), isCached: filesStorage.isCached(fileId), storage: storage, - nonce: $0[RichContentKeys.file.nonce] as? String ?? "", + nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, isFromCurrentSender: isFromCurrentSender, fileType: FileType(raw: fileType) ?? .other ) diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index c6d029f7f..36aa9980f 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -153,9 +153,11 @@ public struct RichMessageFile: RichMessage { contentDict[RichContentKeys.file.file_type] = file_type } - if let preview_id = preview_id, !preview_id.isEmpty, - let preview_nonce = preview_nonce, !preview_nonce.isEmpty { + if let preview_id = preview_id, !preview_id.isEmpty { contentDict[RichContentKeys.file.preview_id] = preview_id + } + + if let preview_nonce = preview_nonce, !preview_nonce.isEmpty { contentDict[RichContentKeys.file.preview_nonce] = preview_nonce } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index fd0b2ac68..4154386ed 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -309,8 +309,10 @@ private extension FilesStorageKit { try FileManager.default.moveItem(at: url, to: fileURL) cachedFilesUrl[id] = fileURL + cachedFilesUrl[url.absoluteString] = fileURL if let uiImage = UIImage(contentsOfFile: fileURL.path) { cachedFiles.setObject(uiImage, forKey: id as NSString) + cachedFiles.setObject(uiImage, forKey: url.absoluteString as NSString) } } } From 8644d0cc0e6e6680bf60ccbf855a6a3e8a03cee5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 3 Apr 2024 15:29:10 +0300 Subject: [PATCH 047/123] [trello.com/c/uxBZaznD] fix: send a file --- .../Helpers/FilesPickerKitHelper.swift | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index bbad8c713..fd0cda76e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -41,6 +41,38 @@ final class FilesPickerKitHelper { return fileURL } + func copyFile(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(cachePath) + + try FileManager.default.createDirectory( + at: folder, + withIntermediateDirectories: true + ) + + let targetURL = folder.appendingPathComponent(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 + } + func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { let newSize = getPreviewSize(from: image.size) @@ -109,16 +141,17 @@ final class FilesPickerKitHelper { } func getFileResult(for url: URL) throws -> FileResult { - let preview = getPreview(for: url) - let fileSize = try getFileSize(from: url) + let newUrl = try copyFile(from: url) + let preview = getPreview(for: newUrl) + let fileSize = try getFileSize(from: newUrl) return FileResult( - url: url, + url: newUrl, type: .other, preview: preview.image, previewUrl: preview.url, size: fileSize, - name: url.lastPathComponent, - extenstion: url.pathExtension, + name: newUrl.lastPathComponent, + extenstion: newUrl.pathExtension, resolution: preview.resolution ) } @@ -131,7 +164,7 @@ final class FilesPickerKitHelper { } return try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + itemProvider.loadFileRepresentation(forTypeIdentifier: type) { [weak self] url, error in if let error = error { continuation.resume(throwing: error) return @@ -143,26 +176,11 @@ final class FilesPickerKitHelper { } do { - let folder = try FileManager.default.url( - for: .cachesDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ).appendingPathComponent(cachePath) - - try FileManager.default.createDirectory( - at: folder, - withIntermediateDirectories: true - ) - - let targetURL = folder.appendingPathComponent(url.lastPathComponent) - - if FileManager.default.fileExists(atPath: targetURL.path) { - try FileManager.default.removeItem(at: targetURL) + guard let targetURL = try self?.copyFile(from: url) else { + continuation.resume(throwing: FileValidationError.fileNotFound) + return } - try FileManager.default.copyItem(at: url, to: targetURL) - continuation.resume(returning: targetURL) } catch { continuation.resume(throwing: error) From dea52207ade89e9bf78caa8668236a7d9a62895c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 3 Apr 2024 15:34:45 +0300 Subject: [PATCH 048/123] [trello.com/c/uxBZaznD] fix: popup design --- Adamant/Modules/StorageUsage/StorageUsageViewModel.swift | 2 +- Adamant/ServiceProtocols/DialogService.swift | 2 +- Adamant/Services/AdamantDialogService.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index d4179ba13..4606ebd29 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -46,7 +46,7 @@ final class StorageUsageViewModel: ObservableObject { dialogService.showProgress(withMessage: nil, userInteractionEnable: false) try filesStorage.clearCache() dialogService.dismissProgress() - dialogService.showSuccess(withMessage: .empty) + dialogService.showSuccess(withMessage: nil) updateCacheSize() NotificationCenter.default.post(name: .Storage.storageClear, object: nil) } catch { diff --git a/Adamant/ServiceProtocols/DialogService.swift b/Adamant/ServiceProtocols/DialogService.swift index a61c42a52..f702e95dc 100644 --- a/Adamant/ServiceProtocols/DialogService.swift +++ b/Adamant/ServiceProtocols/DialogService.swift @@ -151,7 +151,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/Services/AdamantDialogService.swift b/Adamant/Services/AdamantDialogService.swift index 3fc83c5dc..1c96361af 100644 --- a/Adamant/Services/AdamantDialogService.swift +++ b/Adamant/Services/AdamantDialogService.swift @@ -84,7 +84,7 @@ extension AdamantDialogService { popupManager.dismissAlert() } - func showSuccess(withMessage message: String) { + func showSuccess(withMessage message: String?) { vibroService.applyVibration(.success) popupManager.showSuccessAlert(message: message) } From aca40588340046ca7dfde195166c65fc66ca0d4b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 4 Apr 2024 11:35:17 +0300 Subject: [PATCH 049/123] [trello.com/c/uxBZaznD] fix: auto download preview from app settings --- Adamant.xcodeproj/project.pbxproj | 8 ++ Adamant/App/DI/AppAssembly.swift | 7 ++ Adamant/Modules/Chat/ChatFactory.swift | 5 +- .../Chat/ViewModel/ChatViewModel.swift | 13 +++- .../ChatsList/ChatListViewController.swift | 23 ++++-- .../StorageUsage/StorageUsageFactory.swift | 3 +- .../StorageUsage/StorageUsageView.swift | 4 +- .../StorageUsage/StorageUsageViewModel.swift | 31 +++++--- .../FilesStorageProprietiesProtocol.swift | 14 ++++ .../FilesStorageProprietiesService.swift | 73 +++++++++++++++++++ .../Sources/CommonKit/Core/SecuredStore.swift | 4 + 11 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift create mode 100644 Adamant/Services/FilesStorageProprietiesService.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 1d5711a79..0c302c2eb 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -60,6 +60,8 @@ 3AA6DF442BA997C000EA2E16 /* FileContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */; }; 3AA6DF462BA9BEB700EA2E16 /* MediaContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.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 */; }; 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 */; }; @@ -722,6 +724,8 @@ 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainerView.swift; sourceTree = ""; }; 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContainerView.swift; sourceTree = ""; }; 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContentView.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 = ""; }; 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 = ""; }; @@ -2081,6 +2085,7 @@ 3A96E37B2AED27F8001F5A52 /* PartnerQRService.swift */, 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */, 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */, + 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */, ); path = ServiceProtocols; sourceTree = ""; @@ -2118,6 +2123,7 @@ 3A2F55FD2AC6F90E000A3F26 /* AdamantCoinStorageService.swift */, 3A96E3792AED27D7001F5A52 /* AdamantPartnerQRService.swift */, 3AF08D5E2B4EB3A200EB82B1 /* LanguageService.swift */, + 3ACD307D2BBD86B700ABF671 /* FilesStorageProprietiesService.swift */, ); path = Services; sourceTree = ""; @@ -3159,6 +3165,7 @@ 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 */, @@ -3341,6 +3348,7 @@ 93E8EDCD2AF1BD65003E163C /* AdamantApiCore.swift in Sources */, 6416B1A721B024B6006089AC /* LskWalletService+Send.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 */, diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index 2fe0baf9d..d9b60debd 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -276,6 +276,13 @@ struct AppAssembly: Assembly { chatsProvider: r.resolve(ChatsProvider.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/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 457bdb8df..794b85e47 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -29,6 +29,7 @@ struct ChatFactory { let walletServiceCompose: WalletServiceCompose let filesStorage: FilesStorageProtocol let chatFileService: ChatFileProtocol + let filesStorageProprieties: FilesStorageProprietiesProtocol nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! @@ -44,6 +45,7 @@ struct ChatFactory { walletServiceCompose = assembler.resolve(WalletServiceCompose.self)! filesStorage = assembler.resolve(FilesStorageProtocol.self)! chatFileService = assembler.resolve(ChatFileProtocol.self)! + filesStorageProprieties = assembler.resolve(FilesStorageProprietiesProtocol.self)! } func makeViewController(screensFactory: ScreensFactory) -> ChatViewController { @@ -118,7 +120,8 @@ private extension ChatFactory { ), emojiService: emojiService, filesStorage: filesStorage, - chatFileService: chatFileService + chatFileService: chatFileService, + filesStorageProprieties: filesStorageProprieties ) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 71b1ab925..5f808f9b6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -35,6 +35,7 @@ final class ChatViewModel: NSObject { private let emojiService: EmojiService private let filesStorage: FilesStorageProtocol private let chatFileService: ChatFileProtocol + private let filesStorageProprieties: FilesStorageProprietiesProtocol let chatMessagesListViewModel: ChatMessagesListViewModel @@ -155,7 +156,8 @@ final class ChatViewModel: NSObject { chatMessagesListViewModel: ChatMessagesListViewModel, emojiService: EmojiService, filesStorage: FilesStorageProtocol, - chatFileService: ChatFileProtocol + chatFileService: ChatFileProtocol, + filesStorageProprieties: FilesStorageProprietiesProtocol ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -173,6 +175,7 @@ final class ChatViewModel: NSObject { self.emojiService = emojiService self.filesStorage = filesStorage self.chatFileService = chatFileService + self.filesStorageProprieties = filesStorageProprieties super.init() setupObservers() @@ -683,8 +686,12 @@ final class ChatViewModel: NSObject { isFromCurrentSender: Bool ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - - guard tx?.statusEnum == .delivered else { return } + let message = messages.first(where: { $0.messageId == messageId }) + + guard let message = message, + tx?.statusEnum == .delivered || (message.status != .failed && message.status != .pending), + filesStorageProprieties.enabledAutoDownloadPreview() + else { return } chatFileService.downloadPreviewIfNeeded( messageId: messageId, diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 02cd385a2..52b995717 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -306,14 +306,25 @@ final class ChatListViewController: KeyboardObservingViewController { .publisher(for: .Storage.storageClear) .receive(on: OperationQueue.main) .sink { [weak self] _ in - guard let splitVC = self?.tabBarController?.viewControllers?.first as? UISplitViewController, - !splitVC.isCollapsed - else { return } - - splitVC.showDetailViewController(WelcomeViewController(), sender: nil) - + 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() { diff --git a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift index c60582134..9087cb08b 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift @@ -30,7 +30,8 @@ private struct StorageUsageAssembly: Assembly { container.register(StorageUsageViewModel.self) { StorageUsageViewModel( filesStorage: $0.resolve(FilesStorageProtocol.self)!, - dialogService: $0.resolve(DialogService.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 index 6bffd62be..d25400753 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -56,7 +56,7 @@ struct StorageUsageView: View { } } .onAppear(perform: { - viewModel.updateCacheSize() + viewModel.loadData() }) .withoutListBackground() .background(Color(.adamant.secondBackgroundColor)) @@ -84,7 +84,7 @@ private extension StorageUsageView { Image(uiImage: previewImage) Text(previewTitle) } - .onLongPressGesture { + .onChange(of: viewModel.autoDownloadPreview) { value in viewModel.togglePreviewContent() } } diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 4606ebd29..ece0066a9 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -13,6 +13,7 @@ import SwiftUI public extension Notification.Name { struct Storage { public static let storageClear = Notification.Name("adamant.storage.clear") + public static let storageProprietiesUpdated = Notification.Name("adamant.storage.ProprietiesUpdated") } } @@ -20,25 +21,25 @@ public extension Notification.Name { final class StorageUsageViewModel: ObservableObject { private let filesStorage: FilesStorageProtocol private let dialogService: DialogService + private let filesStorageProprieties: FilesStorageProprietiesProtocol @Published var storageUsedDescription: String? - @Published var autoDownloadPreview: Bool = false + @Published var autoDownloadPreview: Bool = true nonisolated init( filesStorage: FilesStorageProtocol, - dialogService: DialogService + dialogService: DialogService, + filesStorageProprieties: FilesStorageProprietiesProtocol ) { self.filesStorage = filesStorage self.dialogService = dialogService + self.filesStorageProprieties = filesStorageProprieties + } - func updateCacheSize() { - DispatchQueue.global().async { - let size = (try? self.filesStorage.getCacheSize()) ?? .zero - DispatchQueue.main.async { - self.storageUsedDescription = self.formatSize(size) - } - } + func loadData() { + autoDownloadPreview = filesStorageProprieties.enabledAutoDownloadPreview() + updateCacheSize() } func clearStorage() { @@ -60,11 +61,21 @@ final class StorageUsageViewModel: ObservableObject { } func togglePreviewContent() { - + filesStorageProprieties.setEnabledAutoDownloadPreview(autoDownloadPreview) + NotificationCenter.default.post(name: .Storage.storageProprietiesUpdated, object: nil) } } private extension StorageUsageViewModel { + func updateCacheSize() { + DispatchQueue.global().async { + let size = (try? self.filesStorage.getCacheSize()) ?? .zero + DispatchQueue.main.async { + self.storageUsedDescription = self.formatSize(size) + } + } + } + func formatSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useGB, .useMB, .useKB] diff --git a/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift new file mode 100644 index 000000000..970dce439 --- /dev/null +++ b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift @@ -0,0 +1,14 @@ +// +// FilesStorageProprietiesProtocol.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 03.04.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation + +protocol FilesStorageProprietiesProtocol { + func enabledAutoDownloadPreview() -> Bool + func setEnabledAutoDownloadPreview(_ value: Bool) +} diff --git a/Adamant/Services/FilesStorageProprietiesService.swift b/Adamant/Services/FilesStorageProprietiesService.swift new file mode 100644 index 000000000..ad3912ae9 --- /dev/null +++ b/Adamant/Services/FilesStorageProprietiesService.swift @@ -0,0 +1,73 @@ +// +// 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 isEnabledAutoDownloadPreview: Bool = 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() { + isEnabledAutoDownloadPreview = getEnabledAutoDownloadPreview() + } + + private func userLoggedOut() { + setEnabledAutoDownloadPreview(true) + } + + // MARK: Update data + + func enabledAutoDownloadPreview() -> Bool { + isEnabledAutoDownloadPreview + } + + func getEnabledAutoDownloadPreview() -> Bool { + guard let result: Bool = securedStore.get( + StoreKey.storage.autoDownloadPreviewEnabled + ) else { + return true + } + + return result + } + + func setEnabledAutoDownloadPreview(_ value: Bool) { + securedStore.set(value, for: StoreKey.storage.autoDownloadPreviewEnabled) + isEnabledAutoDownloadPreview = value + } +} diff --git a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift index 8a7a3d920..8dddb578e 100644 --- a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift +++ b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift @@ -56,6 +56,10 @@ public extension StoreKey { public static let language = "language" public static let languageLocale = "language.locale" } + + enum storage { + public static let autoDownloadPreviewEnabled = "autoDownloadPreviewEnabled" + } } public protocol SecuredStore: AnyObject { From 4a93c5a311accbbaef43ab227d34c645b7937f70 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 4 Apr 2024 13:02:43 +0300 Subject: [PATCH 050/123] [trello.com/c/uxBZaznD] feat: preview all files in fullscreen from one message --- .../Chat/View/ChatViewController.swift | 12 +++-- .../Chat/ViewModel/ChatViewModel.swift | 34 +++++++++++--- .../Pickers/DocumentInteractionService.swift | 45 +++++++++++-------- 3 files changed, 61 insertions(+), 30 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index ec80c73d0..a85366d2d 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -414,8 +414,8 @@ private extension ChatViewController { .store(in: &subscriptions) viewModel.presentDocumentViewerVC - .sink { [weak self] (url, file) in - self?.presentDocumentViewer(url: url, file: file) + .sink { [weak self] (files, index) in + self?.presentDocumentViewer(files: files, selectedIndex: index) } .store(in: &subscriptions) @@ -592,18 +592,16 @@ private extension ChatViewController { present(documentPicker, animated: true) } - func presentDocumentViewer(url: URL, file: ChatFile) { + func presentDocumentViewer(files: [FileResult], selectedIndex: Int) { documentViewerService.openFile( - url: url, - name: file.file.file_name ?? .empty, - size: file.file.file_size, - ext: file.file.file_type ?? .empty + files: files ) let quickVC = QLPreviewController() quickVC.delegate = documentViewerService quickVC.dataSource = documentViewerService quickVC.modalPresentationStyle = .fullScreen + quickVC.currentPreviewItemIndex = selectedIndex if let splitViewController = splitViewController { splitViewController.present(quickVC, animated: true) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 2240497d8..a06b886b2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -87,7 +87,7 @@ final class ChatViewModel: NSObject { let presentSendTokensVC = ObservableSender() let presentMediaPickerVC = ObservableSender() let presentDocumentPickerVC = ObservableSender() - let presentDocumentViewerVC = ObservableSender<(URL, ChatFile)>() + let presentDocumentViewerVC = ObservableSender<([FileResult], Int)>() let presentDropView = ObservableSender() @ObservableValue private(set) var isHeaderLoading = false @@ -664,15 +664,39 @@ final class ChatViewModel: NSObject { func openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - + let message = messages.first(where: { $0.messageId == messageId }) + guard tx?.statusEnum == .delivered, - !downloadingFilesID.contains(file.file.file_id) + !downloadingFilesID.contains(file.file.file_id), + case let(.file(fileModel)) = message?.content, + let index = fileModel.value.content.fileModel.files.firstIndex(of: file) else { return } guard !file.isCached else { do { - let url = try filesStorage.getFileURL(with: file.file.file_id) - presentDocumentViewerVC.send((url, file)) + _ = try filesStorage.getFileURL(with: file.file.file_id) + + let chatFiles = fileModel.value.content.fileModel.files + + let files: [FileResult] = chatFiles.compactMap { file in + guard file.isCached, + let url = try? filesStorage.getFileURL(with: file.file.file_id) else { + return nil + } + + return FileResult.init( + url: url, + type: file.fileType, + preview: nil, + previewUrl: nil, + size: file.file.file_size, + name: file.file.file_name, + extenstion: file.file.file_type, + resolution: nil + ) + } + + presentDocumentViewerVC.send((files, index)) } catch { dialog.send(.alert(error.localizedDescription)) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index 9c7efa9db..a8ea3b432 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -13,45 +13,54 @@ import WebKit import QuickLook public final class DocumentInteractionService: NSObject { - private var url: URL! + private var urls: [URL] = [] private var needToDelete = false - public func openFile(url: URL, name: String, size: Int64, ext: String) { - let fullName = name.contains(ext) - ? name - : "\(name).\(ext)" - - var copyURL = URL(fileURLWithPath: url.deletingLastPathComponent().path) - copyURL.appendPathComponent(fullName) - - if FileManager.default.fileExists(atPath: copyURL.path) { - try? FileManager.default.removeItem(at: copyURL) + 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) + } + + try? FileManager.default.copyItem(at: file.url, to: copyURL) + + self.urls.append(copyURL) } - try? FileManager.default.copyItem(at: url, to: copyURL) - - self.url = copyURL needToDelete = true } public func openFile(url: URL) { - self.url = url + self.urls = [url] needToDelete = false } } extension DocumentInteractionService: QLPreviewControllerDelegate, QLPreviewControllerDataSource { public func numberOfPreviewItems(in controller: QLPreviewController) -> Int { - 1 + urls.count } public func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - QLPreviewItemEq(url: url) + QLPreviewItemEq(url: urls[index]) } public func previewControllerDidDismiss(_ controller: QLPreviewController) { guard needToDelete else { return } - try? FileManager.default.removeItem(at: url) + urls.forEach { url in + try? FileManager.default.removeItem(at: url) + } } } From ad385285dc0ceb7b12d0b365a7fec90b90ded098 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 4 Apr 2024 14:37:31 +0300 Subject: [PATCH 051/123] [trello.com/c/uxBZaznD] fix: input bar content size --- .../Chat/View/ChatViewController.swift | 10 ++----- .../Chat/View/Subviews/ChatInputBar.swift | 28 +++++++++++++++++++ .../ChatMediaContainerView+Model.swift | 2 +- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index a85366d2d..ea71f3ee7 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -847,10 +847,7 @@ private extension ChatViewController { func closeReplyView() { replyView.removeFromSuperview() - - // TODO: Fix it later - // There's an issue: if the text in inputTextView is changed while replyView is positioned on the topStackView of the messageInputBar, removing it causes an incorrect height for the messageInputBar. Reinstalling the text will help recalculate the height. - messageInputBar.inputTextView.text = messageInputBar.inputTextView.text + messageInputBar.invalidateIntrinsicContentSize() } func processFileToolbarView(_ data: [FileResult]?) { @@ -881,10 +878,7 @@ private extension ChatViewController { func closeFileToolbarView() { filesToolbarView.removeFromSuperview() - - // TODO: Fix it later - // There's an issue: if the text in inputTextView is changed while filesToolbarView is positioned on the topStackView of the messageInputBar, removing it causes an incorrect height for the messageInputBar. Reinstalling the text will help recalculate the height. - messageInputBar.inputTextView.text = messageInputBar.inputTextView.text + messageInputBar.invalidateIntrinsicContentSize() } func didTapTransfer(id: String) { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift index 3e560dd7a..19b8133bf 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift @@ -57,6 +57,34 @@ final class ChatInputBar: InputBarAccessoryView { super.didMoveToWindow() 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 + } } private extension ChatInputBar { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index 31206ef6e..0bc0f20ca 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -27,7 +27,7 @@ extension ChatMediaContainerView { ) func makeReplyContent() -> NSAttributedString { - return ChatMessageFactory.markdownParser.parse("File") + ChatMessageFactory.markdownParser.parse("[\(content.fileModel.files.count) File(s)]") } } } From fd37a4094b9b9f3b2cc54d4c1f07ca901657de88 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 4 Apr 2024 15:48:11 +0300 Subject: [PATCH 052/123] [trello.com/c/uxBZaznD] fix: show file message as failed if needed --- .../Chat/ViewModel/ChatFileService.swift | 137 ++++++++++-------- .../Chat/ViewModel/ChatViewModel.swift | 6 + .../DataProviders/ChatsProvider.swift | 7 +- .../DataProviders/AdamantChatsProvider.swift | 13 +- 4 files changed, 96 insertions(+), 67 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index b2cd793eb..61f77bc28 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -140,79 +140,92 @@ final class ChatFileService: ChatFileProtocol { uploadingFilesIDsArray.append(file.file_id) } - for file in files { - let result = try await filesStorage.uploadFile( - file, - recipientPublicKey: chatroom?.partner?.publicKey ?? "", - senderPrivateKey: keyPair.privateKey, - ownerId: ownerId, - recipientId: partnerAddress - ) - - let oldId = file.url.absoluteString - uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) - - let previewID: String - if let id = result.idPreview { - previewID = id - } else { - previewID = result.id + do { + for file in files { + let result = try await filesStorage.uploadFile( + file, + recipientPublicKey: chatroom?.partner?.publicKey ?? "", + senderPrivateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: partnerAddress + ) + + let oldId = file.url.absoluteString + uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) + + let previewID: String + if let id = result.idPreview { + previewID = id + } else { + previewID = result.id + } + + let preview = filesStorage.getPreview( + for: previewID, + type: file.extenstion ?? "" + ) + + let cached = filesStorage.isCached(result.id) + + updateFileFields.send(( + id: oldId, + newId: result.id, + preview: preview, + cached: cached + )) + + if let index = richFiles.firstIndex( + where: { $0.file_id == oldId } + ) { + richFiles[index].file_id = result.id + richFiles[index].nonce = result.nonce + richFiles[index].preview_id = result.idPreview + richFiles[index].preview_nonce = result.noncePreview + } } - let preview = filesStorage.getPreview( - for: previewID, - type: file.extenstion ?? "" - ) - - let cached = filesStorage.isCached(result.id) - - updateFileFields.send(( - id: oldId, - newId: result.id, - preview: preview, - cached: cached - )) + let message: AdamantMessage - if let index = richFiles.firstIndex( - where: { $0.file_id == oldId } - ) { - richFiles[index].file_id = result.id - richFiles[index].nonce = result.nonce - richFiles[index].preview_id = result.idPreview - richFiles[index].preview_nonce = result.noncePreview - } - } - - let message: AdamantMessage - - if let replyMessage = replyMessage { - message = .richMessage( - payload: RichFileReply( - replyto_id: replyMessage.id, - reply_message: RichMessageFile( + if let replyMessage = replyMessage { + message = .richMessage( + payload: RichFileReply( + replyto_id: replyMessage.id, + reply_message: RichMessageFile( + files: richFiles, + storage: NetworkFileProtocolType.uploadCareApi.rawValue, + comment: text + ) + ) + ) + } else { + message = .richMessage( + payload: RichMessageFile( files: richFiles, storage: NetworkFileProtocolType.uploadCareApi.rawValue, comment: text ) ) + } + + _ = try await chatsProvider.sendFileMessage( + message, + recipientId: partnerAddress, + transactionLocaly: txLocally.tx, + context: txLocally.context, + from: chatroom ) - } else { - message = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, - comment: text - ) + } catch { + richFiles.forEach { file in + uploadingFilesIDsArray.removeAll(where: { $0 == file.file_id }) + } + + try? await chatsProvider.setTxMessageAsFailed( + transactionLocaly: txLocally.tx, + context: txLocally.context ) + + throw error } - - _ = try await chatsProvider.sendFileMessage( - message, - recipientId: partnerAddress, - transactionLocaly: txLocally.0, - context: txLocally.1, - from: chatroom - ) } func downloadFile( diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index a06b886b2..a61e4fda4 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -454,6 +454,12 @@ 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 (.file) = message?.content { + return + } + do { try await chatsProvider.retrySendMessage(transaction) } catch { diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index e8675c984..f0acab85c 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -221,7 +221,7 @@ protocol ChatsProvider: DataProvider, Actor { _ message: AdamantMessage, recipientId: String, from chatroom: Chatroom? - ) async throws -> (RichMessageTransaction, NSManagedObjectContext) + ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) func sendFileMessage( _ message: AdamantMessage, @@ -231,6 +231,11 @@ protocol ChatsProvider: DataProvider, Actor { from chatroom: Chatroom? ) async throws -> ChatTransaction + func setTxMessageAsFailed( + transactionLocaly: RichMessageTransaction, + context: NSManagedObjectContext + ) throws + // MARK: - Delete local message func cancelMessage(_ message: ChatTransaction) async throws func isMessageDeleted(id: String) -> Bool diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 58790cbea..db0acaa99 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -869,7 +869,7 @@ extension AdamantChatsProvider { _ message: AdamantMessage, recipientId: String, from chatroom: Chatroom? - ) async throws -> (RichMessageTransaction, NSManagedObjectContext) { + ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) { guard let loggedAccount = accountService.account, let keypair = accountService.keypair else { throw ChatsProviderError.notLogged } @@ -933,9 +933,6 @@ extension AdamantChatsProvider { 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) } @@ -956,6 +953,14 @@ extension AdamantChatsProvider { return transaction } + func setTxMessageAsFailed( + transactionLocaly: RichMessageTransaction, + context: NSManagedObjectContext + ) throws { + transactionLocaly.statusEnum = .failed + try context.save() + } + private func sendTextMessageLocaly( text: String, isMarkdown: Bool, From 4418344ec22171e005f20bf9cdd4931705238fb4 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 5 Apr 2024 13:22:39 +0300 Subject: [PATCH 053/123] [trello.com/c/uxBZaznD] feat: ignore file if cant download --- .../Chat/ViewModel/ChatFileService.swift | 74 +++++++++++++------ .../FilesStorageKit/FilesStorageKit.swift | 22 +++--- 2 files changed, 60 insertions(+), 36 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 61f77bc28..32d952f4a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -56,6 +56,9 @@ final class ChatFileService: ChatFileProtocol { @Published private var downloadingFilesIDsArray: [String] = [] @Published private var uploadingFilesIDsArray: [String] = [] + private var ignoreFilesIDsArray: [String] = [] + private var subscriptions = Set() + var downloadingFilesIDs: Published<[String]>.Publisher { $downloadingFilesIDsArray } @@ -74,6 +77,8 @@ final class ChatFileService: ChatFileProtocol { self.accountService = accountService self.filesStorage = filesStorage self.chatsProvider = chatsProvider + + addObservers() } func sendFile( @@ -286,6 +291,7 @@ final class ChatFileService: ChatFileProtocol { ) { guard let keyPair = accountService.keypair, !downloadingFilesIDsArray.contains(file.file.file_id), + !ignoreFilesIDsArray.contains(file.file.file_id), let previewId = file.file.preview_id, let previewNonce = file.file.preview_nonce, !filesStorage.isCached(previewId), @@ -300,30 +306,50 @@ final class ChatFileService: ChatFileProtocol { downloadingFilesIDsArray.removeAll(where: { $0 == file.file.file_id }) } - try? await filesStorage.cachePreview( - storage: file.storage, - fileType: file.file.file_type ?? .empty, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - ownerId: ownerId, - recipientId: recipientId, - previewId: previewId, - previewNonce: previewNonce - ) - - let preview = filesStorage.getPreview( - for: previewId, - type: file.file.file_type ?? .empty - ) - - let cached = filesStorage.isCached(file.file.file_id) - - updateFileFields.send(( - id: file.file.file_id, - newId: nil, - preview: preview, - cached: cached - )) + do { + try await filesStorage.cachePreview( + storage: file.storage, + fileType: file.file.file_type ?? .empty, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId, + previewId: previewId, + previewNonce: previewNonce + ) + + let preview = filesStorage.getPreview( + for: previewId, + type: file.file.file_type ?? .empty + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields.send(( + id: file.file.file_id, + newId: nil, + preview: preview, + cached: cached + )) + } catch { + ignoreFilesIDsArray.append(file.file.file_id) + } } } } + +private extension ChatFileService { + func addObservers() { + NotificationCenter.default + .publisher(for: .AdamantReachabilityMonitor.reachabilityChanged) + .receive(on: RunLoop.main) + .sink { [weak self] data in + let connection = data.userInfo?[AdamantUserInfoKey.ReachabilityMonitor.connection] as? Bool + + if connection == true { + self?.ignoreFilesIDsArray.removeAll() + } + } + .store(in: &subscriptions) + } +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 4154386ed..cc4b65624 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -30,18 +30,16 @@ public final class FilesStorageKit { previewId: String, previewNonce: String ) async throws { - await taskQueue.enqueue { - try? await self.downloadFile( - id: previewId, - storage: storage, - fileType: fileType, - senderPublicKey: senderPublicKey, - recipientPrivateKey: recipientPrivateKey, - nonce: previewNonce, - ownerId: ownerId, - recipientId: recipientId - ) - } + try await downloadFile( + id: previewId, + storage: storage, + fileType: fileType, + senderPublicKey: senderPublicKey, + recipientPrivateKey: recipientPrivateKey, + nonce: previewNonce, + ownerId: ownerId, + recipientId: recipientId + ) } public func getPreview(for id: String, type: String) -> UIImage? { From 0f5a661f42020b93ad38ea5939b51f6bf689638d Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 9 Apr 2024 12:23:05 +0300 Subject: [PATCH 054/123] [trello.com/c/uxBZaznD] fix: pick image from library --- .../Helpers/FilesPickerKitHelper.swift | 4 +- .../Pickers/MediaPickerService.swift | 43 ++++++------------- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index fd0cda76e..a0a55394c 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -158,7 +158,7 @@ final class FilesPickerKitHelper { @MainActor func getUrl(for itemProvider: NSItemProvider) async throws -> URL { - guard let type = itemProvider.registeredTypeIdentifiers.first + guard let type = itemProvider.registeredTypeIdentifiers.last else { throw FileValidationError.fileNotFound } @@ -171,7 +171,7 @@ final class FilesPickerKitHelper { } guard let url = url else { - continuation.resume(throwing: FileValidationError.tooManyFiles) + continuation.resume(throwing: FileValidationError.fileNotFound) return } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 3c0ee72c8..9ab38f41e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -37,6 +37,7 @@ extension MediaPickerService: PHPickerViewControllerDelegate { private extension MediaPickerService { func processResults(_ results: [PHPickerResult]) async { + do { var dataArray: [FileResult] = [] for result in results { @@ -44,13 +45,14 @@ private extension MediaPickerService { guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, let utType = UTType(typeIdentifier) - else { continue } + else { + throw FileValidationError.fileNotFound + } if utType.conforms(to: .image) { - guard let url = try? await helper.getUrl(for: itemProvider), - let preview = try? await getPhoto(from: itemProvider), - let fileSize = try? helper.getFileSize(from: url) - else { continue } + let url = try await helper.getUrl(for: itemProvider) + let preview = try getPhoto(from: url) + let fileSize = try helper.getFileSize(from: url) let resizedPreview = helper.resizeImage( image: preview, @@ -77,10 +79,8 @@ private extension MediaPickerService { } if utType.conforms(to: .movie) { - guard let url = try? await helper.getUrl(for: itemProvider), - let fileSize = try? helper.getFileSize(from: url) - else { continue } - + let url = try await helper.getUrl(for: itemProvider) + let fileSize = try helper.getFileSize(from: url) let originalSize = helper.getOriginalSize(for: url) let thumbnailImage = try? await helper.getThumbnailImage( @@ -108,7 +108,6 @@ private extension MediaPickerService { } } - do { try helper.validateFiles(dataArray) onPreparedDataCallback?(.success(dataArray)) } catch { @@ -116,27 +115,11 @@ private extension MediaPickerService { } } - func getPhoto(from itemProvider: NSItemProvider) async throws -> UIImage { - let objectType: NSItemProviderReading.Type = UIImage.self - - guard itemProvider.canLoadObject(ofClass: objectType) else { - throw FileValidationError.tooManyFiles + func getPhoto(from url: URL) throws -> UIImage { + guard let image = UIImage(contentsOfFile: url.path) else { + throw FileValidationError.fileNotFound } - return try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadObject(ofClass: objectType) { object, error in - if let error = error { - continuation.resume(throwing: error) - return - } - - guard let image = object as? UIImage else { - continuation.resume(throwing: FileValidationError.tooManyFiles) - return - } - - continuation.resume(returning: image) - } - } + return image } } From ddc528c840b466885044de76c9618b8c9298f1e3 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 10 Apr 2024 15:01:59 +0300 Subject: [PATCH 055/123] [trello.com/c/uxBZaznD] feat: add IPFS support --- Adamant.xcodeproj/project.pbxproj | 55 +++++- Adamant.xcworkspace/contents.xcworkspacedata | 3 - .../xcshareddata/swiftpm/Package.resolved | 9 - Adamant/App/DI/AppAssembly.swift | 29 ++- Adamant/Helpers/NodeGroup+Constants.swift | 10 + Adamant/Models/NodeWithGroup.swift | 4 +- .../Chat/ViewModel/ChatFileService.swift | 172 ++++++++++++++---- .../Chat/ViewModel/ChatViewModel.swift | 1 - .../CoinsNodesListFactory.swift | 3 +- .../CoinsNodesListViewModel+ApiServices.swift | 3 + .../ServiceProtocols/APICoreProtocol.swift | 18 ++ .../AdamantCore/AdamantCore.swift | 13 ++ .../FileApiServiceProtocol.swift | 14 ++ .../FilesNetworkManagerProtocol.swift | 14 ++ .../FilesStorageProtocol.swift | 34 +--- Adamant/Services/APICore.swift | 38 ++++ .../FilesNetworkManager.swift | 38 ++++ .../FilesNetworkManager/IPFS+Constants.swift | 42 +++++ .../FilesNetworkManager/IPFSApiCore.swift | 47 +++++ .../FilesNetworkManager/IPFSApiService.swift | 62 +++++++ .../Models/FileManagerError.swift | 13 +- .../FilesNetworkManager/Models/IPFSDTO.swift | 13 ++ Adamant/Services/NodesStorage.swift | 2 + .../Models/NodeGroup+Constants.swift | 2 +- .../Sources/CommonKit/Models/NodeGroup.swift | 1 + FilesNetworkManagerKit/.gitignore | 8 - FilesNetworkManagerKit/Package.swift | 35 ---- .../FilesNetworkManagerKit.swift | 28 --- .../Managers/UploadCareApiManager.swift | 30 --- .../Models/NetworkFileProtocolType.swift | 12 -- .../Protocols/ApiManagerProtocol.swift | 13 -- .../FilesNetworkManagerKitTests.swift | 12 -- FilesStorageKit/Package.swift | 5 +- .../FilesStorageKit/FilesStorageKit.swift | 150 ++------------- .../FilesStorageKit/NetworkService.swift | 133 -------------- 35 files changed, 568 insertions(+), 498 deletions(-) create mode 100644 Adamant/ServiceProtocols/FileApiServiceProtocol.swift create mode 100644 Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift create mode 100644 Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift create mode 100644 Adamant/Services/FilesNetworkManager/IPFS+Constants.swift create mode 100644 Adamant/Services/FilesNetworkManager/IPFSApiCore.swift create mode 100644 Adamant/Services/FilesNetworkManager/IPFSApiService.swift rename {FilesNetworkManagerKit/Sources/FilesNetworkManagerKit => Adamant/Services/FilesNetworkManager}/Models/FileManagerError.swift (64%) create mode 100644 Adamant/Services/FilesNetworkManager/Models/IPFSDTO.swift delete mode 100644 FilesNetworkManagerKit/.gitignore delete mode 100644 FilesNetworkManagerKit/Package.swift delete mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift delete mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift delete mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift delete mode 100644 FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift delete mode 100644 FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index e2c62b4ae..cbae3cf32 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ 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 */; }; - 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */; }; 3A833C3E2B99CCD600238F6A /* FilesStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */; }; 3A833C402B99CDA000238F6A /* FilesStorageKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A833C3F2B99CDA000238F6A /* FilesStorageKit */; }; 3A8875EF27BBF38D00436195 /* Parchment in Frameworks */ = {isa = PBXBuildFile; productRef = 3A8875EE27BBF38D00436195 /* Parchment */; }; @@ -63,6 +62,14 @@ 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 */; }; @@ -728,6 +735,14 @@ 3AA6DF452BA9BEB700EA2E16 /* MediaContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContentView.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 = ""; }; @@ -1279,7 +1294,6 @@ 416F5EA4290162EB00EF0400 /* SocketIO in Frameworks */, A5F92994262C855B00C3E60A /* MarkdownKit in Frameworks */, 3A833C402B99CDA000238F6A /* FilesStorageKit in Frameworks */, - 3A833C3A2B98B7EE00238F6A /* FilesNetworkManagerKit in Frameworks */, A50AEB04262C815200B37C22 /* EFQRCode in Frameworks */, A544F0D4262C9878001F1A6D /* Eureka in Frameworks */, 3AC76E3D2AB09118008042C4 /* ElegantEmojiPicker in Frameworks */, @@ -1470,6 +1484,27 @@ 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 = ( @@ -2089,6 +2124,8 @@ 265AA1612B74E6B900CF98B0 /* ChatPreservation.swift */, 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */, 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */, + 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */, + 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */, ); path = ServiceProtocols; sourceTree = ""; @@ -2096,6 +2133,7 @@ E913C9061FFFA92E001A83F7 /* Services */ = { isa = PBXGroup; children = ( + 3AE0A4262BC6A64900BF7125 /* FilesNetworkManager */, 3A41938D2A580C3B006A6B22 /* RichTransactionReactService */, 41C1698A29E7F2EE00FEB3CB /* RichTransactionReplyService */, 935F53D429BE8F4800779492 /* RichTransactionStatusService */, @@ -2733,7 +2771,6 @@ 9342F6C12A6A35E300A9B39F /* CommonKit */, 3AC76E3C2AB09118008042C4 /* ElegantEmojiPicker */, 3A075C9D2B98A3B100714E3B /* FilesPickerKit */, - 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */, 3A833C3F2B99CDA000238F6A /* FilesStorageKit */, ); productName = Adamant; @@ -3217,6 +3254,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 */, @@ -3315,6 +3353,7 @@ 6403F5DE22723C6800D58779 /* DashMainnet.swift in Sources */, 649D6BEA21B9627B009E727B /* LskWalletService+RichMessageProviderWithStatusCheck.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 */, @@ -3347,6 +3386,7 @@ 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 */, 6416B1A721B024B6006089AC /* LskWalletService+Send.swift in Sources */, @@ -3363,6 +3403,7 @@ 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 */, @@ -3392,6 +3433,7 @@ 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 */, @@ -3405,6 +3447,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 */, @@ -3456,6 +3499,7 @@ 648C697122915CB8006645F5 /* BTCRPCServerResponce.swift in Sources */, E9A174B32057EC47003667CD /* BackgroundFetchService.swift in Sources */, 649D6BE821B95DB7009E727B /* LskWalletService+RichMessageProvider.swift in Sources */, + 3AE0A4332BC6A9EB00BF7125 /* FileApiServiceProtocol.swift in Sources */, E9E7CDBE2003AEFB00DFC4DB /* CellFactory.swift in Sources */, 3A299C782B84D11100B54C61 /* FilesToolbarCollectionViewCell.swift in Sources */, 9366589D2B0ADBAF00BDB2D3 /* CoinsNodesListView.swift in Sources */, @@ -3543,6 +3587,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 */, E9484B79227C617E008E10F0 /* BalanceTableViewCell.swift in Sources */, @@ -4333,10 +4378,6 @@ isa = XCSwiftPackageProductDependency; productName = FilesPickerKit; }; - 3A833C392B98B7EE00238F6A /* FilesNetworkManagerKit */ = { - isa = XCSwiftPackageProductDependency; - productName = FilesNetworkManagerKit; - }; 3A833C3F2B99CDA000238F6A /* FilesStorageKit */ = { isa = XCSwiftPackageProductDependency; productName = FilesStorageKit; diff --git a/Adamant.xcworkspace/contents.xcworkspacedata b/Adamant.xcworkspace/contents.xcworkspacedata index ab41fcc38..563a0441b 100644 --- a/Adamant.xcworkspace/contents.xcworkspacedata +++ b/Adamant.xcworkspace/contents.xcworkspacedata @@ -4,9 +4,6 @@ - - diff --git a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6252fdfc6..88a2fda6f 100644 --- a/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Adamant.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -325,15 +325,6 @@ "version": "2.7.1" } }, - { - "package": "Uploadcare", - "repositoryURL": "https://github.com/uploadcare/uploadcare-swift.git", - "state": { - "branch": "master", - "revision": "3ab0706a726abcb9c935347add80f855c2a08f12", - "version": null - } - }, { "package": "Web3swift", "repositoryURL": "https://github.com/skywinder/web3swift.git", diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index 1ca116c3c..d31e76aee 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -124,6 +124,24 @@ 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( @@ -270,10 +288,13 @@ struct AppAssembly: Assembly { // MARK: ChatFileService container.register(ChatFileProtocol.self) { r in - ChatFileService( - accountService: r.resolve(AccountService.self)!, - filesStorage: r.resolve(FilesStorageProtocol.self)!, - chatsProvider: r.resolve(ChatsProvider.self)!) + 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 diff --git a/Adamant/Helpers/NodeGroup+Constants.swift b/Adamant/Helpers/NodeGroup+Constants.swift index 98ccdc67e..e683ccc3a 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/Models/NodeWithGroup.swift b/Adamant/Models/NodeWithGroup.swift index 13f3be003..2c5979931 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, .lskNode, .lskService, .doge, .adm: return true - case .eth, .dash: + case .eth, .dash, .ipfs: return false } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 32d952f4a..9ba9b62ef 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -8,7 +8,6 @@ import Foundation import CommonKit -import FilesNetworkManagerKit import UIKit import Combine @@ -47,11 +46,15 @@ protocol ChatFileProtocol { } final class ChatFileService: ChatFileProtocol { + typealias UploadResult = (data: 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 @Published private var downloadingFilesIDsArray: [String] = [] @Published private var uploadingFilesIDsArray: [String] = [] @@ -72,11 +75,15 @@ final class ChatFileService: ChatFileProtocol { init( accountService: AccountService, filesStorage: FilesStorageProtocol, - chatsProvider: ChatsProvider + chatsProvider: ChatsProvider, + filesNetworkManager: FilesNetworkManagerProtocol, + adamantCore: AdamantCore ) { self.accountService = accountService self.filesStorage = filesStorage self.chatsProvider = chatsProvider + self.filesNetworkManager = filesNetworkManager + self.adamantCore = adamantCore addObservers() } @@ -90,12 +97,11 @@ final class ChatFileService: ChatFileProtocol { guard let partnerAddress = chatroom?.partner?.address, let files = filesPicked, let keyPair = accountService.keypair, - let ownerId = accountService.account?.address + let ownerId = accountService.account?.address, + chatroom?.partner?.isDummy != true else { return } - guard chatroom?.partner?.isDummy != true else { - return - } + let storageProtocol = NetworkFileProtocolType.ipfs let replyMessage = replyMessage @@ -120,7 +126,7 @@ final class ChatFileService: ChatFileProtocol { replyto_id: replyMessage.id, reply_message: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, + storage: storageProtocol.rawValue, comment: text ) ) @@ -129,7 +135,7 @@ final class ChatFileService: ChatFileProtocol { messageLocally = .richMessage( payload: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, + storage: storageProtocol.rawValue, comment: text ) ) @@ -147,22 +153,38 @@ final class ChatFileService: ChatFileProtocol { do { for file in files { - let result = try await filesStorage.uploadFile( - file, - recipientPublicKey: chatroom?.partner?.publicKey ?? "", + let result = try await uploadFileToServer( + file: file, + recipientPublicKey: chatroom?.partner?.publicKey ?? .empty, senderPrivateKey: keyPair.privateKey, + storageProtocol: storageProtocol + ) + + try filesStorage.cacheFile( + id: result.file.cid, + url: file.url, ownerId: ownerId, recipientId: partnerAddress ) + if let previewUrl = file.previewUrl, + let previewId = result.preview?.cid { + try filesStorage.cacheFile( + id: previewId, + url: previewUrl, + ownerId: ownerId, + recipientId: partnerAddress + ) + } + let oldId = file.url.absoluteString uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) let previewID: String - if let id = result.idPreview { + if let id = result.preview?.cid { previewID = id } else { - previewID = result.id + previewID = result.file.cid } let preview = filesStorage.getPreview( @@ -170,11 +192,11 @@ final class ChatFileService: ChatFileProtocol { type: file.extenstion ?? "" ) - let cached = filesStorage.isCached(result.id) + let cached = filesStorage.isCached(result.file.cid) updateFileFields.send(( id: oldId, - newId: result.id, + newId: result.file.cid, preview: preview, cached: cached )) @@ -182,10 +204,10 @@ final class ChatFileService: ChatFileProtocol { if let index = richFiles.firstIndex( where: { $0.file_id == oldId } ) { - richFiles[index].file_id = result.id - richFiles[index].nonce = result.nonce - richFiles[index].preview_id = result.idPreview - richFiles[index].preview_nonce = result.noncePreview + richFiles[index].file_id = result.file.cid + richFiles[index].nonce = result.file.nonce + richFiles[index].preview_id = result.preview?.cid + richFiles[index].preview_nonce = result.preview?.nonce } } @@ -197,7 +219,7 @@ final class ChatFileService: ChatFileProtocol { replyto_id: replyMessage.id, reply_message: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, + storage: NetworkFileProtocolType.ipfs.rawValue, comment: text ) ) @@ -206,7 +228,7 @@ final class ChatFileService: ChatFileProtocol { message = .richMessage( payload: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.uploadCareApi.rawValue, + storage: NetworkFileProtocolType.ipfs.rawValue, comment: text ) ) @@ -248,17 +270,19 @@ final class ChatFileService: ChatFileProtocol { } downloadingFilesIDsArray.append(file.file.file_id) - try await filesStorage.downloadFile( + let data = try await downloadFile( id: file.file.file_id, storage: file.storage, - fileType: file.file.file_type ?? .empty, senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, + nonce: file.nonce + ) + + try filesStorage.cacheFile( + id: file.file.file_id, + data: data, ownerId: ownerId, - recipientId: recipientId, - nonce: file.nonce, - previewId: nil, - previewNonce: nil + recipientId: recipientId ) let previewID: String @@ -307,15 +331,19 @@ final class ChatFileService: ChatFileProtocol { } do { - try await filesStorage.cachePreview( + let data = try await downloadFile( + id: previewId, storage: file.storage, - fileType: file.file.file_type ?? .empty, senderPublicKey: chatroom?.partner?.publicKey ?? .empty, recipientPrivateKey: keyPair.privateKey, + nonce: previewNonce + ) + + try filesStorage.cacheFile( + id: previewId, + data: data, ownerId: ownerId, - recipientId: recipientId, - previewId: previewId, - previewNonce: previewNonce + recipientId: recipientId ) let preview = filesStorage.getPreview( @@ -353,3 +381,83 @@ private extension ChatFileService { .store(in: &subscriptions) } } + +private extension ChatFileService { + func uploadFileToServer( + file: FileResult, + recipientPublicKey: String, + senderPrivateKey: String, + storageProtocol: NetworkFileProtocolType + ) async throws -> (file: UploadResult, preview: UploadResult?) { + let result = try await uploadFile( + url: file.url, + recipientPublicKey: recipientPublicKey, + senderPrivateKey: senderPrivateKey, + storageProtocol: storageProtocol + ) + + var preview: UploadResult? + + if let url = file.previewUrl { + preview = try? await uploadFile( + url: url, + recipientPublicKey: recipientPublicKey, + senderPrivateKey: senderPrivateKey, + storageProtocol: storageProtocol + ) + } + + return (result, preview) + } + + func uploadFile( + url: URL, + recipientPublicKey: String, + senderPrivateKey: String, + storageProtocol: NetworkFileProtocolType + ) async throws -> (data: Data, nonce: String, cid: String) { + 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.cantEnctryptFile + } + + let cid = try await filesNetworkManager.uploadFiles(encodedData, type: storageProtocol) + return (encodedData, nonce, cid) + } + + func downloadFile( + id: String, + storage: String, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String + ) async throws -> Data { + let encodedData = try await filesNetworkManager.downloadFile(id, type: storage) + + guard let decodedData = adamantCore.decodeData( + encodedData, + rawNonce: nonce, + senderPublicKey: senderPublicKey, + privateKey: recipientPrivateKey + ) + else { + throw FileManagerError.cantDownloadFile + } + + return decodedData + } +} diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index a61e4fda4..9400e1cc6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -14,7 +14,6 @@ import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker import FilesPickerKit -import FilesNetworkManagerKit @MainActor final class ChatViewModel: NSObject { diff --git a/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift b/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift index 2e230fc37..68bb72f78 100644 --- a/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift +++ b/Adamant/Modules/CoinsNodesList/CoinsNodesListFactory.swift @@ -54,7 +54,8 @@ private struct CoinsNodesListAssembly: Assembly { lskService: $0.resolve(LskServiceApiService.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 53bc5f309..bb22c0bcc 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/ServiceProtocols/APICoreProtocol.swift b/Adamant/ServiceProtocols/APICoreProtocol.swift index 4c1ea329a..f4a235bac 100644 --- a/Adamant/ServiceProtocols/APICoreProtocol.swift +++ b/Adamant/ServiceProtocols/APICoreProtocol.swift @@ -14,6 +14,12 @@ import UIKit enum ApiCommands {} protocol APICoreProtocol: Actor { + func sendRequestMultipartFormData( + node: Node, + path: String, + data: [String: Data] + ) async -> APIResponseModel + func sendRequestBasic( node: Node, path: String, @@ -106,6 +112,18 @@ extension APICoreProtocol { ).result.flatMap { parseJSON(data: $0) } } + func sendRequestMultipartFormDataJsonResponse( + node: Node, + path: String, + data: [String: Data] + ) async -> ApiServiceResult { + await sendRequestMultipartFormData( + node: node, + path: path, + data: data + ).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/FileApiServiceProtocol.swift b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift new file mode 100644 index 000000000..10f5d12ac --- /dev/null +++ b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift @@ -0,0 +1,14 @@ +// +// 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) async throws -> String + func downloadFile(id: String) async throws -> Data +} diff --git a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift new file mode 100644 index 000000000..1102a55d8 --- /dev/null +++ b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift @@ -0,0 +1,14 @@ +// +// 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) async throws -> String + func downloadFile(_ id: String, type: String) async throws -> Data +} diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index d19b5c04d..02a65aadc 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -18,43 +18,25 @@ protocol FilesStorageProtocol { func getFileURL(with id: String) throws -> URL - func uploadFile( - _ file: FileResult, - recipientPublicKey: String, - senderPrivateKey: String, + func cacheFile( + id: String, + url: URL, ownerId: String, recipientId: String - ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) + ) throws - func downloadFile( + func cacheFile( id: String, - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, + data: Data, ownerId: String, - recipientId: String, - nonce: String, - previewId: String?, - previewNonce: String? - ) async throws + recipientId: String + ) throws func getCacheSize() throws -> Int64 func clearCache() throws func clearTempCache() throws - - func cachePreview( - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, - ownerId: String, - recipientId: String, - previewId: String, - previewNonce: String - ) async throws } extension FilesStorageKit: FilesStorageProtocol { } diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index f3748aa34..f3b9c759c 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -25,6 +25,32 @@ actor APICore: APICoreProtocol { return Alamofire.Session.init(configuration: configuration) }() + func sendRequestMultipartFormData( + node: Node, + path: String, + data: [String: Data] + ) async -> APIResponseModel { + do { + let request = AF.upload(multipartFormData: { multipartFormData in + data.forEach { file in + multipartFormData.append( + file.value, + withName: file.key, + fileName: "file" + ) + } + }, to: try buildUrl(node: node, path: path)) + + 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, @@ -93,6 +119,18 @@ private extension APICore { } } + func sendRequest(request: UploadRequest) async -> APIResponseModel { + await withCheckedContinuation { continuation in + request.responseData(queue: responseQueue) { response in + continuation.resume(returning: .init( + result: response.result.mapError { .init(error: $0) }, + data: response.data, + code: response.response?.statusCode + )) + } + } + } + func buildUrl(node: Node, path: String) throws -> URL { guard let url = node.asURL()?.appendingPathComponent(path, conformingTo: .url) else { throw InternalAPIError.endpointBuildFailed } diff --git a/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift new file mode 100644 index 000000000..97ac76dd0 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift @@ -0,0 +1,38 @@ +// +// 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 + ) async throws -> String { + switch type { + case .ipfs: + return try await ipfsService.uploadFile(data: data) + } + } + + func downloadFile(_ id: String, type: String) async throws -> Data { + guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { + throw FileManagerError.cantDownloadFile + } + + switch netwrokProtocol { + case .ipfs: + return try await ipfsService.downloadFile(id: id) + } + } +} diff --git a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift new file mode 100644 index 000000000..afdd34a54 --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift @@ -0,0 +1,42 @@ +// +// 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: "http://194.163.154.252:4000")!), + Node(url: URL(string: "http://154.26.159.245:4000")!), + Node(url: URL(string: "http://109.123.240.102:4000")!) + ] + } + + 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..2c5dd9d71 --- /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 + +final class IPFSApiCore { + let apiCore: APICoreProtocol + + init(apiCore: APICoreProtocol) { + self.apiCore = apiCore + } + + func getNodeStatus(node: Node) async -> ApiServiceResult { + await Task.sleep(interval: Double.random(in: 0.1...1)) + return .success(.init( + success: true, + nodeTimestamp: Date().timeIntervalSince1970, + network: nil, + version: nil, + wsClient: nil + )) + } +} + +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..841f99def --- /dev/null +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -0,0 +1,62 @@ +// +// 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: "file/upload", + download: "file/", + field: "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) async throws -> String { + let result: IpfsDTO = try await request { core, node in + await core.sendRequestMultipartFormDataJsonResponse( + node: node, + path: IPFSApiCommands.file.upload, + data: [IPFSApiCommands.file.field: data]) + }.get() + + guard let cid = result.cids.first else { + throw FileManagerError.cantUploadFile + } + + return cid + } + + func downloadFile(id: String) async throws -> Data { + let result: Data = try await request { core, node in + await core.sendRequest( + node: node, + path: "\(IPFSApiCommands.file.download)\(id)" + ) + }.get() + + return result + } +} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift similarity index 64% rename from FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift rename to Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift index a1407a495..d86df18ba 100644 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/FileManagerError.swift +++ b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift @@ -1,20 +1,25 @@ // // FileManagerError.swift -// +// Adamant // -// Created by Stanislav Jelezoglo on 06.03.2024. +// Created by Stanislav Jelezoglo on 10.04.2024. +// Copyright © 2024 Adamant. All rights reserved. // import Foundation -public enum FileManagerError: Error { +enum NetworkFileProtocolType: String { + case ipfs +} + +enum FileManagerError: Error { case cantDownloadFile case cantUploadFile case cantEnctryptFile } extension FileManagerError: LocalizedError { - public var errorDescription: String? { + var errorDescription: String? { switch self { case .cantDownloadFile: return "cant Download File" 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/NodesStorage.swift b/Adamant/Services/NodesStorage.swift index 8a7b4089f..0a4a91a6f 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/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift b/CommonKit/Sources/CommonKit/Models/NodeGroup+Constants.swift index 609311d0a..ec02b0eaf 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, .lskNode, .lskService, .doge, .dash, .btc: + case .eth, .lskNode, .lskService, .doge, .dash, .btc, .ipfs: return true } } diff --git a/CommonKit/Sources/CommonKit/Models/NodeGroup.swift b/CommonKit/Sources/CommonKit/Models/NodeGroup.swift index d87fb9d80..340fdc536 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/FilesNetworkManagerKit/.gitignore b/FilesNetworkManagerKit/.gitignore deleted file mode 100644 index 0023a5340..000000000 --- a/FilesNetworkManagerKit/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/FilesNetworkManagerKit/Package.swift b/FilesNetworkManagerKit/Package.swift deleted file mode 100644 index d6b2d6f2a..000000000 --- a/FilesNetworkManagerKit/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// 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: "FilesNetworkManagerKit", - platforms: [ - .iOS(.v15) - ], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "FilesNetworkManagerKit", - targets: ["FilesNetworkManagerKit"]), - ], - dependencies: [ - .package(path: "../CommonKit"), - .package(url: "https://github.com/uploadcare/uploadcare-swift.git", branch: "master") - ], - 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: "FilesNetworkManagerKit", - dependencies: [ - "CommonKit", - .product(name: "Uploadcare", package: "uploadcare-swift") - ] - ), - .testTarget( - name: "FilesNetworkManagerKitTests", - dependencies: ["FilesNetworkManagerKit"]), - ] -) diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift deleted file mode 100644 index 15569265a..000000000 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/FilesNetworkManagerKit.swift +++ /dev/null @@ -1,28 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -import Foundation - -public final class FilesNetworkManager { - private let uploadCareApi: ApiManagerProtocol = UploadCareApiManager() - - public init() { } - - public func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String { - switch type { - case .uploadCareApi: - return try await uploadCareApi.uploadFile(data: data) - } - } - - public func downloadFile(_ id: String, type: String) async throws -> Data { - guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { - throw FileManagerError.cantDownloadFile - } - - switch netwrokProtocol { - case .uploadCareApi: - return try await uploadCareApi.downloadFile(id: id) - } - } -} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift deleted file mode 100644 index 921215dd0..000000000 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Managers/UploadCareApiManager.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// File.swift -// -// -// Created by Stanislav Jelezoglo on 06.03.2024. -// - -import Foundation -import Uploadcare -import CommonKit - -final class UploadCareApiManager: ApiManagerProtocol { - private var uploadcare: Uploadcare - - init() { - self.uploadcare = Uploadcare(withPublicKey: "a309ad74a3c543fed143") - } - - func uploadFile(data: Data) async throws -> String { - let fileForUploading = uploadcare.file(fromData: data) - try await fileForUploading.upload(withName: String.random(length: 6), store: .auto) - return fileForUploading.fileId - } - - func downloadFile(id: String) async throws -> Data { - let request = URLRequest(url: URL(string: "https://ucarecdn.com/\(id)/")!) - let (data, _) = try await URLSession.shared.data(for: request) - return data - } -} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift deleted file mode 100644 index a663816e2..000000000 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Models/NetworkFileProtocolType.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// NetworkFileProtocolType.swift -// -// -// Created by Stanislav Jelezoglo on 06.03.2024. -// - -import Foundation - -public enum NetworkFileProtocolType: String { - case uploadCareApi -} diff --git a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift b/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift deleted file mode 100644 index 5cd694c47..000000000 --- a/FilesNetworkManagerKit/Sources/FilesNetworkManagerKit/Protocols/ApiManagerProtocol.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ApiManagerProtocol.swift -// -// -// Created by Stanislav Jelezoglo on 06.03.2024. -// - -import Foundation - -protocol ApiManagerProtocol { - func uploadFile(data: Data) async throws -> String - func downloadFile(id: String) async throws -> Data -} diff --git a/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift b/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift deleted file mode 100644 index f8c7628c6..000000000 --- a/FilesNetworkManagerKit/Tests/FilesNetworkManagerKitTests/FilesNetworkManagerKitTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import XCTest -@testable import FilesNetworkManagerKit - -final class FilesNetworkManagerKitTests: 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/Package.swift b/FilesStorageKit/Package.swift index d24396c56..588daaeb2 100644 --- a/FilesStorageKit/Package.swift +++ b/FilesStorageKit/Package.swift @@ -15,15 +15,14 @@ let package = Package( targets: ["FilesStorageKit"]), ], dependencies: [ - .package(path: "../CommonKit"), - .package(path: "../FilesNetworkManagerKit") + .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", "FilesNetworkManagerKit"]), + dependencies: ["CommonKit"]), .testTarget( name: "FilesStorageKitTests", dependencies: ["FilesStorageKit"]), diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index cc4b65624..2a69740fc 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -3,16 +3,8 @@ import CommonKit import UIKit -import FilesNetworkManagerKit public final class FilesStorageKit { - typealias UploadResult = (id: String, nonce: String) - - private let adamantCore = NativeAdamantCore() - private let networkFileManager = FilesNetworkManager() - private let networkService = NetworkService() - private let taskQueue = TaskQueue(maxTasks: 5) - @Atomic private var cachedFilesUrl: [String: URL] = [:] private var cachedFiles: NSCache = NSCache() @@ -20,28 +12,6 @@ public final class FilesStorageKit { try? loadCache() } - public func cachePreview( - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, - ownerId: String, - recipientId: String, - previewId: String, - previewNonce: String - ) async throws { - try await downloadFile( - id: previewId, - storage: storage, - fileType: fileType, - senderPublicKey: senderPublicKey, - recipientPrivateKey: recipientPrivateKey, - nonce: previewNonce, - ownerId: ownerId, - recipientId: recipientId - ) - } - public func getPreview(for id: String, type: String) -> UIImage? { if let image = cachedFiles.object(forKey: id as NSString) { return image @@ -68,69 +38,29 @@ public final class FilesStorageKit { return url } - public func uploadFile( - _ file: FileResult, - recipientPublicKey: String, - senderPrivateKey: String, + public func cacheFile( + id: String, + url: URL, ownerId: String, recipientId: String - ) async throws -> (id: String, nonce: String, idPreview: String?, noncePreview: String?) { - let result = try await uploadFile( - url: file.url, - recipientPublicKey: recipientPublicKey, - senderPrivateKey: senderPrivateKey, + ) throws { + try cacheFile( + with: id, + localUrl: url, ownerId: ownerId, recipientId: recipientId ) - - var resultPreview: UploadResult? - - if let url = file.previewUrl { - resultPreview = try? await uploadFile( - url: url, - recipientPublicKey: recipientPublicKey, - senderPrivateKey: senderPrivateKey, - ownerId: ownerId, - recipientId: recipientId - ) - } - - return (id: result.id, nonce: result.nonce, idPreview: resultPreview?.id, noncePreview: resultPreview?.nonce) } - public func downloadFile( + public func cacheFile( id: String, - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, + data: Data, ownerId: String, - recipientId: String, - nonce: String, - previewId: String?, - previewNonce: String? - ) async throws { - if let previewId = previewId, - let previewNonce = previewNonce { - try? await downloadFile( - id: previewId, - storage: storage, - fileType: fileType, - senderPublicKey: senderPublicKey, - recipientPrivateKey: recipientPrivateKey, - nonce: previewNonce, - ownerId: ownerId, - recipientId: recipientId - ) - } - - return try await downloadFile( - id: id, - storage: storage, - fileType: fileType, - senderPublicKey: senderPublicKey, - recipientPrivateKey: recipientPrivateKey, - nonce: nonce, + recipientId: String + ) throws { + try cacheFile( + with: id, + data: data, ownerId: ownerId, recipientId: recipientId ) @@ -184,56 +114,6 @@ public final class FilesStorageKit { } private extension FilesStorageKit { - func downloadFile( - id: String, - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String, - ownerId: String, - recipientId: String - ) async throws { - let decodedData = try await networkService.downloadFile( - id: id, - storage: storage, - fileType: fileType, - senderPublicKey: senderPublicKey, - recipientPrivateKey: recipientPrivateKey, - nonce: nonce - ) - - return try cacheFile( - id: id, - data: decodedData, - ownerId: ownerId, - recipientId: recipientId - ) - } - - func uploadFile( - url: URL, - recipientPublicKey: String, - senderPrivateKey: String, - ownerId: String, - recipientId: String - ) async throws -> UploadResult { - let result = try await networkService.uploadFile( - url: url, - recipientPublicKey: recipientPublicKey, - senderPrivateKey: senderPrivateKey - ) - - try cacheFile( - id: result.id, - localUrl: url, - ownerId: ownerId, - recipientId: recipientId - ) - - return (id: result.id, nonce: result.nonce) - } - func loadCache() throws { let folder = try FileManager.default.url( for: .cachesDirectory, @@ -277,7 +157,7 @@ private extension FilesStorageKit { } func cacheFile( - id: String, + with id: String, data: Data? = nil, localUrl: URL? = nil, ownerId: String, diff --git a/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift b/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift deleted file mode 100644 index 11635ce3b..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/NetworkService.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// NetworkService.swift -// -// -// Created by Stanislav Jelezoglo on 28.03.2024. -// - -import Foundation -import CommonKit -import UIKit -import FilesNetworkManagerKit -import Combine - -final class NetworkService { - typealias UploadResult = (id: String, nonce: String, data: Data) - - private let adamantCore = NativeAdamantCore() - private let networkFileManager = FilesNetworkManager() - - func downloadFile( - id: String, - storage: String, - fileType: String?, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String - ) async throws -> Data { - let encodedData = try await networkFileManager.downloadFile(id, type: storage) - - guard let decodedData = adamantCore.decodeData( - encodedData, - rawNonce: nonce, - senderPublicKey: senderPublicKey, - privateKey: recipientPrivateKey - ) - else { - throw FileValidationError.fileNotFound - } - - return decodedData - } - - func uploadFile( - url: URL, - recipientPublicKey: String, - senderPrivateKey: String - ) 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.cantEnctryptFile - } - - let id = try await networkFileManager.uploadFiles(encodedData, type: .uploadCareApi) - - return (id: id, nonce: nonce, data: data) - } -} - -func makePublisher( - operation: @escaping () async -> Output -) -> some Publisher { - Future { promise in - Task { - let output = await operation() - promise(.success(output)) - } - } -} - -actor TaskQueue { - private struct Effect { - private let f: () -> AnyPublisher - let continuation: CheckedContinuation - - init( - operation: @escaping () async -> Value, - continuation: CheckedContinuation - ) { - self.f = { makePublisher(operation: operation).eraseToAnyPublisher() } - self.continuation = continuation - } - - func invoke() -> AnyPublisher { - f() - } - } - - typealias Operation = () async -> Value - typealias Continuation = CheckedContinuation - - private var cancellable: AnyCancellable! - private var input = PassthroughSubject() - - init(maxTasks: Int = 1, bufferSize: Int = 100) { - self.cancellable = input - .buffer(size: bufferSize, prefetch: .keepFull, whenFull: .dropOldest) - .flatMap(maxPublishers: .max(maxTasks)) { effect in - effect.invoke().map { [continuation = effect.continuation] in ($0, continuation) } - } - .sink { value, continuation in - continuation.resume(returning: value) - } - } - - func enqueue(_ operation: @escaping Operation) async -> Value { - await withCheckedContinuation { continuation in - self.send(operation: operation, continuation: continuation) - } - } - - private func send(operation: @escaping Operation, continuation: Continuation) { - self.input.send( - Effect( - operation: operation, - continuation: continuation - ) - ) - } -} From b0d1c85698dfcf90435b47b7bebc4ceaf0f60302 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 10 Apr 2024 15:16:31 +0300 Subject: [PATCH 056/123] [trello.com/c/uxBZaznD] fix: pick image from library --- .../Helpers/FilesPickerKitHelper.swift | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index a0a55394c..193496385 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -158,32 +158,34 @@ final class FilesPickerKitHelper { @MainActor func getUrl(for itemProvider: NSItemProvider) async throws -> URL { - guard let type = itemProvider.registeredTypeIdentifiers.last - else { - throw FileValidationError.fileNotFound + for type in itemProvider.registeredTypeIdentifiers { + do { + return try await getFileURL(by: type, itemProvider: itemProvider) + } catch { + continue + } } - return try await withUnsafeThrowingContinuation { continuation in - itemProvider.loadFileRepresentation(forTypeIdentifier: type) { [weak self] url, error in + throw FileValidationError.fileNotFound + } + + @MainActor + func getFileURL( + by type: String, + itemProvider: NSItemProvider + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + itemProvider.loadFileRepresentation(forTypeIdentifier: type) { url, error in if let error = error { continuation.resume(throwing: error) - return - } - - guard let url = url else { - continuation.resume(throwing: FileValidationError.fileNotFound) - return - } - - do { - guard let targetURL = try self?.copyFile(from: url) else { + } else if let url = url { + if let targetURL = try? self.copyFile(from: url) { + continuation.resume(returning: targetURL) + } else { continuation.resume(throwing: FileValidationError.fileNotFound) - return } - - continuation.resume(returning: targetURL) - } catch { - continuation.resume(throwing: error) + } else { + continuation.resume(throwing: FileValidationError.fileNotFound) } } } From 6a7dcbf978def52af1d1557bc92e4040b448a335 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 10 Apr 2024 17:17:18 +0300 Subject: [PATCH 057/123] [trello.com/c/uxBZaznD] fix: inputBar offset --- Adamant/Modules/Chat/View/ChatViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index ea71f3ee7..d87c19296 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -547,6 +547,8 @@ private extension ChatViewController { } func presentMediaPicker() { + messageInputBar.inputTextView.resignFirstResponder() + mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in DispatchQueue.main.async { self?.viewModel.presentDialog(progress: false) @@ -570,6 +572,8 @@ private extension ChatViewController { } func presentDocumentPicker() { + messageInputBar.inputTextView.resignFirstResponder() + documentPickerDelegate.onPreparedDataCallback = { [weak self] result in DispatchQueue.main.async { self?.viewModel.presentDialog(progress: false) From 298b1d28d29f0c2163994daea492108855cecfa2 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 11 Apr 2024 13:56:08 +0300 Subject: [PATCH 058/123] [trello.com/c/uxBZaznD] feat: add localizations --- .../Chat/ViewModel/ChatFileService.swift | 4 +-- .../StorageUsage/StorageUsageView.swift | 12 +++---- Adamant/ServiceProtocols/DialogService.swift | 15 +++++++-- .../Models/FileManagerError.swift | 13 +++++--- .../Localization/de.lproj/Localizable.strings | 30 +++++++++++++++++ .../Localization/en.lproj/Localizable.strings | 30 +++++++++++++++++ .../Localization/ru.lproj/Localizable.strings | 30 +++++++++++++++++ .../Localization/zh.lproj/Localizable.strings | 30 +++++++++++++++++ .../CommonKit/Helpers/FilesConstants.swift | 15 +++++++++ .../Models/FileValidationError.swift | 33 +++++++++++++++++++ .../FilesPickerKit/Models/Constants.swift | 16 --------- .../Models/FileValidationError.swift | 27 --------------- .../Models/FileValidationError.swift | 27 --------------- 13 files changed, 196 insertions(+), 86 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift create mode 100644 CommonKit/Sources/CommonKit/Models/FileValidationError.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift delete mode 100644 FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 9ba9b62ef..3ec0df28e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -432,7 +432,7 @@ private extension ChatFileService { guard let encodedData = encodedResult?.data, let nonce = encodedResult?.nonce else { - throw FileManagerError.cantEnctryptFile + throw FileManagerError.cantEncryptFile } let cid = try await filesNetworkManager.uploadFiles(encodedData, type: storageProtocol) @@ -455,7 +455,7 @@ private extension ChatFileService { privateKey: recipientPrivateKey ) else { - throw FileManagerError.cantDownloadFile + throw FileManagerError.cantDecryptFile } return decodedData diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index d25400753..2d07b1cb9 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -84,7 +84,7 @@ private extension StorageUsageView { Image(uiImage: previewImage) Text(previewTitle) } - .onChange(of: viewModel.autoDownloadPreview) { value in + .onChange(of: viewModel.autoDownloadPreview) { _ in viewModel.togglePreviewContent() } } @@ -93,9 +93,9 @@ private extension StorageUsageView { } private let storageImage: UIImage = .asset(named: "row_storage")! -private let storageDescription: String = .localized("StorageUsage.Description") -private let storageTitle: String = .localized("StorageUsage.Title") -private let clearTitle: String = .localized("StorageUsage.Clear.Title") +private var storageDescription: String { .localized("StorageUsage.Description") } +private var storageTitle: String { .localized("StorageUsage.Title") } +private var clearTitle: String { .localized("StorageUsage.Clear.Title") } private let previewImage: UIImage = .asset(named: "row_preview")! -private let previewTitle: String = .localized("Storage.AutoDownloadPreview.Title") -private let previewDescription: String = .localized("Storage.AutoDownloadPreview.Description") +private var previewTitle: String { .localized("Storage.AutoDownloadPreview.Title") } +private var previewDescription: String { .localized("Storage.AutoDownloadPreview.Description") } diff --git a/Adamant/ServiceProtocols/DialogService.swift b/Adamant/ServiceProtocols/DialogService.swift index 5ca5da44f..4787cfead 100644 --- a/Adamant/ServiceProtocols/DialogService.swift +++ b/Adamant/ServiceProtocols/DialogService.swift @@ -22,6 +22,15 @@ extension String.adamant.alert { static var saveToPhotolibrary: String { String.localized("Shared.SaveToPhotolibrary", comment: "Shared alert 'Save to Photos'. Used with saving images to photolibrary") } + 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'") + } } enum AddressChatShareType { @@ -63,13 +72,13 @@ enum ShareType { return String.adamant.alert.saveToPhotolibrary case .sendTokens: - return "Send tokens" + return String.adamant.alert.sendTokens case .uploadMedia: - return "Upload media" + return String.adamant.alert.uploadMedia case .uploadFile: - return "Upload file" + return String.adamant.alert.uploadFile } } } diff --git a/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift index d86df18ba..0cba0b7ec 100644 --- a/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift +++ b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift @@ -15,18 +15,21 @@ enum NetworkFileProtocolType: String { enum FileManagerError: Error { case cantDownloadFile case cantUploadFile - case cantEnctryptFile + case cantEncryptFile + case cantDecryptFile } extension FileManagerError: LocalizedError { var errorDescription: String? { switch self { case .cantDownloadFile: - return "cant Download File" + return .localized("FileManagerError.CantDownloadFile") case .cantUploadFile: - return "cant Upload File" - case .cantEnctryptFile: - return "cant encrypt file" + return .localized("FileManagerError.CantUploadFile") + case .cantEncryptFile: + return .localized("FileManagerError.CantEncryptFile") + case .cantDecryptFile: + return .localized("FileManagerError.CantDecryptFile") } } } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index f1132af64..19bfe1a82 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -895,6 +895,15 @@ /* Shared alert 'Save to Photos'. Used with saving images to photolibrary */ "Shared.SaveToPhotolibrary" = "Im Fotoalbum speichern"; +/* 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"; @@ -1242,3 +1251,24 @@ /* Include partner url */ "PartnerQR.includePartnerURL" = "Koppeling naar webapp opnemen"; + +/* 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 7447458df..0b5abc35b 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -880,6 +880,15 @@ /* Shared alert 'Save to Photos'. Used with saving images to photolibrary */ "Shared.SaveToPhotolibrary" = "Save to Photos"; +/* Shared alert 'Send tokens' */ +"Shared.SendTokens" = "Send tokens"; + +/* Shared alert 'Upload file' */ +"Shared.UploadFile" = "Upload file"; + +/* Shared alert 'Upload Media' */ +"Shared.UploadMedia" = "Upload 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"; @@ -1215,3 +1224,24 @@ /* Include partner url */ "PartnerQR.includePartnerURL" = "Include Web app link"; + +/* 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" = "File size exceeds the limit. Maximum allowed: %lld MB"; + +/* File validation error 'File not found' */ +"FileValidationError.FileNotFound" = "File not found"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 60afc96cc..30dab28e6 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -877,6 +877,15 @@ /* Shared alert 'Save to Photos'. Used with saving images to photolibrary */ "Shared.SaveToPhotolibrary" = "Сохранить в Фото"; +/* 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" = "Настройки"; @@ -1212,3 +1221,24 @@ /* Include partner url */ "PartnerQR.includePartnerURL" = "Добавить ссылку на веб-приложение"; + +/* 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" = "Файл не найден"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 44981f9e3..11b15418e 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -880,6 +880,15 @@ /* Shared alert 'Save to Photos'. Used with saving images to photolibrary */ "Shared.SaveToPhotolibrary" = "保存到照片"; +/* 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" = "设置"; @@ -1212,3 +1221,24 @@ /* Include partner url */ "PartnerQR.includePartnerURL" = "包括Web应用程序链接"; + +/* 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" = "找不到文件"; diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift new file mode 100644 index 000000000..c8cbf1d80 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -0,0 +1,15 @@ +// +// FilesConstants.swift +// +// +// Created by Stanislav Jelezoglo on 10.04.2024. +// + +import Foundation + +public final class FilesConstants { + public static let maxFilesCount = 5 + public static let maxFileSize: Int64 = 10 * 1024 * 1024 + public static let previewSize: CGSize = .init(squareSize: 400) + public static let previewTag: String = "preview_" +} diff --git a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift new file mode 100644 index 000000000..4a1320db7 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift @@ -0,0 +1,33 @@ +// +// FileValidationError.swift +// +// +// Created by Stanislav Jelezoglo on 11.04.2024. +// + +import Foundation + +public enum FileValidationError: Error { + case tooManyFiles + case fileSizeExceedsLimit + case fileNotFound +} + +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") + } + } +} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift deleted file mode 100644 index 22175905a..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/Constants.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Constants.swift -// -// -// Created by Stanislav Jelezoglo on 06.03.2024. -// - -import Foundation -import UIKit - -public final class FilesConstants { - public static let maxFilesCount = 5 - static let maxFileSize: Int64 = 10 * 1024 * 1024 - static let previewSize: CGSize = .init(squareSize: 400) - static let previewTag: String = "preview_" -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift b/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift deleted file mode 100644 index 122951522..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Models/FileValidationError.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// FileValidationError.swift -// -// -// Created by Stanislav Jelezoglo on 12.02.2024. -// - -import Foundation - -public enum FileValidationError: Error { - case tooManyFiles - case fileSizeExceedsLimit - case fileNotFound -} - -extension FileValidationError: LocalizedError { - public var errorDescription: String? { - switch self { - case .tooManyFiles: - return "too Many Files" - case .fileSizeExceedsLimit: - return "file Size Exceeds Limit" - case .fileNotFound: - return "file Not Found" - } - } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift b/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift deleted file mode 100644 index 5870b1162..000000000 --- a/FilesStorageKit/Sources/FilesStorageKit/Models/FileValidationError.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// FileValidationError.swift -// -// -// Created by Stanislav Jelezoglo on 13.03.2024. -// - -import Foundation - -public enum FileValidationError: Error { - case tooManyFiles - case fileSizeExceedsLimit - case fileNotFound -} - -extension FileValidationError: LocalizedError { - public var errorDescription: String? { - switch self { - case .tooManyFiles: - return "too Many Files" - case .fileSizeExceedsLimit: - return "file Size Exceeds Limit" - case .fileNotFound: - return "file Not Found" - } - } -} From df7dad1da6de8ab731fd3e8e707353210a66edf3 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 12 Apr 2024 09:38:43 +0300 Subject: [PATCH 059/123] [trello.com/c/uxBZaznD] fix: layout for small screens --- .../ChatFileContainerView/ChatFileView.swift | 3 ++- .../MediaContainerView.swift | 19 ++++++++++++------- .../MediaContainerView/MediaContentView.swift | 3 ++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 0f84ea5e5..ccd3e5585 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -17,7 +17,7 @@ class ChatFileView: UIView { private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) view.isHidden = true - view.color = .black + view.color = .white return view }() @@ -142,6 +142,7 @@ private extension ChatFileView { videoIconIV.addShadow() downloadImageView.addShadow() + spinner.addShadow() } func update() { 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 index 2a1844f81..63673d91b 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -31,7 +31,7 @@ final class MediaContainerView: UIView { let view = MediaContentView() view.layer.masksToBounds = true view.snp.makeConstraints { - $0.height.equalTo(rowHeight) + $0.height.equalTo(rowVerticalHeight) } stackView.addArrangedSubview(view) } @@ -50,6 +50,11 @@ final class MediaContainerView: UIView { var actionHandler: (ChatAction) -> Void = { _ in } + private var stackWidth: CGFloat { + guard !isMacOS else { return defaultStackWidth } + return UIScreen.main.bounds.width - screenSpace + } + // MARK: - Init override init(frame: CGRect) { @@ -183,12 +188,6 @@ private extension MediaContainerView { } } -private let stackSpacing: CGFloat = 1 -private let rowHeight: CGFloat = 240 -private let rowVerticalHeight: CGFloat = 200 -private let rowHorizontalHeight: CGFloat = 150 -private let stackWidth: CGFloat = 280 - extension ChatMediaContentView.FileModel { func height() -> CGFloat { let fileList = Array(files.prefix(FilesConstants.maxFilesCount)) @@ -221,3 +220,9 @@ extension ChatMediaContentView.FileModel { + 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 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 index c26fff842..5e982d085 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -18,7 +18,7 @@ final class MediaContentView: UIView { private lazy var spinner: UIActivityIndicatorView = { let view = UIActivityIndicatorView(style: .medium) view.isHidden = true - view.color = .black + view.color = .white return view }() @@ -96,6 +96,7 @@ private extension MediaContentView { videoIconIV.addShadow() downloadImageView.addShadow() + spinner.addShadow() } func update() { From 3ef433cf5cf86abc6fe6b29a88811470f8597f34 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 12 Apr 2024 09:56:37 +0300 Subject: [PATCH 060/123] [trello.com/c/uxBZaznD] fix: download animation --- Adamant/Modules/Chat/ViewModel/ChatFileService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 3ec0df28e..fa48be3ff 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -320,7 +320,8 @@ final class ChatFileService: ChatFileProtocol { let previewNonce = file.file.preview_nonce, !filesStorage.isCached(previewId), let ownerId = accountService.account?.address, - let recipientId = chatroom?.partner?.address + let recipientId = chatroom?.partner?.address, + NetworkFileProtocolType(rawValue: file.storage) != nil else { return } downloadingFilesIDsArray.append(file.file.file_id) From 2d05b5abb09baa6476297ec90730dce5be57ddd9 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 12 Apr 2024 18:58:13 +0300 Subject: [PATCH 061/123] [trello.com/c/uxBZaznD] fix: cell content height --- .../Managers/FixedTextMessageSizeCalculator.swift | 1 - .../ChatMedia/Content/ChatMediaContnentView.swift | 14 +++++--------- .../MediaContainerView/MediaContainerView.swift | 6 +++--- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index 09ac747ab..3d1133fc3 100644 --- a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift +++ b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift @@ -84,7 +84,6 @@ messageContainerSize.width = maxWidth messageContainerSize.height = contentViewHeight + messageInsets.vertical - + additionalHeight } return messageContainerSize diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 80a088c44..3c0c90b60 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -13,7 +13,6 @@ import FilesPickerKit final class ChatMediaContentView: UIView { private let commentLabel = UILabel( - font: commentFont, textColor: .adamant.textColor, numberOfLines: .zero ) @@ -235,7 +234,7 @@ private extension ChatMediaContentView { extension ChatMediaContentView.Model { func height() -> CGFloat { - let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : 0 + let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : .zero var rowCount: CGFloat = fileModel.isMediaFilesOnly ? .zero : 1 @@ -244,12 +243,14 @@ extension ChatMediaContentView.Model { } if !comment.string.isEmpty { - rowCount += 1 + rowCount += 3 } + let stackWidth = MediaContainerView.stackWidth + return fileModel.height() + rowCount * verticalStackSpacing - + labelSize(for: comment, considering: contentWidth).height + + labelSize(for: comment, considering: stackWidth - horizontalInsets).height + replyViewDynamicHeight } @@ -275,12 +276,7 @@ extension ChatMediaContentView.Model { } } -private let nameFont = UIFont.systemFont(ofSize: 15) -private let sizeFont = UIFont.systemFont(ofSize: 13) -private let imageSize: CGFloat = 70 -private let commentFont = UIFont.systemFont(ofSize: 14) private let verticalStackSpacing: CGFloat = 10 private let verticalInsets: CGFloat = 8 private let horizontalInsets: CGFloat = 12 private let replyViewHeight: CGFloat = 25 -private let contentWidth: CGFloat = 280 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 index 63673d91b..53756bf06 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -50,7 +50,7 @@ final class MediaContainerView: UIView { var actionHandler: (ChatAction) -> Void = { _ in } - private var stackWidth: CGFloat { + static var stackWidth: CGFloat { guard !isMacOS else { return defaultStackWidth } return UIScreen.main.bounds.width - screenSpace } @@ -75,7 +75,7 @@ private extension MediaContainerView { addSubview(filesStack) filesStack.snp.makeConstraints { $0.directionalEdges.equalToSuperview() - $0.width.equalTo(stackWidth) + $0.width.equalTo(Self.stackWidth) } } @@ -136,7 +136,7 @@ private extension MediaContainerView { isHorizontal: Bool, fileList: [ChatFile] ) { - let filesStackWidth = stackWidth + let filesStackWidth = Self.stackWidth let minimumWidth = calculateMinimumWidth(availableWidth: filesStackWidth) let maximumWidth = calculateMaximumWidth(availableWidth: filesStackWidth) From 0a23c643ea3e43a796a4aa34694af55f80c334ab Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 13:47:23 +0300 Subject: [PATCH 062/123] [trello.com/c/uxBZaznD] fix: select live photo --- .../Chat/View/ChatViewController.swift | 2 +- .../Localization/de.lproj/Localizable.strings | 3 + .../Localization/en.lproj/Localizable.strings | 3 + .../Localization/ru.lproj/Localizable.strings | 3 + .../Localization/zh.lproj/Localizable.strings | 3 + .../Models/FileValidationError.swift | 16 ++ .../Helpers/FilesPickerKitHelper.swift | 20 +++ .../Pickers/MediaPickerService.swift | 149 ++++++++++-------- 8 files changed, 131 insertions(+), 68 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index d87c19296..4a2902193 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -564,7 +564,7 @@ private extension ChatViewController { var phPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) phPickerConfig.selectionLimit = FilesConstants.maxFilesCount - phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) + phPickerConfig.filter = PHPickerFilter.any(of: [.images, .videos, .livePhotos]) let phPickerVC = PHPickerViewController(configuration: phPickerConfig) phPickerVC.delegate = mediaPickerDelegate diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 19bfe1a82..219c7b572 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -1272,3 +1272,6 @@ /* 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: %@"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 0b5abc35b..d20138bb8 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -1245,3 +1245,6 @@ /* File validation error 'File not found' */ "FileValidationError.FileNotFound" = "File not found"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "Can't select file: %@"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 30dab28e6..fd854d5d0 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -1242,3 +1242,6 @@ /* File validation error 'File not found' */ "FileValidationError.FileNotFound" = "Файл не найден"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "Невозможно выбрать файл: %@"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 11b15418e..9049d7e57 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -1242,3 +1242,6 @@ /* File validation error 'File not found' */ "FileValidationError.FileNotFound" = "找不到文件"; + +/* File picker error 'Cant select file' */ +"FileValidationError.CantSelectFile" = "无法选择文件: %@"; diff --git a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift index 4a1320db7..7de23eeb5 100644 --- a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift +++ b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift @@ -7,6 +7,22 @@ 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 diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 193496385..71f5c76b1 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -156,6 +156,26 @@ final class FilesPickerKitHelper { ) } + @MainActor + 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 func getUrl(for itemProvider: NSItemProvider) async throws -> URL { for type in itemProvider.registeredTypeIdentifiers { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 9ab38f41e..4b6b219c5 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -38,76 +38,77 @@ extension MediaPickerService: PHPickerViewControllerDelegate { private extension MediaPickerService { func processResults(_ results: [PHPickerResult]) async { do { - var dataArray: [FileResult] = [] - - for result in results { - let itemProvider = result.itemProvider + var dataArray: [FileResult] = [] - guard let typeIdentifier = itemProvider.registeredTypeIdentifiers.first, - let utType = UTType(typeIdentifier) - else { - throw FileValidationError.fileNotFound - } - - if utType.conforms(to: .image) { - let url = try await helper.getUrl(for: itemProvider) - let preview = try getPhoto(from: url) - 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 - ) - - dataArray.append( - .init( - url: url, - type: .image, - preview: resizedPreview, - previewUrl: previewUrl, - size: fileSize, - name: itemProvider.suggestedName, - extenstion: url.pathExtension, - resolution: preview.size + for result in results { + let itemProvider = result.itemProvider + if isConforms(to: .image, itemProvider.registeredTypeIdentifiers) { + let url = try await helper.getUrlConforms( + to: .image, + for: itemProvider ) - ) - } - - if utType.conforms(to: .movie) { - let url = try await helper.getUrl(for: itemProvider) - let fileSize = try helper.getFileSize(from: url) - let originalSize = helper.getOriginalSize(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( - url: url, - type: .video, - preview: thumbnailImage, - previewUrl: previewUrl, - size: fileSize, - name: itemProvider.suggestedName, - extenstion: url.pathExtension, - resolution: originalSize + + let preview = try getPhoto(from: url) + 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 + ) + + dataArray.append( + .init( + url: url, + type: .image, + preview: resizedPreview, + previewUrl: previewUrl, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: url.pathExtension, + resolution: preview.size + ) + ) + } 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 thumbnailImage = try? await helper.getThumbnailImage( + forUrl: url, + originalSize: originalSize + ) + + let previewUrl = try? helper.getUrl( + for: thumbnailImage, + name: FilesConstants.previewTag + url.lastPathComponent + ) + + dataArray.append( + .init( + url: url, + type: .video, + preview: thumbnailImage, + previewUrl: previewUrl, + size: fileSize, + name: itemProvider.suggestedName, + extenstion: url.pathExtension, + resolution: originalSize + ) ) - ) + } else { + throw FilePickersError.cantSelectFile(itemProvider.suggestedName ?? .empty) + } } - } - + try helper.validateFiles(dataArray) onPreparedDataCallback?(.success(dataArray)) } catch { @@ -117,9 +118,23 @@ private extension MediaPickerService { func getPhoto(from url: URL) throws -> UIImage { guard let image = UIImage(contentsOfFile: url.path) else { - throw FileValidationError.fileNotFound + throw FilePickersError.cantSelectFile(url.lastPathComponent) } 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 + } } From 277fd3448ebcd657c77c05c343cf6efe99a91a45 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 13:50:32 +0300 Subject: [PATCH 063/123] [trello.com/c/uxBZaznD] feat: inc max file size to 250 mb --- CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift index c8cbf1d80..c63d9221a 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -9,7 +9,7 @@ import Foundation public final class FilesConstants { public static let maxFilesCount = 5 - public static let maxFileSize: Int64 = 10 * 1024 * 1024 + public static let maxFileSize: Int64 = 250 * 1024 * 1024 public static let previewSize: CGSize = .init(squareSize: 400) public static let previewTag: String = "preview_" } From 8d121493d7336dd90543bff2bc892640ba9abee3 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 14:15:04 +0300 Subject: [PATCH 064/123] [trello.com/c/uxBZaznD] fix: inc preview quality --- .../Sources/CommonKit/Helpers/FilesConstants.swift | 1 + .../FilesPickerKit/Helpers/FilesPickerKitHelper.swift | 11 ++--------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift index c63d9221a..71fcb9b6b 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -12,4 +12,5 @@ public final class FilesConstants { public static let maxFileSize: Int64 = 250 * 1024 * 1024 public static let previewSize: CGSize = .init(squareSize: 400) public static let previewTag: String = "preview_" + public static let previewCompressQuality: CGFloat = 0.8 } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index 71f5c76b1..b0a1f4cc3 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -21,7 +21,7 @@ final class FilesPickerKitHelper { } func getUrl(for image: UIImage?, name: String) throws -> URL { - guard let data = image?.jpegData(compressionQuality: 1.0) else { + guard let data = image?.jpegData(compressionQuality: FilesConstants.previewCompressQuality) else { throw FileValidationError.fileNotFound } @@ -76,14 +76,7 @@ final class FilesPickerKitHelper { func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { let newSize = getPreviewSize(from: image.size) - let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height) - - UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) - image.draw(in: rect) - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return newImage ?? image + return image.imageResized(to: newSize) } func getOriginalSize(for url: URL) -> CGSize? { From 3a4ea055e8b6a944fa56338ae75546a2fd150826 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 15:05:44 +0300 Subject: [PATCH 065/123] [trello.com/c/uxBZaznD] feat: append new picked files to old --- .../Modules/Chat/View/ChatViewController.swift | 6 ++++++ .../Modules/Chat/ViewModel/ChatViewModel.swift | 15 ++++++++++++++- .../Sources/CommonKit/Models/FileResult.swift | 3 +++ .../Helpers/FilesPickerKitHelper.swift | 1 + .../Pickers/MediaPickerService.swift | 13 ++++++++++++- 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 4a2902193..b6add967a 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -562,9 +562,15 @@ private extension ChatViewController { } } + 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 = mediaPickerDelegate diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 9400e1cc6..0fbe482cf 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -765,7 +765,20 @@ final class ChatViewModel: NSObject { func processFileResult(_ result: Result<[FileResult], Error>) { switch result { case .success(let files): - filesPicked = 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 + oldFiles.removeFirst(numberOfExtraElements) + } + + filesPicked = oldFiles case .failure(let error): dialog.send(.alert(error.localizedDescription)) } diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index 4d118ca75..9583fe56f 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -26,6 +26,7 @@ public extension FileType { } public struct FileResult { + public let assetId: String? public let url: URL public let type: FileType public let previewUrl: URL? @@ -36,6 +37,7 @@ public struct FileResult { public let resolution: CGSize? public init( + assetId: String? = nil, url: URL, type: FileType, preview: UIImage?, @@ -45,6 +47,7 @@ public struct FileResult { extenstion: String?, resolution: CGSize? ) { + self.assetId = assetId self.url = url self.type = type self.previewUrl = previewUrl diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index b0a1f4cc3..e95c994e6 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -138,6 +138,7 @@ final class FilesPickerKitHelper { let preview = getPreview(for: newUrl) let fileSize = try getFileSize(from: newUrl) return FileResult( + assetId: url.absoluteString, url: newUrl, type: .other, preview: preview.image, diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 4b6b219c5..a6e065adb 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -16,6 +16,7 @@ public final class MediaPickerService: NSObject, FilePickerProtocol { public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? public var onPreparingDataCallback: (() -> Void)? + public var preSelectedFiles: [FileResult] = [] public override init() { } } @@ -63,6 +64,7 @@ private extension MediaPickerService { dataArray.append( .init( + assetId: result.assetIdentifier, url: url, type: .image, preview: resizedPreview, @@ -94,6 +96,7 @@ private extension MediaPickerService { dataArray.append( .init( + assetId: result.assetIdentifier, url: url, type: .video, preview: thumbnailImage, @@ -105,7 +108,13 @@ private extension MediaPickerService { ) ) } else { - throw FilePickersError.cantSelectFile(itemProvider.suggestedName ?? .empty) + if let file = preSelectedFiles.first(where: { + $0.assetId == result.assetIdentifier + }) { + dataArray.append(file) + } else { + throw FilePickersError.cantSelectFile(itemProvider.suggestedName ?? .empty) + } } } @@ -114,6 +123,8 @@ private extension MediaPickerService { } catch { onPreparedDataCallback?(.failure(error)) } + + preSelectedFiles.removeAll() } func getPhoto(from url: URL) throws -> UIImage { From 1faad13a9ef0f2bcdc54983cd2956ca693aac710 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 15:39:17 +0300 Subject: [PATCH 066/123] [trello.com/c/uxBZaznD] feat: present preview immideatly on sending --- .../Chat/ViewModel/ChatFileService.swift | 4 + .../FilesStorageProtocol.swift | 2 + .../Helpers/FilesPickerKitHelper.swift | 6 +- .../Pickers/MediaPickerService.swift | 10 +- .../Protocols/PinchZoomViewProtocol.swift | 16 -- .../Views/ImageViewer/ImageViewer.swift | 189 ------------------ .../ImageViewer/ImageViewerViewModel.swift | 34 ---- .../Views/OtherViewer/OtherViewer.swift | 139 ------------- .../OtherViewer/OtherViewerViewModel.swift | 77 ------- .../FilesPickerKit/Views/PinchZoomView.swift | 159 --------------- .../FilesPickerKit/Views/ShareSheet.swift | 31 --- .../FilesStorageKit/FilesStorageKit.swift | 11 + 12 files changed, 27 insertions(+), 651 deletions(-) delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift delete mode 100644 FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index fa48be3ff..62f78a0e2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -141,6 +141,10 @@ final class ChatFileService: ChatFileProtocol { ) } + for url in files.compactMap({ $0.previewUrl }) { + filesStorage.cacheTemporaryFile(url: url) + } + let txLocally = try await chatsProvider.sendFileMessageLocally( messageLocally, recipientId: partnerAddress, diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 02a65aadc..3939bdbcf 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -32,6 +32,8 @@ protocol FilesStorageProtocol { recipientId: String ) throws + func cacheTemporaryFile(url: URL) + func getCacheSize() throws -> Int64 func clearCache() throws diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index e95c994e6..debab7804 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -60,7 +60,7 @@ final class FilesPickerKitHelper { withIntermediateDirectories: true ) - let targetURL = folder.appendingPathComponent(url.lastPathComponent) + let targetURL = folder.appendingPathComponent(String.random(length: 6) + url.lastPathComponent) guard targetURL != url else { return url } @@ -144,8 +144,8 @@ final class FilesPickerKitHelper { preview: preview.image, previewUrl: preview.url, size: fileSize, - name: newUrl.lastPathComponent, - extenstion: newUrl.pathExtension, + name: url.lastPathComponent, + extenstion: url.pathExtension, resolution: preview.resolution ) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index a6e065adb..0630f62fd 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -49,7 +49,11 @@ private extension MediaPickerService { for: itemProvider ) - let preview = try getPhoto(from: url) + let preview = try getPhoto( + from: url, + name: itemProvider.suggestedName ?? .empty + ) + let fileSize = try helper.getFileSize(from: url) let resizedPreview = helper.resizeImage( @@ -127,9 +131,9 @@ private extension MediaPickerService { preSelectedFiles.removeAll() } - func getPhoto(from url: URL) throws -> UIImage { + func getPhoto(from url: URL, name: String) throws -> UIImage { guard let image = UIImage(contentsOfFile: url.path) else { - throw FilePickersError.cantSelectFile(url.lastPathComponent) + throw FilePickersError.cantSelectFile(name) } return image diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift deleted file mode 100644 index 781b56b4d..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/PinchZoomViewProtocol.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// File.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import SwiftUI - -protocol PinchZoomViewProtocol: AnyObject { - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift deleted file mode 100644 index a5a288cc9..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewer.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// ImageViewer.swift -// -// -// Created by Stanislav Jelezoglo on 11.03.2024. -// - -import Foundation -import SwiftUI -import CommonKit -import Combine - -public struct ImageViewer: View { - @StateObject private var viewModel: ImageViewerViewModel - @Environment(\.dismiss) private var dismiss - - public init(image: UIImage, caption: String? = nil) { - _viewModel = StateObject( - wrappedValue: ImageViewerViewModel(image: image, caption: caption) - ) - } - - public var body: some View { - VStack { - if viewModel.viewerShown { - ViewerContent(viewModel: viewModel, dismissAction: { - dismissAction() - }) - .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2))) - .onAppear { - resetDragOffset() - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - animateViewer() - } - } - - private func animateViewer() { - Task { - await animate(duration: 0.25) { - viewModel.viewerShown.toggle() - } - } - } - - private func resetDragOffset() { - viewModel.dragOffset = .zero - viewModel.dragOffsetPredicted = .zero - } - - private func dismissAction() { - Task { - await animate(duration: 0.25) { - viewModel.viewerShown = false - } - - dismiss() - } - } -} - -private struct ViewerContent: View { - @ObservedObject var viewModel: ImageViewerViewModel - var dismissAction: () -> Void - - var body: some View { - ZStack { - ViewerControls(viewModel: viewModel, dismissAction: dismissAction) - - ImageContent(viewModel: viewModel, dismissAction: dismissAction) - } - .background(backgroundOpacity()) - } - - private func backgroundOpacity() -> Color { - Color( - red: 0.12, - green: 0.12, - blue: 0.12, - opacity: (1.0 - Double(abs(viewModel.dragOffset.width) + abs(viewModel.dragOffset.height)) / 1000) - ) - } -} - -private struct ViewerControls: View { - @ObservedObject var viewModel: ImageViewerViewModel - - var dismissAction: () -> Void - - @State private var isShareSheetPresented = false - - var body: some View { - VStack { - HStack { - if let caption = viewModel.caption { - Text(caption) - .foregroundColor(.white) - .multilineTextAlignment(.leading) - .padding() - } - Spacer() - CloseButton(dismissAction: dismissAction, color: .white) - .padding() - } - .background(Color.black.opacity(0.5)) - - Spacer() - - HStack { - Spacer() - - Button { - isShareSheetPresented.toggle() - } label: { - Image(systemName: "square.and.arrow.up") - .resizable() - .frame(width: 22, height: 30) - .tint(.white) - } - .padding() - .sheet(isPresented: $isShareSheetPresented) { - ShareSheet(activityItems: [viewModel.uiImage], completion: nil) - } - - Spacer() - } - .background(Color.black.opacity(0.5)) - } - .zIndex(2) - } -} - -struct CloseButton: View { - var dismissAction: () -> Void - let color: Color - - var body: some View { - Button(action: dismissAction) { - Image(systemName: "xmark") - .foregroundColor(color) - .font(.system(size: UIFontMetrics.default.scaledValue(for: 24))) - } - } -} - -private struct ImageContent: View { - @ObservedObject var viewModel: ImageViewerViewModel - var dismissAction: () -> Void - - var body: some View { - VStack { - viewModel.image - .resizable() - .aspectRatio(contentMode: .fit) - .offset(x: viewModel.dragOffset.width, y: viewModel.dragOffset.height) - .rotationEffect(.init(degrees: Double(viewModel.dragOffset.width / 30))) - .pinchToZoom() - .gesture(dragGesture()) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func dragGesture() -> some Gesture { - DragGesture() - .onChanged { value in - viewModel.dragOffset = value.translation - viewModel.dragOffsetPredicted = value.predictedEndTranslation - } - .onEnded { _ in - handleDragEnd() - } - } - - private func handleDragEnd() { - if viewModel.shouldDismissViewer() { - withAnimation(.spring()) { - viewModel.dragOffset = viewModel.dragOffsetPredicted - } - dismissAction() - } else { - withAnimation(.interactiveSpring()) { - viewModel.dragOffset = .zero - } - } - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift deleted file mode 100644 index 78edc7404..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/ImageViewer/ImageViewerViewModel.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// ImageViewerViewModel.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import SwiftUI -import CommonKit - -final class ImageViewerViewModel: ObservableObject { - @Published var viewerShown: Bool = false - @Published var image: Image - @Published var uiImage: UIImage - @Published var caption: String? - @Published var dragOffset: CGSize = CGSize.zero - @Published var dragOffsetPredicted: CGSize = CGSize.zero - - var dismissAction: (() -> Void)? - let presentSendTokensVC = ObservableSender() - - init(image: UIImage, caption: String? = nil) { - self.uiImage = image - self.image = .init(uiImage: image) - self.caption = caption - } - - func shouldDismissViewer() -> Bool { - (abs(dragOffset.height) + abs(dragOffset.width) > 570) || - ((abs(dragOffsetPredicted.height)) / (abs(dragOffset.height)) > 3) || - ((abs(dragOffsetPredicted.width)) / (abs(dragOffset.width))) > 3 - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift deleted file mode 100644 index 8b1f7d747..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewer.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// OtherViewer.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import SwiftUI -import CommonKit - -struct OtherViewer: View { - @Environment(\.dismiss) private var dismiss - @StateObject private var viewModel: OtherViewerViewModel - - init(viewModel: OtherViewerViewModel) { - _viewModel = StateObject( - wrappedValue: viewModel - ) - } - - public var body: some View { - VStack { - if viewModel.viewerShown { - ViewerContent(viewModel: viewModel, dismissAction: { - dismissAction() - }) - .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2))) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - animateViewer() - } - } - - private func animateViewer() { - Task { - await animate(duration: 0.25) { - viewModel.viewerShown.toggle() - } - } - } - - private func dismissAction() { - Task { - await animate(duration: 0.25) { - viewModel.viewerShown = false - } - - dismiss() - } - } -} - -private struct ViewerContent: View { - @ObservedObject var viewModel: OtherViewerViewModel - - var dismissAction: () -> Void - - var body: some View { - VStack { - HStack { - Text(viewModel.caption) - .foregroundColor(.black) - .multilineTextAlignment(.leading) - .padding() - Spacer() - CloseButton(dismissAction: dismissAction, color: .black) - .padding() - } - .background(Color(UIColor.tertiarySystemGroupedBackground)) - - Content(viewModel: viewModel) - - HStack { - Spacer() - - Button { - viewModel.shareAction() - } label: { - Image(systemName: "square.and.arrow.up") - .resizable() - .frame(width: 22, height: 30) - .tint(Color(UIColor.adamant.active)) - } - .padding(EdgeInsets(top: 5, leading: .zero, bottom: 5, trailing: .zero)) - .sheet(isPresented: viewModel.$isShareSheetPresented) { - shareView() - } - - Spacer() - } - .background(Color(UIColor.tertiarySystemGroupedBackground)) - } - .background(Color.white) - } - - func shareView() -> some View { - if let copyURL = try? viewModel.getCopyOfFile() { - let completion: UIActivityViewController.CompletionWithItemsHandler = { [weak viewModel] (_, _, _, _) in - viewModel?.removeCopyOfFile() - } - return ShareSheet(activityItems: [copyURL], completion: completion) - } - - return ShareSheet(activityItems: [viewModel.fileUrl], completion: nil) - } -} - -private struct Content: View { - @ObservedObject var viewModel: OtherViewerViewModel - - var body: some View { - VStack { - Image(uiImage: image) - .resizable() - .frame(width: 80, height: 90) - - Text(viewModel.caption) - .font(.headline) - .foregroundColor(.black) - .multilineTextAlignment(.center) - .padding() - - if let size = viewModel.size { - Text(viewModel.formatSize(size)) - .font(.subheadline) - .foregroundColor(.gray) - .multilineTextAlignment(.center) - .padding() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.white) - } -} - -private let image: UIImage = UIImage.asset(named: "file-default-box")! diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift deleted file mode 100644 index c3867462e..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/OtherViewer/OtherViewerViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// OtherViewerViewModel.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import CommonKit -import SwiftUI - -final class OtherViewerViewModel: ObservableObject { - @Published var viewerShown: Bool = false - @Published var caption: String - @Published var size: Int64? - @Published var fileUrl: URL - @State var isShareSheetPresented = false - - private var copyURL: URL - - init(caption: String, size: Int64?, fileUrl: URL) { - self.caption = caption - self.size = size - self.fileUrl = fileUrl - self.copyURL = URL(fileURLWithPath: fileUrl.deletingLastPathComponent().path) - copyURL.appendPathComponent(caption) - } - - func formatSize(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useMB, .useKB] - formatter.countStyle = .file - - return formatter.string(fromByteCount: bytes) - } - - func getCopyOfFile() throws -> URL { - if FileManager.default.fileExists(atPath: copyURL.path) { - try? FileManager.default.removeItem(at: copyURL) - } - - try FileManager.default.copyItem(at: fileUrl, to: copyURL) - return copyURL - } - - func removeCopyOfFile() { - try? FileManager.default.removeItem(at: copyURL) - } - - func shareAction() { -// if isMacOS { -// try? saveFileToDownloadsFolder() -// return -// } -// -// isShareSheetPresented = true - - let documentInteractionController = UIDocumentInteractionController(url: fileUrl) - documentInteractionController.presentPreview(animated: true) - } -} - -private extension OtherViewerViewModel { - func saveFileToDownloadsFolder() throws { - let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! - - let fileURLInDownloads = downloadsURL.appendingPathComponent(caption) - - do { - let data = try Data(contentsOf: fileUrl) - - try data.write(to: fileURLInDownloads) - } catch { - print("saving error=\(error)") - } - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift deleted file mode 100644 index d2f662dff..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/PinchZoomView.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// File.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import UIKit -import SwiftUI - -class PinchZoomView: UIView { - weak var delegate: PinchZoomViewProtocol? - - private(set) var scale: CGFloat = 0 { - didSet { - delegate?.pinchZoomView(self, didChangeScale: scale) - } - } - - private(set) var anchor: UnitPoint = .center { - didSet { - delegate?.pinchZoomView(self, didChangeAnchor: anchor) - } - } - - private(set) var offset: CGSize = .zero { - didSet { - delegate?.pinchZoomView(self, didChangeOffset: offset) - } - } - - private(set) var isPinching: Bool = false { - didSet { - delegate?.pinchZoomView(self, didChangePinching: isPinching) - } - } - - private var startLocation: CGPoint = .zero - private var location: CGPoint = .zero - private var numberOfTouches: Int = 0 - - init() { - super.init(frame: .zero) - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:))) - pinchGesture.cancelsTouchesInView = false - addGestureRecognizer(pinchGesture) - } - - required init?(coder: NSCoder) { - fatalError() - } - - @objc private func pinch(gesture: UIPinchGestureRecognizer) { - switch gesture.state { - case .began: - isPinching = true - startLocation = gesture.location(in: self) - anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height) - numberOfTouches = gesture.numberOfTouches - - case .changed: - if gesture.numberOfTouches != numberOfTouches { - // If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping. - let newLocation = gesture.location(in: self) - let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y) - startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height) - - numberOfTouches = gesture.numberOfTouches - } - - scale = gesture.scale - - location = gesture.location(in: self) - offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y) - - case .ended, .cancelled, .failed: - withAnimation(.interactiveSpring()) { - isPinching = false - scale = 1.0 - anchor = .center - offset = .zero - } - default: - break - } - } -} - -struct PinchZoom: UIViewRepresentable { - @Binding var scale: CGFloat - @Binding var anchor: UnitPoint - @Binding var offset: CGSize - @Binding var isPinching: Bool - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeUIView(context: Context) -> PinchZoomView { - let pinchZoomView = PinchZoomView() - pinchZoomView.delegate = context.coordinator - return pinchZoomView - } - - func updateUIView(_ pageControl: PinchZoomView, context: Context) { } - - class Coordinator: NSObject, PinchZoomViewProtocol { - var pinchZoom: PinchZoom - - init(_ pinchZoom: PinchZoom) { - self.pinchZoom = pinchZoom - } - - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) { - pinchZoom.isPinching = isPinching - } - - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) { - pinchZoom.scale = scale - } - - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) { - pinchZoom.anchor = anchor - } - - func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) { - pinchZoom.offset = offset - } - } -} - -struct PinchToZoom: ViewModifier { - @State var scale: CGFloat = 1.0 - @State var anchor: UnitPoint = .center - @State var offset: CGSize = .zero - @State var isPinching: Bool = false - - func body(content: Content) -> some View { - content - .scaleEffect(scale, anchor: anchor) - .offset(offset) - .overlay( - PinchZoom( - scale: $scale, - anchor: $anchor, - offset: $offset, - isPinching: $isPinching - ) - ) - } -} - -extension View { - func pinchToZoom() -> some View { - self.modifier(PinchToZoom()) - } -} diff --git a/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift b/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift deleted file mode 100644 index 41f286a7a..000000000 --- a/FilesPickerKit/Sources/FilesPickerKit/Views/ShareSheet.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ShareSheet.swift -// -// -// Created by Stanislav Jelezoglo on 12.03.2024. -// - -import Foundation -import SwiftUI - -struct ShareSheet: View { - let activityItems: [Any] - let completion: UIActivityViewController.CompletionWithItemsHandler? - - var body: some View { - ActivityView(activityItems: activityItems, completion: completion) - } -} - -struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - let completion: UIActivityViewController.CompletionWithItemsHandler? - - func makeUIViewController(context: Context) -> UIActivityViewController { - let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - controller.completionWithItemsHandler = completion - return controller - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { } -} diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 2a69740fc..d79991c37 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -66,6 +66,10 @@ public final class FilesStorageKit { ) } + public func cacheTemporaryFile(url: URL) { + cacheTemporaryFile(with: url) + } + public func getCacheSize() throws -> Int64 { let url = try FileManager.default.url( for: .cachesDirectory, @@ -156,6 +160,13 @@ private extension FilesStorageKit { return fileURLs } + func cacheTemporaryFile(with url: URL) { + cachedFilesUrl[url.absoluteString] = url + if let uiImage = UIImage(contentsOfFile: url.path) { + cachedFiles.setObject(uiImage, forKey: url.absoluteString as NSString) + } + } + func cacheFile( with id: String, data: Data? = nil, From 6d8dd531d8635dae2461f2f8d4e98ba26b53644c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 15 Apr 2024 15:52:45 +0300 Subject: [PATCH 067/123] [trello.com/c/uxBZaznD] fix: clear temp cache --- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 9 ++++++--- Adamant/ServiceProtocols/FilesStorageProtocol.swift | 2 ++ .../Sources/FilesStorageKit/FilesStorageKit.swift | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 0fbe482cf..8fd60f623 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -836,11 +836,14 @@ extension ChatViewModel { } func updateFiles(_ data: [FileResult]?) { - filesPicked = data - if (data?.count ?? .zero) == .zero { - try? filesStorage.clearTempCache() + let previewUrls = filesPicked?.compactMap { $0.previewUrl } ?? [] + let fileUrls = filesPicked?.compactMap { $0.url } ?? [] + + filesStorage.removeTempFiles(at: previewUrls + fileUrls) } + + filesPicked = data } } diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 3939bdbcf..99c11163a 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -39,6 +39,8 @@ protocol FilesStorageProtocol { func clearCache() throws func clearTempCache() throws + + func removeTempFiles(at urls: [URL]) } extension FilesStorageKit: FilesStorageProtocol { } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index d79991c37..faa3bdc33 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -101,6 +101,16 @@ public final class FilesStorageKit { cachedFilesUrl.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, From 5a5cdb984997709e2a479fcaf56c7a42792947e6 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 18 Apr 2024 10:26:10 +0200 Subject: [PATCH 068/123] [trello.com/c/uxBZaznD] feat & fix: add copy action, fix message offset --- .../FixedTextMessageSizeCalculator.swift | 1 - .../Container/ChatMediaContainerView.swift | 9 +++- .../Content/ChatMediaContnentView.swift | 29 ++++++----- .../Chat/ViewModel/ChatMessageFactory.swift | 49 ++++++++++++------- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index 3d1133fc3..fe8ca1359 100644 --- a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift +++ b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift @@ -83,7 +83,6 @@ let contentViewHeight: CGFloat = model.value.height() messageContainerSize.width = maxWidth messageContainerSize.height = contentViewHeight - + messageInsets.vertical } return messageContainerSize diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 83298f9b9..9346f2a1d 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -286,7 +286,14 @@ extension ChatMediaContainerView { actionHandler(.reply(message: model)) } - return AMenuSection([reply, report, remove]) + let copy = AMenuItem.action( + title: .adamant.chat.copy, + systemImageName: "doc.on.doc" + ) { [actionHandler, model] in + actionHandler(.copy(text: model.content.comment.string)) + } + + return AMenuSection([reply, copy, report, remove]) } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 3c0c90b60..bca71fb72 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -10,12 +10,10 @@ import SnapKit import UIKit import CommonKit import FilesPickerKit +import MessageKit final class ChatMediaContentView: UIView { - private let commentLabel = UILabel( - textColor: .adamant.textColor, - numberOfLines: .zero - ) + private let commentLabel = MessageLabel() private let spacingView: UIView = { let view = UIView() @@ -88,9 +86,8 @@ final class ChatMediaContentView: UIView { view.addSubview(fileContainerView) fileContainerView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(verticalInsets) + make.verticalEdges.equalToSuperview().inset(verticalInsets) make.horizontalEdges.equalToSuperview().inset(horizontalInsets) - make.bottom.equalToSuperview().offset(-verticalInsets) } return view @@ -103,9 +100,8 @@ final class ChatMediaContentView: UIView { view.addSubview(commentLabel) commentLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(verticalInsets) + make.verticalEdges.equalToSuperview().inset(verticalInsets) make.horizontalEdges.equalToSuperview().inset(horizontalInsets) - make.bottom.equalToSuperview().offset(-verticalInsets) } return view @@ -171,6 +167,9 @@ private extension ChatMediaContentView { verticalStack.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } + + commentLabel.enabledDetectors = [.url] + commentLabel.setAttributes([.foregroundColor: UIColor.adamant.active], detector: .url) } func update() { @@ -236,21 +235,21 @@ extension ChatMediaContentView.Model { func height() -> CGFloat { let replyViewDynamicHeight: CGFloat = isReply ? replyViewHeight : .zero - var rowCount: CGFloat = fileModel.isMediaFilesOnly ? .zero : 1 + var spaceCount: CGFloat = fileModel.isMediaFilesOnly ? .zero : 1 if isReply { - rowCount += 1 + spaceCount += 2 } if !comment.string.isEmpty { - rowCount += 3 + spaceCount += 2 } let stackWidth = MediaContainerView.stackWidth return fileModel.height() - + rowCount * verticalStackSpacing - + labelSize(for: comment, considering: stackWidth - horizontalInsets).height + + spaceCount * verticalInsets + + labelSize(for: comment, considering: stackWidth - horizontalInsets * 2).height + replyViewDynamicHeight } @@ -272,11 +271,11 @@ extension ChatMediaContentView.Model { let rect = layoutManager.usedRect(for: textContainer) - return rect.integral.size + return .init(width: rect.width, height: rect.height + additionalHeight) } } -private let verticalStackSpacing: CGFloat = 10 private let verticalInsets: CGFloat = 8 private let horizontalInsets: CGFloat = 12 private let replyViewHeight: CGFloat = 25 +private let additionalHeight: CGFloat = 2 diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 49ca1010d..509a5338e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -180,17 +180,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 @@ -204,7 +194,7 @@ private extension ChatMessageFactory { return .message(.init( value: .init( id: transaction.txId, - text: mutableAttributedString, + text: text, backgroundColor: backgroundColor, isFromCurrentSender: isFromCurrentSender, reactions: reactions, @@ -223,11 +213,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 @@ -244,7 +235,7 @@ private extension ChatMessageFactory { value: .init( id: transaction.txId, replyId: replyId, - message: Self.markdownParser.parse(replyMessage), + message: replyMessage, messageReply: decodedMessageMarkDown, backgroundColor: backgroundColor, isFromCurrentSender: isFromCurrentSender, @@ -318,9 +309,10 @@ private extension ChatMessageFactory { let decodedMessage = decodeMessage(transaction) let storage = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty - let comment = transaction.getRichValue(for: RichContentKeys.file.comment) ?? .empty + 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 @@ -361,7 +353,7 @@ private extension ChatMessageFactory { isReply: transaction.isFileReply(), replyMessage: decodedMessage, replyId: replyId, - comment: Self.markdownParser.parse(comment), + comment: comment, backgroundColor: backgroundColor ), address: address, @@ -369,12 +361,29 @@ private extension ChatMessageFactory { ))) } - private func decodeMessage(_ transaction: RichMessageTransaction) -> NSMutableAttributedString { + 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() + return Self.markdownReplyParser.parse(decodedMessage).resolveLinkColor() } - private func makeChatFiles( + func makeChatFiles( from files: [[String: Any]], uploadingFilesIDs: [String], downloadingFilesIDs: [String], @@ -548,3 +557,5 @@ private extension ChatSender { ) } } + +private let lineSpacing: CGFloat = 1.15 From 8004cb5208ddd0e8a4fb5f4f7a43362d3e275280 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 18 Apr 2024 10:49:32 +0200 Subject: [PATCH 069/123] [trello.com/c/uxBZaznD] fix: remove temp files --- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8fd60f623..61262b787 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -775,6 +775,12 @@ final class ChatViewModel: NSObject { 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) } From 354f0b5bda89ee2ed129c99b9a813c4827ef899b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 19 Apr 2024 09:56:11 +0200 Subject: [PATCH 070/123] [trello.com/c/uxBZaznD] fix: download preview if auto is disabled --- .../Chat/ViewModel/ChatFileService.swift | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 62f78a0e2..c198c367a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -171,6 +171,8 @@ final class ChatFileService: ChatFileProtocol { recipientId: partnerAddress ) + var preview: UIImage? + if let previewUrl = file.previewUrl, let previewId = result.preview?.cid { try filesStorage.cacheFile( @@ -179,23 +181,16 @@ final class ChatFileService: ChatFileProtocol { ownerId: ownerId, recipientId: partnerAddress ) + + preview = filesStorage.getPreview( + for: previewId, + type: file.extenstion ?? .empty + ) } let oldId = file.url.absoluteString uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) - let previewID: String - if let id = result.preview?.cid { - previewID = id - } else { - previewID = result.file.cid - } - - let preview = filesStorage.getPreview( - for: previewID, - type: file.extenstion ?? "" - ) - let cached = filesStorage.isCached(result.file.cid) updateFileFields.send(( @@ -289,17 +284,31 @@ final class ChatFileService: ChatFileProtocol { recipientId: recipientId ) - let previewID: String - if let id = file.file.preview_id { - previewID = id - } else { - previewID = file.file.file_id - } + var preview: UIImage? - let preview = filesStorage.getPreview( - for: previewID, - type: file.file.file_type ?? "" - ) + if let previewId = file.file.preview_id, + let previewNonce = file.file.preview_nonce, + !filesStorage.isCached(previewId) { + let data = try await downloadFile( + id: previewId, + storage: file.storage, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + nonce: previewNonce + ) + + try filesStorage.cacheFile( + id: previewId, + data: data, + ownerId: ownerId, + recipientId: recipientId + ) + + preview = filesStorage.getPreview( + for: previewId, + type: file.file.file_type ?? .empty + ) + } let cached = filesStorage.isCached(file.file.file_id) From 06b4999b3d8f1240597c95ab74d32f4a907d39b2 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 19 Apr 2024 11:07:28 +0200 Subject: [PATCH 071/123] [trello.com/c/uxBZaznD] fix: download preview --- .../Chat/ViewModel/ChatFileService.swift | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index c198c367a..c7955bf6d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -287,22 +287,23 @@ final class ChatFileService: ChatFileProtocol { var preview: UIImage? if let previewId = file.file.preview_id, - let previewNonce = file.file.preview_nonce, - !filesStorage.isCached(previewId) { - let data = try await downloadFile( - id: previewId, - storage: file.storage, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: previewNonce - ) - - try filesStorage.cacheFile( - id: previewId, - data: data, - ownerId: ownerId, - recipientId: recipientId - ) + let previewNonce = file.file.preview_nonce { + if !filesStorage.isCached(previewId) { + let data = try await downloadFile( + id: previewId, + storage: file.storage, + senderPublicKey: chatroom?.partner?.publicKey ?? .empty, + recipientPrivateKey: keyPair.privateKey, + nonce: previewNonce + ) + + try filesStorage.cacheFile( + id: previewId, + data: data, + ownerId: ownerId, + recipientId: recipientId + ) + } preview = filesStorage.getPreview( for: previewId, From 09bd6324b1575ed5cc2b0adbb5ee2775504954aa Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 25 Apr 2024 15:11:40 +0300 Subject: [PATCH 072/123] [trello.com/c/uxBZaznD] feat: auto-download full media & new policy --- .../CoreData/Chatroom+CoreDataClass.swift | 15 + .../Chat/ViewModel/ChatFileService.swift | 272 +++++++++++------- .../Chat/ViewModel/ChatViewModel.swift | 16 +- .../StorageUsage/StorageUsageView.swift | 36 ++- .../StorageUsage/StorageUsageViewModel.swift | 90 +++++- .../FilesStorageProprietiesProtocol.swift | 6 +- .../FilesStorageProprietiesService.swift | 48 +++- .../Localization/de.lproj/Localizable.strings | 19 +- .../Localization/en.lproj/Localizable.strings | 19 +- .../Localization/ru.lproj/Localizable.strings | 19 +- .../Localization/zh.lproj/Localizable.strings | 19 +- .../Sources/CommonKit/Core/SecuredStore.swift | 1 + 12 files changed, 411 insertions(+), 149 deletions(-) diff --git a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift index e2225d0c4..8a0c07d45 100644 --- a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift +++ b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift @@ -47,6 +47,21 @@ public class Chatroom: NSManagedObject { return result?.checkAndReplaceSystemWallets() } + @MainActor func havePartnerName(addressBookService: AddressBookService) -> Bool { + guard let partner = partner else { return false } + + if let address = partner.address, + let name = addressBookService.getName(for: address) { + return true + } else if let title = title { + return true + } else if let name = partner.name { + return true + } + + return false + } + private let semaphore = DispatchSemaphore(value: 1) func updateLastTransaction() { diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index c7955bf6d..866f87069 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -37,11 +37,13 @@ protocol ChatFileProtocol { chatroom: Chatroom? ) async throws - func downloadPreviewIfNeeded( - messageId: String, + func autoDownload( file: ChatFile, isFromCurrentSender: Bool, - chatroom: Chatroom? + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy ) } @@ -258,10 +260,105 @@ final class ChatFileService: ChatFileProtocol { file: ChatFile, isFromCurrentSender: Bool, chatroom: Chatroom? + ) async throws { + try await downloadFile( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + shouldDownloadOriginalFile: true, + shouldDownloadPreviewFile: true + ) + } + + func autoDownload( + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy + ) { + guard !downloadingFilesIDsArray.contains(file.file.file_id), + !ignoreFilesIDsArray.contains(file.file.file_id) + else { + return + } + + Task { + 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 + } + + let shouldDownloadOriginalFile: Bool + switch fullMediaDownloadPolicy { + case .nobody: + shouldDownloadOriginalFile = false + case .everybody: + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) + ? true + : false + case .contacts: + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) + ? havePartnerName + : false + } + + guard shouldDownloadOriginalFile || shouldDownloadPreviewFile else { return } + + do { + try await downloadFile( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + shouldDownloadOriginalFile: shouldDownloadOriginalFile, + shouldDownloadPreviewFile: shouldDownloadPreviewFile + ) + } catch { + ignoreFilesIDsArray.append(file.file.file_id) + } + } + } +} + +private extension ChatFileService { + func addObservers() { + NotificationCenter.default + .publisher(for: .AdamantReachabilityMonitor.reachabilityChanged) + .receive(on: RunLoop.main) + .sink { [weak self] data in + let connection = data.userInfo?[AdamantUserInfoKey.ReachabilityMonitor.connection] as? Bool + + if connection == true { + self?.ignoreFilesIDsArray.removeAll() + } + } + .store(in: &subscriptions) + } +} + +private extension ChatFileService { + func downloadFile( + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom?, + shouldDownloadOriginalFile: Bool, + shouldDownloadPreviewFile: Bool ) async throws { guard let keyPair = accountService.keypair, let ownerId = accountService.account?.address, - let recipientId = chatroom?.partner?.address + let recipientId = chatroom?.partner?.address, + NetworkFileProtocolType(rawValue: file.storage) != nil, + (shouldDownloadOriginalFile || shouldDownloadPreviewFile) else { return } defer { @@ -269,37 +366,19 @@ final class ChatFileService: ChatFileProtocol { } downloadingFilesIDsArray.append(file.file.file_id) - let data = try await downloadFile( - id: file.file.file_id, - storage: file.storage, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: file.nonce - ) - - try filesStorage.cacheFile( - id: file.file.file_id, - data: data, - ownerId: ownerId, - recipientId: recipientId - ) - var preview: UIImage? if let previewId = file.file.preview_id, let previewNonce = file.file.preview_nonce { - if !filesStorage.isCached(previewId) { - let data = try await downloadFile( + + if shouldDownloadPreviewFile, + !filesStorage.isCached(previewId) { + try await downloadAndCacheFile( id: previewId, + nonce: previewNonce, storage: file.storage, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: previewNonce - ) - - try filesStorage.cacheFile( - id: previewId, - data: data, + publicKey: chatroom?.partner?.publicKey ?? .empty, + privateKey: keyPair.privateKey, ownerId: ownerId, recipientId: recipientId ) @@ -309,63 +388,8 @@ final class ChatFileService: ChatFileProtocol { for: previewId, type: file.file.file_type ?? .empty ) - } - - let cached = filesStorage.isCached(file.file.file_id) - - updateFileFields.send(( - id: file.file.file_id, - newId: nil, - preview: preview, - cached: cached - )) - } - - func downloadPreviewIfNeeded( - messageId: String, - file: ChatFile, - isFromCurrentSender: Bool, - chatroom: Chatroom? - ) { - guard let keyPair = accountService.keypair, - !downloadingFilesIDsArray.contains(file.file.file_id), - !ignoreFilesIDsArray.contains(file.file.file_id), - let previewId = file.file.preview_id, - let previewNonce = file.file.preview_nonce, - !filesStorage.isCached(previewId), - let ownerId = accountService.account?.address, - let recipientId = chatroom?.partner?.address, - NetworkFileProtocolType(rawValue: file.storage) != nil - else { return } - - downloadingFilesIDsArray.append(file.file.file_id) - - Task { - defer { - downloadingFilesIDsArray.removeAll(where: { $0 == file.file.file_id }) - } - do { - let data = try await downloadFile( - id: previewId, - storage: file.storage, - senderPublicKey: chatroom?.partner?.publicKey ?? .empty, - recipientPrivateKey: keyPair.privateKey, - nonce: previewNonce - ) - - try filesStorage.cacheFile( - id: previewId, - data: data, - ownerId: ownerId, - recipientId: recipientId - ) - - let preview = filesStorage.getPreview( - for: previewId, - type: file.file.file_type ?? .empty - ) - + if shouldDownloadPreviewFile { let cached = filesStorage.isCached(file.file.file_id) updateFileFields.send(( @@ -374,30 +398,68 @@ final class ChatFileService: ChatFileProtocol { preview: preview, cached: cached )) - } catch { - ignoreFilesIDsArray.append(file.file.file_id) } } + + if shouldDownloadOriginalFile, + !filesStorage.isCached(file.file.file_id) { + try await downloadAndCacheFile( + id: file.file.file_id, + nonce: file.nonce, + storage: file.storage, + publicKey: chatroom?.partner?.publicKey ?? .empty, + privateKey: keyPair.privateKey, + ownerId: ownerId, + recipientId: recipientId + ) + + let cached = filesStorage.isCached(file.file.file_id) + + updateFileFields.send(( + id: file.file.file_id, + newId: nil, + preview: preview, + cached: cached + )) + } } -} - -private extension ChatFileService { - func addObservers() { - NotificationCenter.default - .publisher(for: .AdamantReachabilityMonitor.reachabilityChanged) - .receive(on: RunLoop.main) - .sink { [weak self] data in - let connection = data.userInfo?[AdamantUserInfoKey.ReachabilityMonitor.connection] as? Bool - - if connection == true { - self?.ignoreFilesIDsArray.removeAll() - } - } - .store(in: &subscriptions) + + func downloadAndCacheFile( + id: String, + nonce: String, + storage: String, + publicKey: String, + privateKey: String, + ownerId: String, + recipientId: String + ) async throws { + let data = try await downloadFile( + id: id, + storage: storage, + senderPublicKey: publicKey, + recipientPrivateKey: privateKey, + nonce: nonce + ) + + try filesStorage.cacheFile( + id: id, + data: data, + ownerId: ownerId, + recipientId: recipientId + ) } -} - -private extension ChatFileService { + + func needsPreviewDownload(file: ChatFile) -> Bool { + if let previewId = file.file.preview_id, + file.file.preview_nonce != nil, + !ignoreFilesIDsArray.contains(previewId), + !filesStorage.isCached(previewId) { + return true + } + + return false + } + func uploadFileToServer( file: FileResult, recipientPublicKey: String, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 61262b787..da2c0f7c2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -66,7 +66,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] = [] @@ -368,6 +369,7 @@ final class ChatViewModel: NSObject { }.stored(in: tasksStorage) partnerName = newName + havePartnerName = !newName.isEmpty } func saveChatOffset(_ offset: CGFloat?) { @@ -732,14 +734,17 @@ final class ChatViewModel: NSObject { guard let message = message, tx?.statusEnum == .delivered || (message.status != .failed && message.status != .pending), - filesStorageProprieties.enabledAutoDownloadPreview() + (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || + filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) else { return } - chatFileService.downloadPreviewIfNeeded( - messageId: messageId, + chatFileService.autoDownload( file: file, isFromCurrentSender: isFromCurrentSender, - chatroom: chatroom + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), + fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy() ) } @@ -1057,6 +1062,7 @@ private extension ChatViewModel { } partnerName = chatroom?.getName(addressBookService: addressBookService) + havePartnerName = chatroom?.havePartnerName(addressBookService: addressBookService) ?? false guard let avatarName = chatroom?.partner?.avatar, let avatar = UIImage.asset(named: avatarName) diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index 2d07b1cb9..9dd6cc8e5 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -30,10 +30,13 @@ struct StorageUsageView: View { Section( content: { - previewContent + autoDownloadContent(for: .preview) + .listRowBackground(Color(uiColor: .adamant.cellColor)) + autoDownloadContent(for: .fullMedia) .listRowBackground(Color(uiColor: .adamant.cellColor)) }, - footer: { Text(verbatim: previewDescription) } + header: { Text(verbatim: autDownloadHeader) }, + footer: { Text(verbatim: autDownloadDescription) } ) } .listStyle(.insetGrouped) @@ -78,17 +81,28 @@ private extension StorageUsageView { } } - var previewContent: some View { - Toggle(isOn: $viewModel.autoDownloadPreview) { + func autoDownloadContent( + for type: StorageUsageViewModel.AutoDownloadMediaType + ) -> some View { + Button { + viewModel.presentPicker(for: type) + } label: { HStack { Image(uiImage: previewImage) - Text(previewTitle) - } - .onChange(of: viewModel.autoDownloadPreview) { _ in - viewModel.togglePreviewContent() + Text(type.title) + + Spacer() + + switch type { + case .preview: + Text(viewModel.autoDownloadPreview.title) + case .fullMedia: + Text(viewModel.autoDownloadFullMedia.title) + } + + NavigationLink(destination: { EmptyView() }, label: { EmptyView() }).fixedSize() } } - .tint(.init(uiColor: .adamant.active)) } } @@ -97,5 +111,5 @@ private var storageDescription: String { .localized("StorageUsage.Description") private var storageTitle: String { .localized("StorageUsage.Title") } private var clearTitle: String { .localized("StorageUsage.Clear.Title") } private let previewImage: UIImage = .asset(named: "row_preview")! -private var previewTitle: String { .localized("Storage.AutoDownloadPreview.Title") } -private var previewDescription: String { .localized("Storage.AutoDownloadPreview.Description") } +private var autDownloadHeader: String { .localized("Storage.AutoDownloadPreview.Header") } +private var autDownloadDescription: String { .localized("Storage.AutoDownloadPreview.Description") } diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index ece0066a9..9edd50f6c 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -17,6 +17,23 @@ public extension Notification.Name { } } +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") + } + } +} + @MainActor final class StorageUsageViewModel: ObservableObject { private let filesStorage: FilesStorageProtocol @@ -24,7 +41,22 @@ final class StorageUsageViewModel: ObservableObject { private let filesStorageProprieties: FilesStorageProprietiesProtocol @Published var storageUsedDescription: String? - @Published var autoDownloadPreview: Bool = true + @Published var autoDownloadPreview: DownloadPolicy = .everybody + @Published var autoDownloadFullMedia: DownloadPolicy = .everybody + + 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, @@ -38,7 +70,8 @@ final class StorageUsageViewModel: ObservableObject { } func loadData() { - autoDownloadPreview = filesStorageProprieties.enabledAutoDownloadPreview() + autoDownloadPreview = filesStorageProprieties.autoDownloadPreviewPolicy() + autoDownloadFullMedia = filesStorageProprieties.autoDownloadFullMediaPolicy() updateCacheSize() } @@ -60,9 +93,42 @@ final class StorageUsageViewModel: ObservableObject { } } - func togglePreviewContent() { - filesStorageProprieties.setEnabledAutoDownloadPreview(autoDownloadPreview) - NotificationCenter.default.post(name: .Storage.storageProprietiesUpdated, object: nil) + 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 + ) } } @@ -84,3 +150,17 @@ private extension StorageUsageViewModel { 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/ServiceProtocols/FilesStorageProprietiesProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift index 970dce439..2ed8f86c8 100644 --- a/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift @@ -9,6 +9,8 @@ import Foundation protocol FilesStorageProprietiesProtocol { - func enabledAutoDownloadPreview() -> Bool - func setEnabledAutoDownloadPreview(_ value: Bool) + func autoDownloadPreviewPolicy() -> DownloadPolicy + func setAutoDownloadPreview(_ value: DownloadPolicy) + func autoDownloadFullMediaPolicy() -> DownloadPolicy + func setAutoDownloadFullMedia(_ value: DownloadPolicy) } diff --git a/Adamant/Services/FilesStorageProprietiesService.swift b/Adamant/Services/FilesStorageProprietiesService.swift index ad3912ae9..6aa12a084 100644 --- a/Adamant/Services/FilesStorageProprietiesService.swift +++ b/Adamant/Services/FilesStorageProprietiesService.swift @@ -18,8 +18,9 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { // MARK: Proprieties @Atomic private var notificationsSet: Set = [] - private var isEnabledAutoDownloadPreview: Bool = true - + private var autoDownloadPreviewState: DownloadPolicy = .everybody + private var autoDownloadFullMediaState: DownloadPolicy = .everybody + // MARK: Lifecycle init(securedStore: SecuredStore) { @@ -43,31 +44,52 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { // MARK: Notification actions private func userLoggedIn() { - isEnabledAutoDownloadPreview = getEnabledAutoDownloadPreview() + autoDownloadPreviewState = getAutoDownloadPreview() + autoDownloadFullMediaState = getAutoDownloadFullMedia() } private func userLoggedOut() { - setEnabledAutoDownloadPreview(true) + setAutoDownloadPreview(.everybody) + setAutoDownloadFullMedia(.everybody) } // MARK: Update data - func enabledAutoDownloadPreview() -> Bool { - isEnabledAutoDownloadPreview + func autoDownloadPreviewPolicy() -> DownloadPolicy { + autoDownloadPreviewState } - func getEnabledAutoDownloadPreview() -> Bool { - guard let result: Bool = securedStore.get( + func getAutoDownloadPreview() -> DownloadPolicy { + guard let result: String = securedStore.get( StoreKey.storage.autoDownloadPreviewEnabled ) else { - return true + return .everybody + } + + return DownloadPolicy(rawValue: result) ?? .everybody + } + + func setAutoDownloadPreview(_ value: DownloadPolicy) { + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadPreviewEnabled) + autoDownloadPreviewState = value + } + + func autoDownloadFullMediaPolicy() -> DownloadPolicy { + autoDownloadFullMediaState + } + + func getAutoDownloadFullMedia() -> DownloadPolicy { + guard let result: String = securedStore.get( + StoreKey.storage.autoDownloadFullMediaEnabled + ) else { + return .everybody } - return result + return DownloadPolicy(rawValue: result) ?? .everybody } - func setEnabledAutoDownloadPreview(_ value: Bool) { - securedStore.set(value, for: StoreKey.storage.autoDownloadPreviewEnabled) - isEnabledAutoDownloadPreview = value + func setAutoDownloadFullMedia(_ value: DownloadPolicy) { + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadFullMediaEnabled) + autoDownloadFullMediaState = value } } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 219c7b572..3134a015d 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -734,10 +734,25 @@ "NodesEditor.FailedToBuildURL" = "Invalid host"; /* Storage: Auto download preview */ -"Storage.AutoDownloadPreview.Title" = "Automatisches Herunterladen von Vorschaubildern"; +"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.Description" = "Automatisches Herunterladen von Vorschauen für Fotos und Videos: Wenn diese Option aktiviert ist, kann die Anwendung automatisch Vorschauen von Fotos und Videos herunterladen. Dies erleichtert das schnelle Anzeigen von Mediendateien, ohne auf den vollständigen Download der Datei warten zu müssen"; +"Storage.AutoDownloadPreview.Header" = "Automatischer Download von Medien"; /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Clear entire cache"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index d20138bb8..3d72a141e 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -719,10 +719,25 @@ "NodesEditor.FailedToBuildURL" = "Invalid host"; /* Storage: Auto download preview */ -"Storage.AutoDownloadPreview.Title" = "Auto download previews"; +"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" = "Automatically download previews for photo and video: When enabled, this option allows the application to automatically download previews of photos and videos. This helps in quickly previewing media content without having to wait for the full file to download"; +"Storage.AutoDownloadPreview.Description" = "To create a contact, give ADM address a name"; /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Clear entire cache"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index fd854d5d0..2e2e1acc5 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -719,10 +719,25 @@ "NodesEditor.FailedToBuildURL" = "Некорректный адрес хоста"; /* Storage: Auto download preview */ -"Storage.AutoDownloadPreview.Title" = "Автоматически загружать превью"; +"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" = "Автоматическая загрузка превью для фотографий и видео: При включении этой опции приложение может автоматически загружать превью фотографий и видео. Это помогает быстро просматривать медиа-контент без необходимости ожидания полной загрузки файла"; +"Storage.AutoDownloadPreview.Description" = "Чтобы сохранить ADM-адрес в контакты, дайте ему имя"; /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Очистите весь кэш"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 9049d7e57..45334c7a1 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -677,10 +677,25 @@ "NodeList.DefaultNodesLoaded" = "已加载默认节点列表"; /* Storage: Auto download preview */ -"Storage.AutoDownloadPreview.Title" = "自动下载预览"; +"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.Description" = "自动下载照片和视频预览:当启用此选项时,应用程序可以自动下载照片和视频的预览。这有助于快速预览媒体内容,而无需等待完整文件下载"; +"Storage.AutoDownloadPreview.Header" = "自动下载媒体文件"; /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "清除整个缓存"; diff --git a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift index 8dddb578e..67705620a 100644 --- a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift +++ b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift @@ -59,6 +59,7 @@ public extension StoreKey { enum storage { public static let autoDownloadPreviewEnabled = "autoDownloadPreviewEnabled" + public static let autoDownloadFullMediaEnabled = "autoDownloadFullMediaEnabled" } } From 565a8a350582abe25ef3097ca7aeceb841a45ad9 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 10:37:45 +0300 Subject: [PATCH 073/123] [trello.com/c/uxBZaznD] feat: add background color for spinner --- .../Content/Views/ChatFileContainerView/ChatFileView.swift | 4 +++- .../Content/Views/MediaContainerView/MediaContentView.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index ccd3e5585..b40767c37 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -18,6 +18,7 @@ class ChatFileView: UIView { let view = UIActivityIndicatorView(style: .medium) view.isHidden = true view.color = .white + view.backgroundColor = .darkGray.withAlphaComponent(0.45) return view }() @@ -112,6 +113,7 @@ private extension ChatFileView { addSubview(spinner) spinner.snp.makeConstraints { make in make.center.equalTo(iconImageView) + make.size.equalTo(imageSize / 2) } addSubview(downloadImageView) @@ -142,7 +144,7 @@ private extension ChatFileView { videoIconIV.addShadow() downloadImageView.addShadow() - spinner.addShadow() + spinner.addShadow(shadowColor: .white) } func update() { 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 index 5e982d085..28f1ca8ad 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -19,6 +19,7 @@ final class MediaContentView: UIView { let view = UIActivityIndicatorView(style: .medium) view.isHidden = true view.color = .white + view.backgroundColor = .darkGray.withAlphaComponent(0.45) return view }() @@ -71,6 +72,7 @@ private extension MediaContentView { addSubview(spinner) spinner.snp.makeConstraints { make in make.center.equalTo(imageView) + make.size.equalTo(imageSize / 2) } addSubview(downloadImageView) @@ -96,7 +98,7 @@ private extension MediaContentView { videoIconIV.addShadow() downloadImageView.addShadow() - spinner.addShadow() + spinner.addShadow(shadowColor: .white) } func update() { From 0bf05ab7e7bc4a3fd5cf5c7c684b0977dd62a631 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 10:38:23 +0300 Subject: [PATCH 074/123] [trello.com/c/uxBZaznD] fix: size in bytes --- .../Content/Views/ChatFileContainerView/ChatFileView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index b40767c37..ff1ced838 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -192,7 +192,7 @@ private extension ChatFileView { func formatSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useMB, .useKB] + formatter.allowedUnits = [.useGB, .useMB, .useKB, .useBytes] formatter.countStyle = .file return formatter.string(fromByteCount: bytes) From 74bb4a70402b664fc7588f3620eb16753a311d87 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 10:45:37 +0300 Subject: [PATCH 075/123] [trello.com/c/uxBZaznD] fix: auto-download only media --- Adamant/Modules/Chat/ViewModel/ChatFileService.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 866f87069..d8d3e11b1 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -299,16 +299,17 @@ final class ChatFileService: ChatFileProtocol { : false } + let isMedia = file.fileType == .image || file.fileType == .video let shouldDownloadOriginalFile: Bool switch fullMediaDownloadPolicy { case .nobody: shouldDownloadOriginalFile = false case .everybody: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) && isMedia ? true : false case .contacts: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) && isMedia ? havePartnerName : false } From ed8366e93625c35e2abeb486402248bdc7999c7c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 10:58:22 +0300 Subject: [PATCH 076/123] [trello.com/c/uxBZaznD] feat: replace checkmark icon --- .../FilesToolbarCollectionViewCell.swift | 9 +----- .../checkMarkIcon.imageset/Contents.json | 26 ++++++++++++++++++ .../checkMarkIcon.imageset/checkMarkIcon.png | Bin 0 -> 1673 bytes .../checkMarkIcon@2x.png | Bin 0 -> 3839 bytes .../checkMarkIcon@3x.png | Bin 0 -> 6519 bytes 5 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/checkMarkIcon.imageset/checkMarkIcon@3x.png diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index 02406c2d5..e2cc85531 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -53,14 +53,7 @@ final class FilesToolbarCollectionViewCell: UICollectionViewCell { private lazy var removeBtn: UIButton = { let btn = UIButton() - let config = UIImage.SymbolConfiguration(pointSize: 30) - btn.setImage( - UIImage( - systemName: "checkmark.circle.fill", - withConfiguration: config - )?.withTintColor(.adamant.active), - for: .normal - ) + btn.setImage(.asset(named: "checkMarkIcon"), for: .normal) btn.tintColor = .adamant.active btn.addTarget(self, action: #selector(didTapRemoveBtn), for: .touchUpInside) return btn 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 0000000000000000000000000000000000000000..cd1504001ffeadc322cd9f06a21caaca77d656c2 GIT binary patch literal 1673 zcmV;426p+0P)2mv-=Dg*=rVlw;)1~YU*6j2yBG9yC~6qyplgg+Dr8WbX% zG9(ajC=vzD5Joi7Y)V3e@iA8X+E&Kc_s079?)CJ^=H5PPU%QR|{%G$zefPf8`_4W0 zoO2OEB&nz>(%T%F1_EXRvJ@fsXhe*oZzCde0`L|DX5*Y+M22UhoLFId$=V7bfDi=J z_Sj8TLbs6V*kU1YE{P%n?Mn}I3xr|>zJHh@;k$E7@g`_QlhTum>Y^H2r7X_nl{9;4 z0D?i#AV&~3j_iGjONZ?_mimBu26Puq=dk>`1&GpCdfnrLd_n${xFER5yPgw&3=l;C z_xmSt@3jOW>t80DT}PKj7&HXgKW>jUS-07qMZY%hSLuAjPgQ z2_LeruvYc_t=H-wx3j_GH5iNGHY)6cR^tTO3x^W-wk7oZt(Vg6CR%U;ra@RBW(kjL zO;4%HJcj2tK1MF>Fbt}f(28*C7%sMF#~J9$^2DC6RIQb2avq1b54 z-dy0#wY_h2JLu270lc+NC61V!4yK1!L9mc(j$sL3hQQKgt3$n1Q#cs{xU-mw-8A0}Kd^Rgz76 zD?lrVif!tX)j=U|y+PjOPnAp;I|8B?ZdUpX1dQ`b#b$Eyhlyq)C2f(VVd!^rP;90mMZvpn_GeO?IFVry)p;vlc^C z<|eg}{)8!g1w_tS3Ij}W#q$Ymw?b34&sST6ATizl92lq0Ctb-?A4 ze%zQVIR5~`6ncqSwb}8?Om}4~BEnrrp1Tsllk-xmqia*>Qu_MR#GAB-t*53F!V3p7 zk>_(5odQ)}SeN4~h~Rbz8z{lzxUDuj;dpmxuVfY__fE3(QxFzlX`G(-n#8`VrUea2 zGUq1+V&GZlK!aeDT+aLYs#Et_HmF%?t)`QxC+PLq;Pz07GA`(^u@}DacLQL8r6Pk&EatuTyO;HPmxN`Y5MNWUD0N zzq03GN5DNH2!?O_%`9R@&!Yrl8I9m8sRbMv(F0u^M2TRmP%pcV+jB++tZcV2LL=pq z8__K?rqb!_6b@`6WuD`ai&%1(209t@*=dsi@82lcWeS1IbRCr!s@KkPKnVN;?m$7y TG4SE$00000NkvXXu0mjfMa~Sf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..21c924d09e861c037f3e457a12898fd7df49a4bd GIT binary patch literal 3839 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 BT;l)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..02c355000d2ba47371df1e8024c7bf751fbf7924 GIT binary patch literal 6519 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 literal 0 HcmV?d00001 From 8e35fd9c80ed1527f7a572ea4ba08987770eff2c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 12:32:38 +0300 Subject: [PATCH 077/123] [trello.com/c/uxBZaznD] feat: new style for short file message description --- .../ChatMediaContainerView+Model.swift | 17 ++++++- .../ChatsList/ChatListViewController.swift | 17 +------ .../AdamantRichTransactionReplyService.swift | 19 ++----- .../Helpers/FilePresentationHelper.swift | 49 +++++++++++++++++++ .../NotificationService.swift | 17 +------ 5 files changed, 72 insertions(+), 47 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index 0bc0f20ca..a1bebfd14 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -7,6 +7,7 @@ // import Foundation +import CommonKit extension ChatMediaContainerView { struct Model: ChatReusableViewModelProtocol, MessageModel { @@ -27,7 +28,21 @@ extension ChatMediaContainerView { ) func makeReplyContent() -> NSAttributedString { - ChatMessageFactory.markdownParser.parse("[\(content.fileModel.files.count) File(s)]") + 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/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 2147dc017..18d4d24d0 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -966,7 +966,7 @@ extension ChatListViewController { if richMessage.additionalType == .reply, let content = richMessage.richContent, richMessage.isFileReply() { - let text = getRawFilePresentation(content) + let text = FilePresentationHelper.getFilePresentationText(content) return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: text) } @@ -976,7 +976,7 @@ extension ChatListViewController { ? "\(String.adamant.chatList.sentMessagePrefix)" : "" - let fileText = getRawFilePresentation(content) + let fileText = FilePresentationHelper.getFilePresentationText(content) let attributesText = markdownParser.parse(prefix + fileText).resolveLinkColor() @@ -1005,19 +1005,6 @@ extension ChatListViewController { } } - private func getRawFilePresentation(_ richContent: [String: Any]) -> String { - let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent - - let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] - - let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty - let comment = !rawComment.isEmpty - ? ": \(rawComment)" - : "" - - return "[\(files.count) file(s)]\(comment)" - } - private func getRawReplyPresentation(isOutgoing: Bool, text: String) -> NSMutableAttributedString { let prefix = isOutgoing ? "\(String.adamant.chatList.sentMessagePrefix)" diff --git a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift index 8941a08fc..082cc0c48 100644 --- a/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift +++ b/Adamant/Services/RichTransactionReplyService/AdamantRichTransactionReplyService.swift @@ -213,14 +213,14 @@ private extension AdamantRichTransactionReplyService { let richContent = RichMessageTools.richContent(from: data), let replyMessage = richContent[RichContentKeys.reply.replyMessage] as? [String: Any], replyMessage[RichContentKeys.file.files] is [[String: Any]] { - message = getRawFilePresentation(richContent) + 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 = getRawFilePresentation(richContent) + message = FilePresentationHelper.getFilePresentationText(richContent) break } @@ -275,7 +275,7 @@ private extension AdamantRichTransactionReplyService { if let richContent = trs.richContent, let _: [[String: Any]] = trs.getRichValue(for: RichContentKeys.file.files) { - message = getRawFilePresentation(richContent) + message = FilePresentationHelper.getFilePresentationText(richContent) break } @@ -287,19 +287,6 @@ private extension AdamantRichTransactionReplyService { return message } - func getRawFilePresentation(_ richContent: [String: Any]) -> String { - let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent - - let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] - - let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty - let comment = !rawComment.isEmpty - ? ": \(rawComment)" - : "" - - return "[\(files.count) file(s)]\(comment)" - } - func setReplyMessage( for transaction: RichMessageTransaction, message: String diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift new file mode 100644 index 000000000..dd4ad82d1 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -0,0 +1,49 @@ +// +// 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 mediaText = mediaFilesCount > 0 ? "📸\(mediaFilesCount)" : .empty + let fileText = otherFilesCount > 0 ? "📄\(otherFilesCount)" : .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 fileTypeRaw = file[RichContentKeys.file.file_type] as? String ?? .empty + let fileType = FileType(raw: fileTypeRaw) ?? .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/NotificationServiceExtension/NotificationService.swift b/NotificationServiceExtension/NotificationService.swift index 87e80328e..13c560ef3 100644 --- a/NotificationServiceExtension/NotificationService.swift +++ b/NotificationServiceExtension/NotificationService.swift @@ -239,7 +239,7 @@ class NotificationService: UNNotificationServiceExtension { let replyMessage = richContent[RichContentKeys.reply.replyMessage] as? [String: Any], replyMessage[RichContentKeys.file.files] is [[String: Any]] { - let text = getRawFilePresentation(richContent) + let text = FilePresentationHelper.getFilePresentationText(richContent) content = NotificationContent( title: partnerName ?? partnerAddress, subtitle: nil, @@ -254,7 +254,7 @@ class NotificationService: UNNotificationServiceExtension { let richContent = RichMessageTools.richContent(from: data), richContent[RichContentKeys.file.files] is [[String: Any]] { - let text = getRawFilePresentation(richContent) + let text = FilePresentationHelper.getFilePresentationText(richContent) content = NotificationContent( title: partnerName ?? partnerAddress, subtitle: nil, @@ -312,19 +312,6 @@ class NotificationService: UNNotificationServiceExtension { } } - private func getRawFilePresentation(_ richContent: [String: Any]) -> String { - let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent - - let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] - - let rawComment: String = (content[RichContentKeys.file.comment] as? String) ?? .empty - let comment = !rawComment.isEmpty - ? ": \(rawComment)" - : "" - - return "[\(files.count) file(s)]\(comment)" - } - private func handleAdamantTransfer( notificationContent: UNMutableNotificationContent, partnerAddress address: String, From cf09a424c627d3dcca5d6594b8e378a5c5b33ab6 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Apr 2024 13:54:01 +0300 Subject: [PATCH 078/123] [trello.com/c/uxBZaznD] fix: replaced translation text & fix storage screen design --- .../StorageUsage/StorageUsageView.swift | 84 ++++++++++++------- .../StorageUsage/StorageUsageViewModel.swift | 2 +- .../Localization/de.lproj/Localizable.strings | 18 ++-- .../Localization/en.lproj/Localizable.strings | 20 +++-- .../Localization/ru.lproj/Localizable.strings | 20 +++-- .../Localization/zh.lproj/Localizable.strings | 18 ++-- 6 files changed, 103 insertions(+), 59 deletions(-) diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index 9dd6cc8e5..268f8a061 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -20,43 +20,22 @@ struct StorageUsageView: View { var body: some View { VStack { List { - Section( - content: { - content - .listRowBackground(Color(uiColor: .adamant.cellColor)) - }, - footer: { Text(verbatim: storageDescription) } - ) - - 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) } - ) + storageSection + autoDownloadSection } .listStyle(.insetGrouped) .navigationTitle(storageTitle) Spacer() - HStack { - Button { - viewModel.clearStorage() - } label: { - Text(clearTitle) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Color(uiColor: UIColor.adamant.active)) - .clipShape(.rect(cornerRadius: 8.0)) - .padding() - } + makeClearButton() + } + .alert( + clearPopupTitle, + isPresented: $viewModel.isRemoveAlertShown + ) { + Button(String.adamant.alert.cancel, role: .cancel) {} + Button(clearTitle) { viewModel.clearStorage() } } .onAppear(perform: { viewModel.loadData() @@ -67,10 +46,33 @@ struct StorageUsageView: View { } 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 content: some View { HStack { Image(uiImage: storageImage) - Text(verbatim: storageTitle) + Text(verbatim: storageUsedTitle) Spacer() if let storage = viewModel.storageUsedDescription { Text(storage) @@ -104,11 +106,29 @@ private extension StorageUsageView { } } } + + 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") } diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 9edd50f6c..8ae643592 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -43,6 +43,7 @@ final class StorageUsageViewModel: ObservableObject { @Published var storageUsedDescription: String? @Published var autoDownloadPreview: DownloadPolicy = .everybody @Published var autoDownloadFullMedia: DownloadPolicy = .everybody + @Published var isRemoveAlertShown: Bool = false enum AutoDownloadMediaType { case preview @@ -66,7 +67,6 @@ final class StorageUsageViewModel: ObservableObject { self.filesStorage = filesStorage self.dialogService = dialogService self.filesStorageProprieties = filesStorageProprieties - } func loadData() { diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 3134a015d..402d9a86a 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -754,14 +754,20 @@ /* Storage: Auto download preview */ "Storage.AutoDownloadPreview.Header" = "Automatischer Download von Medien"; +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "Sind Sie sicher, dass Sie die Daten aus dem Speicher löschen möchten?"; + /* Storage usage: Clear Title */ -"StorageUsage.Clear.Title" = "Clear entire cache"; +"StorageUsage.Clear.Title" = "Klarer Speicher"; /* Storage usage: Title */ -"StorageUsage.Title" = "Gesamten Cache löschen"; +"StorageUsage.Title" = "Speicherung und Daten"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Speicherung"; /* Storage usage: Description */ -"StorageUsage.Description" = "Dieses Feld zeigt die Gesamtmenge an Platz auf dem Gerät, die von Daten-Dateien belegt wird, die mit der Anwendung verbunden sind, wie Bilder, Videos, Audiodateien und Dokumente"; +"StorageUsage.Description" = "Gesamtgröße der Dateien und Bilder im sicheren Speicher der App"; /* 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."; @@ -911,13 +917,13 @@ "Shared.SaveToPhotolibrary" = "Im Fotoalbum speichern"; /* Shared alert 'Send tokens' */ -"Shared.SendTokens" = "Tokens senden"; +"Shared.SendTokens" = "💸 Tokens senden"; /* Shared alert 'Upload file' */ -"Shared.UploadFile" = "Datei hochladen"; +"Shared.UploadFile" = "📄 Datei hochladen"; /* Shared alert 'Upload Media' */ -"Shared.UploadMedia" = "Medien hochladen"; +"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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 3d72a141e..6ad447828 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -740,13 +740,19 @@ "Storage.AutoDownloadPreview.Description" = "To create a contact, give ADM address a name"; /* Storage usage: Clear Title */ -"StorageUsage.Clear.Title" = "Clear entire cache"; +"StorageUsage.Clear.Title" = "Clear storage"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "Are you sure you want to delete the data from storage?"; /* Storage usage: Title */ -"StorageUsage.Title" = "Storage Usage"; +"StorageUsage.Title" = "Storage and data"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Storage"; /* Storage usage: Description */ -"StorageUsage.Description" = "This field displays the total amount of space occupied on the device by data files associated with the application, such as images, videos, audio files, and documents"; +"StorageUsage.Description" = "Total file and image size in the app's secure storage"; /* 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"; @@ -896,13 +902,13 @@ "Shared.SaveToPhotolibrary" = "Save to Photos"; /* Shared alert 'Send tokens' */ -"Shared.SendTokens" = "Send tokens"; +"Shared.SendTokens" = "💸 Transfer crypto"; /* Shared alert 'Upload file' */ -"Shared.UploadFile" = "Upload file"; +"Shared.UploadFile" = "📄 Send file"; /* Shared alert 'Upload Media' */ -"Shared.UploadMedia" = "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"; @@ -1256,7 +1262,7 @@ "FileValidationError.TooManyFiles" = "Too many files. Maximum allowed: %lld"; /* File validation error 'File size exceeds limit' */ -"FileValidationError.FileSizeExceedsLimit" = "File size exceeds the limit. Maximum allowed: %lld MB"; +"FileValidationError.FileSizeExceedsLimit" = "Send files up to %lld MB"; /* File validation error 'File not found' */ "FileValidationError.FileNotFound" = "File not found"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 2e2e1acc5..cb094917f 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -740,13 +740,19 @@ "Storage.AutoDownloadPreview.Description" = "Чтобы сохранить ADM-адрес в контакты, дайте ему имя"; /* Storage usage: Clear Title */ -"StorageUsage.Clear.Title" = "Очистите весь кэш"; +"StorageUsage.Clear.Title" = "Очистить хранилище"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "Вы уверены, что хотите удалить данные из хранилища ?"; /* Storage usage: Title */ -"StorageUsage.Title" = "Использование хранилища"; +"StorageUsage.Title" = "Память и данные"; + +/* Storage used: Title */ +"StorageUsed.Title" = "Хранилище"; /* Storage usage: Description */ -"StorageUsage.Description" = "В этом поле отображается общий объем пространства, занимаемый на устройстве файлами, связанными с приложением, такими как изображения, видео, аудиофайлы и документы"; +"StorageUsage.Description" = "Общий объем файлов и изображений в защищенном хранилище приложения"; /* 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"; @@ -893,13 +899,13 @@ "Shared.SaveToPhotolibrary" = "Сохранить в Фото"; /* Shared alert 'Send tokens' */ -"Shared.SendTokens" = "Отправить токены"; +"Shared.SendTokens" = "💸 Перевести крипту"; /* Shared alert 'Upload file' */ -"Shared.UploadFile" = "Отправить файл"; +"Shared.UploadFile" = "📄 Отправить файл"; /* Shared alert 'Upload Media' */ -"Shared.UploadMedia" = "Отправить медиа"; +"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" = "Настройки"; @@ -1253,7 +1259,7 @@ "FileValidationError.TooManyFiles" = "Превышено максимально допустимое количество файлов (%lld)"; /* File validation error 'File size exceeds limit' */ -"FileValidationError.FileSizeExceedsLimit" = "Превышен максимально допустимый размер файла (%lld МБ)"; +"FileValidationError.FileSizeExceedsLimit" = "Превышен размер файла. Отправляйте файлы до %lld МБ"; /* File validation error 'File not found' */ "FileValidationError.FileNotFound" = "Файл не найден"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 45334c7a1..bd767a781 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -698,13 +698,19 @@ "Storage.AutoDownloadPreview.Header" = "自动下载媒体文件"; /* Storage usage: Clear Title */ -"StorageUsage.Clear.Title" = "清除整个缓存"; +"StorageUsage.Clear.Title" = "清晰的存储"; + +/* Storage usage: Clear Popup Title */ +"StorageUsage.Clear.Popup.Title" = "您确定要从存储中删除数据吗?"; /* Storage usage: Title */ -"StorageUsage.Title" = "存储使用"; +"StorageUsage.Title" = "存储和数据"; + +/* Storage used: Title */ +"StorageUsed.Title" = "存储"; /* Storage usage: Description */ -"StorageUsage.Description" = "该字段显示与应用程序关联的数据文件(如图像、视频、音频文件和文档)在设备上占用的总空间量"; +"StorageUsage.Description" = "应用程序安全存储中的文件和图像总大小"; /* CoinsNodesList: Title */ "CoinsNodesList.Title" = "Coin和服务节点列表"; @@ -896,13 +902,13 @@ "Shared.SaveToPhotolibrary" = "保存到照片"; /* Shared alert 'Send tokens' */ -"Shared.SendTokens" = "发送代币"; +"Shared.SendTokens" = "💸 发送代币"; /* Shared alert 'Upload file' */ -"Shared.UploadFile" = "上传文件"; +"Shared.UploadFile" = "📄 上传文件"; /* Shared alert 'Upload Media' */ -"Shared.UploadMedia" = "上传媒体"; +"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" = "设置"; From 2b87b111840ad3731869a0035897d82fcefdda54 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 18:02:52 +0300 Subject: [PATCH 079/123] [trello.com/c/uxBZaznD] feat: new rich message structure --- .../ChatFileContainerView/ChatFileView.swift | 8 +- .../MediaContainerView.swift | 8 +- .../Chat/ViewModel/ChatFileService.swift | 92 ++++++----- .../Chat/ViewModel/ChatMessageFactory.swift | 9 +- .../Chat/ViewModel/ChatViewModel.swift | 24 +-- .../Helpers/FilePresentationHelper.swift | 2 +- .../CommonKit/Models/RichMessage.swift | 156 ++++++++++++------ .../FilesStorageKit/FilesStorageKit.swift | 2 + 8 files changed, 181 insertions(+), 120 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index ff1ced838..9e95b56e1 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -172,14 +172,14 @@ private extension ChatFileView { spinner.stopAnimating() } - let fileType = model.file.file_type ?? "" - let fileName = model.file.file_name ?? "UNKNWON" + let fileType = model.file.type.map { ".\($0)" } ?? .empty + let fileName = model.file.name ?? "UNKNWON" nameLabel.text = fileName.contains(fileType) ? fileName - : "\(fileName.uppercased()).\(fileType.uppercased())" + : "\(fileName.uppercased())\(fileType.uppercased())" - sizeLabel.text = formatSize(model.file.file_size) + sizeLabel.text = formatSize(model.file.size) additionalLabel.text = fileType.uppercased() videoIconIV.isHidden = !( 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 index 53756bf06..5791517c7 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -114,7 +114,7 @@ private extension MediaContainerView { ) } - if let resolution = file.file.file_resolution, + if let resolution = file.file.resolution, resolution.width > resolution.height { isHorizontal = true } @@ -148,7 +148,7 @@ private extension MediaContainerView { var totalWidthForEqualAspectRatio: CGFloat = 0.0 for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { - if let resolution = mediaView.model.file.file_resolution { + if let resolution = mediaView.model.file.resolution { let aspectRatio = resolution.width / resolution.height let widthForEqualAspectRatio = height * aspectRatio totalWidthForEqualAspectRatio += widthForEqualAspectRatio @@ -160,7 +160,7 @@ private extension MediaContainerView { let scaleFactor = filesStackWidth / totalWidthForEqualAspectRatio for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { - if let resolution = mediaView.model.file.file_resolution { + if let resolution = mediaView.model.file.resolution { let aspectRatio = resolution.width / resolution.height let widthForEqualAspectRatio = height * aspectRatio var width = max(widthForEqualAspectRatio * scaleFactor, minimumWidth) @@ -203,7 +203,7 @@ extension ChatMediaContentView.FileModel { for row in rows { var isHorizontal = false for row in row { - if let resolution = row.file.file_resolution, + if let resolution = row.file.resolution, resolution.width > resolution.height { isHorizontal = true } diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index d8d3e11b1..0b906f2e0 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -108,15 +108,16 @@ final class ChatFileService: ChatFileProtocol { let replyMessage = replyMessage var richFiles: [RichMessageFile.File] = files.compactMap { - RichMessageFile.File.init( - file_id: $0.url.absoluteString, - file_type: $0.extenstion, - file_size: $0.size, - preview_id: $0.previewUrl?.absoluteString, - preview_nonce: nil, - file_name: $0.name, + .init( + id: $0.url.absoluteString, + size: $0.size, nonce: .empty, - file_resolution: $0.resolution + name: $0.name, + type: $0.extenstion, + preview: $0.previewUrl.map { + RichMessageFile.Preview(id: $0.absoluteString, nonce: .empty) + }, + resolution: $0.resolution ) } @@ -128,7 +129,7 @@ final class ChatFileService: ChatFileProtocol { replyto_id: replyMessage.id, reply_message: RichMessageFile( files: richFiles, - storage: storageProtocol.rawValue, + storage: .init(id: storageProtocol.rawValue), comment: text ) ) @@ -137,7 +138,7 @@ final class ChatFileService: ChatFileProtocol { messageLocally = .richMessage( payload: RichMessageFile( files: richFiles, - storage: storageProtocol.rawValue, + storage: .init(id: storageProtocol.rawValue), comment: text ) ) @@ -154,7 +155,7 @@ final class ChatFileService: ChatFileProtocol { ) richFiles.forEach { file in - uploadingFilesIDsArray.append(file.file_id) + uploadingFilesIDsArray.append(file.id) } do { @@ -202,13 +203,18 @@ final class ChatFileService: ChatFileProtocol { cached: cached )) + var previewDTO: RichMessageFile.Preview? + if let cid = result.preview?.cid, + let nonce = result.preview?.nonce { + previewDTO = .init(id: cid, nonce: nonce) + } + if let index = richFiles.firstIndex( - where: { $0.file_id == oldId } + where: { $0.id == oldId } ) { - richFiles[index].file_id = result.file.cid + richFiles[index].id = result.file.cid richFiles[index].nonce = result.file.nonce - richFiles[index].preview_id = result.preview?.cid - richFiles[index].preview_nonce = result.preview?.nonce + richFiles[index].preview = previewDTO } } @@ -220,7 +226,7 @@ final class ChatFileService: ChatFileProtocol { replyto_id: replyMessage.id, reply_message: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.ipfs.rawValue, + storage: .init(id: NetworkFileProtocolType.ipfs.rawValue), comment: text ) ) @@ -229,7 +235,7 @@ final class ChatFileService: ChatFileProtocol { message = .richMessage( payload: RichMessageFile( files: richFiles, - storage: NetworkFileProtocolType.ipfs.rawValue, + storage: .init(id: NetworkFileProtocolType.ipfs.rawValue), comment: text ) ) @@ -244,7 +250,7 @@ final class ChatFileService: ChatFileProtocol { ) } catch { richFiles.forEach { file in - uploadingFilesIDsArray.removeAll(where: { $0 == file.file_id }) + uploadingFilesIDsArray.removeAll(where: { $0 == file.id }) } try? await chatsProvider.setTxMessageAsFailed( @@ -278,8 +284,8 @@ final class ChatFileService: ChatFileProtocol { previewDownloadPolicy: DownloadPolicy, fullMediaDownloadPolicy: DownloadPolicy ) { - guard !downloadingFilesIDsArray.contains(file.file.file_id), - !ignoreFilesIDsArray.contains(file.file.file_id) + guard !downloadingFilesIDsArray.contains(file.file.id), + !ignoreFilesIDsArray.contains(file.file.id) else { return } @@ -305,11 +311,11 @@ final class ChatFileService: ChatFileProtocol { case .nobody: shouldDownloadOriginalFile = false case .everybody: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) && isMedia + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia ? true : false case .contacts: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.file_id) && isMedia + shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia ? havePartnerName : false } @@ -325,7 +331,7 @@ final class ChatFileService: ChatFileProtocol { shouldDownloadPreviewFile: shouldDownloadPreviewFile ) } catch { - ignoreFilesIDsArray.append(file.file.file_id) + ignoreFilesIDsArray.append(file.file.id) } } } @@ -362,21 +368,25 @@ private extension ChatFileService { (shouldDownloadOriginalFile || shouldDownloadPreviewFile) else { return } + guard !file.file.id.isEmpty, + !file.file.nonce.isEmpty + else { + throw FileManagerError.cantDownloadFile + } + defer { - downloadingFilesIDsArray.removeAll(where: { $0 == file.file.file_id }) + downloadingFilesIDsArray.removeAll(where: { $0 == file.file.id }) } - downloadingFilesIDsArray.append(file.file.file_id) + downloadingFilesIDsArray.append(file.file.id) var preview: UIImage? - if let previewId = file.file.preview_id, - let previewNonce = file.file.preview_nonce { - + if let previewDTO = file.file.preview { if shouldDownloadPreviewFile, - !filesStorage.isCached(previewId) { + !filesStorage.isCached(previewDTO.id) { try await downloadAndCacheFile( - id: previewId, - nonce: previewNonce, + id: previewDTO.id, + nonce: previewDTO.nonce, storage: file.storage, publicKey: chatroom?.partner?.publicKey ?? .empty, privateKey: keyPair.privateKey, @@ -386,15 +396,15 @@ private extension ChatFileService { } preview = filesStorage.getPreview( - for: previewId, - type: file.file.file_type ?? .empty + for: previewDTO.id, + type: file.file.type ?? .empty ) if shouldDownloadPreviewFile { - let cached = filesStorage.isCached(file.file.file_id) + let cached = filesStorage.isCached(file.file.id) updateFileFields.send(( - id: file.file.file_id, + id: file.file.id, newId: nil, preview: preview, cached: cached @@ -403,9 +413,9 @@ private extension ChatFileService { } if shouldDownloadOriginalFile, - !filesStorage.isCached(file.file.file_id) { + !filesStorage.isCached(file.file.id) { try await downloadAndCacheFile( - id: file.file.file_id, + id: file.file.id, nonce: file.nonce, storage: file.storage, publicKey: chatroom?.partner?.publicKey ?? .empty, @@ -414,10 +424,10 @@ private extension ChatFileService { recipientId: recipientId ) - let cached = filesStorage.isCached(file.file.file_id) + let cached = filesStorage.isCached(file.file.id) updateFileFields.send(( - id: file.file.file_id, + id: file.file.id, newId: nil, preview: preview, cached: cached @@ -451,8 +461,8 @@ private extension ChatFileService { } func needsPreviewDownload(file: ChatFile) -> Bool { - if let previewId = file.file.preview_id, - file.file.preview_nonce != nil, + if let previewId = file.file.preview?.id, + file.file.preview?.nonce != nil, !ignoreFilesIDsArray.contains(previewId), !filesStorage.isCached(previewId) { return true diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 509a5338e..cea45b775 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -391,13 +391,14 @@ private extension ChatMessageFactory { storage: String ) -> [ChatFile] { return files.map { - let previewId = $0[RichContentKeys.file.preview_id] as? String ?? .empty - let fileType = $0[RichContentKeys.file.file_type] as? String ?? .empty - let fileId = $0[RichContentKeys.file.file_id] as? String ?? .empty + let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] + let preview = RichMessageFile.Preview(previewData) + let fileType = $0[RichContentKeys.file.type] as? String ?? .empty + let fileId = $0[RichContentKeys.file.id] as? String ?? .empty return ChatFile( file: RichMessageFile.File($0), - previewImage: filesStorage.getPreview(for: previewId, type: fileType), + previewImage: filesStorage.getPreview(for: preview.id, type: fileType), isDownloading: downloadingFilesIDs.contains(fileId), isUploading: uploadingFilesIDs.contains(fileId), isCached: filesStorage.isCached(fileId), diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index da2c0f7c2..638a6e43e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -674,20 +674,20 @@ final class ChatViewModel: NSObject { let message = messages.first(where: { $0.messageId == messageId }) guard tx?.statusEnum == .delivered, - !downloadingFilesID.contains(file.file.file_id), + !downloadingFilesID.contains(file.file.id), case let(.file(fileModel)) = message?.content, let index = fileModel.value.content.fileModel.files.firstIndex(of: file) else { return } guard !file.isCached else { do { - _ = try filesStorage.getFileURL(with: file.file.file_id) + _ = try filesStorage.getFileURL(with: file.file.id) let chatFiles = fileModel.value.content.fileModel.files let files: [FileResult] = chatFiles.compactMap { file in guard file.isCached, - let url = try? filesStorage.getFileURL(with: file.file.file_id) else { + let url = try? filesStorage.getFileURL(with: file.file.id) else { return nil } @@ -696,9 +696,9 @@ final class ChatViewModel: NSObject { type: file.fileType, preview: nil, previewUrl: nil, - size: file.file.file_size, - name: file.file.file_name, - extenstion: file.file.file_type, + size: file.file.size, + name: file.file.name, + extenstion: file.file.type, resolution: nil ) } @@ -1175,10 +1175,10 @@ private extension ChatViewModel { messages.indices.forEach { index in messages[index].getFiles().forEach { file in messages[index].updateFields( - id: file.file.file_id, + id: file.file.id, preview: nil, needToUpdatePeview: false, - isDownloading: downloadingFilesID.contains(file.file.file_id) + isDownloading: downloadingFilesID.contains(file.file.id) ) } } @@ -1188,10 +1188,10 @@ private extension ChatViewModel { messages.indices.forEach { index in messages[index].getFiles().forEach { file in messages[index].updateFields( - id: file.file.file_id, + id: file.file.id, preview: nil, needToUpdatePeview: false, - isUploading: uploadingFilesIDs.contains(file.file.file_id) + isUploading: uploadingFilesIDs.contains(file.file.id) ) } } @@ -1285,11 +1285,11 @@ private extension ChatMessage { var model = fileModel.value guard let index = model.content.fileModel.files.firstIndex( - where: { $0.file.file_id == oldId } + where: { $0.file.id == oldId } ) else { return } if let newId = newId { - model.content.fileModel.files[index].file.file_id = newId + model.content.fileModel.files[index].file.id = newId } if let value = cached { model.content.fileModel.files[index].isCached = value diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift index dd4ad82d1..5ac9bb22f 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -29,7 +29,7 @@ public class FilePresentationHelper { let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] let mediaFilesCount = files.filter { file in - let fileTypeRaw = file[RichContentKeys.file.file_type] as? String ?? .empty + let fileTypeRaw = file[RichContentKeys.file.type] as? String ?? .empty let fileType = FileType(raw: fileTypeRaw) ?? .other return fileType == .image || fileType == .video }.count diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index 36aa9980f..5ca1a550a 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -52,13 +52,13 @@ public enum RichContentKeys { public static let file_id = "file_id" public static let comment = "comment" public static let storage = "storage" - public static let file_size = "file_size" - public static let file_type = "file_type" - public static let preview_id = "preview_id" - public static let file_name = "file_name" public static let nonce = "nonce" - public static let preview_nonce = "preview_nonce" - public static let file_resolution = "file_resolution" + 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" } } @@ -92,82 +92,130 @@ 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 init( + id: String, + nonce: String + ) { + self.id = id + self.nonce = nonce + } + + public init(_ data: [String: Any]) { + self.id = (data[RichContentKeys.file.id] as? String) ?? .empty + self.nonce = data[RichContentKeys.file.nonce] 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 + } + + return contentDict + } + } + public struct File: Codable, Equatable, Hashable { - public var file_id: String - public var file_type: String? - public var file_size: Int64 - public var preview_id: String? - public var file_name: String? + public var preview: Preview? + public var id: String + public var type: String? + public var size: Int64 public var nonce: String - public var preview_nonce: String? - public var file_resolution: CGSize? + public var resolution: CGSize? + public var name: String? public init( - file_id: String, - file_type: String? = nil, - file_size: Int64, - preview_id: String? = nil, - preview_nonce: String? = nil, - file_name: String? = nil, + id: String, + size: Int64, nonce: String, - file_resolution: CGSize? = nil + name: String?, + type: String? = nil, + preview: Preview? = nil, + resolution: CGSize? = nil ) { - self.file_id = file_id - self.file_type = file_type - self.file_size = file_size - self.preview_id = preview_id - self.file_name = file_name + self.id = id + self.type = type + self.size = size self.nonce = nonce - self.preview_nonce = preview_nonce - self.file_resolution = file_resolution + self.name = name + self.preview = preview + self.resolution = resolution } public init(_ data: [String: Any]) { - self.file_id = (data[RichContentKeys.file.file_id] as? String) ?? .empty - self.file_type = data[RichContentKeys.file.file_type] as? String - self.file_size = (data[RichContentKeys.file.file_size] as? Int64) ?? .zero - self.preview_id = data[RichContentKeys.file.preview_id] as? String - self.file_name = data[RichContentKeys.file.file_name] as? String + self.id = (data[RichContentKeys.file.id] as? String) ?? .empty + self.type = 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.preview_nonce = data[RichContentKeys.file.preview_nonce] as? String ?? .empty - if let resolution = data[RichContentKeys.file.file_resolution] as? [CGFloat] { - self.file_resolution = .init( + + 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.file_resolution] as? CGSize { - self.file_resolution = resolution + } else if let resolution = data[RichContentKeys.file.resolution] as? CGSize { + self.resolution = resolution } else { - self.file_resolution = nil + self.resolution = nil } } public func content() -> [String: Any] { var contentDict: [String : Any] = [ - RichContentKeys.file.file_id: file_id, - RichContentKeys.file.file_size: file_size, + RichContentKeys.file.id: id, + RichContentKeys.file.size: size, RichContentKeys.file.nonce: nonce ] - if let file_type = file_type, !file_type.isEmpty { - contentDict[RichContentKeys.file.file_type] = file_type + if let type = type, !type.isEmpty { + contentDict[RichContentKeys.file.type] = type } - if let preview_id = preview_id, !preview_id.isEmpty { - contentDict[RichContentKeys.file.preview_id] = preview_id + if let preview = preview { + contentDict[RichContentKeys.file.preview] = preview.content() } - if let preview_nonce = preview_nonce, !preview_nonce.isEmpty { - contentDict[RichContentKeys.file.preview_nonce] = preview_nonce + if let name = name, !name.isEmpty { + contentDict[RichContentKeys.file.name] = name } - if let file_name = file_name, !file_name.isEmpty { - contentDict[RichContentKeys.file.file_name] = file_name + if let resolution = resolution { + contentDict[RichContentKeys.file.resolution] = resolution } - if let file_resolution = file_resolution { - contentDict[RichContentKeys.file.file_resolution] = file_resolution - } + 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 } @@ -176,14 +224,14 @@ public struct RichMessageFile: RichMessage { public var type: String public var additionalType: RichAdditionalType public var files: [File] - public var storage: String + public var storage: Storage public var comment: String? public enum CodingKeys: String, CodingKey { case files, storage, comment } - public init(files: [File], storage: String, comment: String?) { + public init(files: [File], storage: Storage, comment: String?) { self.type = RichContentKeys.file.file self.files = files self.storage = storage @@ -194,7 +242,7 @@ public struct RichMessageFile: RichMessage { public func content() -> [String: Any] { var contentDict: [String : Any] = [ RichContentKeys.file.files: files.map { $0.content() }, - RichContentKeys.file.storage: storage + RichContentKeys.file.storage: storage.content() ] if let comment = comment, !comment.isEmpty { diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index faa3bdc33..c7a2f2ad8 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -13,6 +13,8 @@ public final class FilesStorageKit { } public func getPreview(for id: String, type: String) -> UIImage? { + guard !id.isEmpty else { return nil } + if let image = cachedFiles.object(forKey: id as NSString) { return image } From 40488431e8197ca92b07c91596773eb1fdc8b357 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 18:32:03 +0300 Subject: [PATCH 080/123] [trello.com/c/uxBZaznD] fix: check file storage --- Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index cea45b775..d9940fd32 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -308,7 +308,9 @@ private extension ChatMessageFactory { let files: [[String: Any]] = transaction.getRichValue(for: RichContentKeys.file.files) ?? [[:]] let decodedMessage = decodeMessage(transaction) - let storage = transaction.getRichValue(for: RichContentKeys.file.storage) ?? .empty + 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 From ac250d34185c71bb3f8c666a243670166f538dd2 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 18:44:31 +0300 Subject: [PATCH 081/123] [trello.com/c/uxBZaznD] fix: file position in full screen --- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 638a6e43e..aa36f6889 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -675,8 +675,7 @@ final class ChatViewModel: NSObject { guard tx?.statusEnum == .delivered, !downloadingFilesID.contains(file.file.id), - case let(.file(fileModel)) = message?.content, - let index = fileModel.value.content.fileModel.files.firstIndex(of: file) + case let(.file(fileModel)) = message?.content else { return } guard !file.isCached else { @@ -692,6 +691,7 @@ final class ChatViewModel: NSObject { } return FileResult.init( + assetId: file.file.id, url: url, type: file.fileType, preview: nil, @@ -702,7 +702,8 @@ final class ChatViewModel: NSObject { resolution: nil ) } - + + let index = files.firstIndex(where: { $0.assetId == file.file.id }) ?? .zero presentDocumentViewerVC.send((files, index)) } catch { dialog.send(.alert(error.localizedDescription)) From 9b4e439bed06dfa9c0dc9f3bcc0eac9c2a09ce3c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 18:49:06 +0300 Subject: [PATCH 082/123] [trello.com/c/uxBZaznD] set: max download attempts count --- .../Chat/ViewModel/ChatFileService.swift | 100 +++++++++++++----- 1 file changed, 73 insertions(+), 27 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 0b906f2e0..7aa9e4572 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -63,6 +63,9 @@ final class ChatFileService: ChatFileProtocol { private var ignoreFilesIDsArray: [String] = [] private var subscriptions = Set() + private let maxDownloadAttemptsCount = 3 + + @Atomic private var fileDownloadAttemptsCount: [String: Int] = [:] var downloadingFilesIDs: Published<[String]>.Publisher { $downloadingFilesIDsArray @@ -291,34 +294,17 @@ final class ChatFileService: ChatFileProtocol { } Task { - 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 - } + let shouldDownloadPreviewFile = shoudDownloadPreview( + file: file, + previewDownloadPolicy: previewDownloadPolicy, + havePartnerName: havePartnerName + ) - let isMedia = file.fileType == .image || file.fileType == .video - let shouldDownloadOriginalFile: Bool - switch fullMediaDownloadPolicy { - case .nobody: - shouldDownloadOriginalFile = false - case .everybody: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia - ? true - : false - case .contacts: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia - ? havePartnerName - : false - } + let shouldDownloadOriginalFile = shoudDownloadOriginal( + file: file, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + havePartnerName: havePartnerName + ) guard shouldDownloadOriginalFile || shouldDownloadPreviewFile else { return } @@ -331,6 +317,21 @@ final class ChatFileService: ChatFileProtocol { shouldDownloadPreviewFile: shouldDownloadPreviewFile ) } catch { + let count = fileDownloadAttemptsCount[file.file.id] ?? .zero + + guard count >= maxDownloadAttemptsCount else { + fileDownloadAttemptsCount[file.file.id] = count + 1 + autoDownload( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy + ) + return + } + ignoreFilesIDsArray.append(file.file.id) } } @@ -460,6 +461,51 @@ private extension ChatFileService { ) } + 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.isCached(file.file.id) && isMedia + ? true + : false + case .contacts: + shouldDownloadOriginalFile = !filesStorage.isCached(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, From a8b853fa75654af2634e36d120d58d227a0f82f0 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 18:57:20 +0300 Subject: [PATCH 083/123] [trello.com/c/uxBZaznD] feat: inc max file count per message --- .../Content/Views/MediaContainerView/MediaContainerView.swift | 2 +- CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 5791517c7..fc1e6e816 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -20,7 +20,7 @@ final class MediaContainerView: UIView { stack.distribution = .fill stack.layer.masksToBounds = true - for chunk in 0..<3 { + for chunk in 0..<(FilesConstants.maxFilesCount / 2) { let stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = stackSpacing diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift index 71fcb9b6b..db6d8dc98 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -8,7 +8,7 @@ import Foundation public final class FilesConstants { - public static let maxFilesCount = 5 + public static let maxFilesCount = 10 public static let maxFileSize: Int64 = 250 * 1024 * 1024 public static let previewSize: CGSize = .init(squareSize: 400) public static let previewTag: String = "preview_" From 66ccd441eb991ef1f2737a6ae04172453f0aae19 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Apr 2024 19:25:24 +0300 Subject: [PATCH 084/123] [trello.com/c/uxBZaznD] fix: double download --- Adamant/Modules/Chat/ViewModel/ChatFileService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 7aa9e4572..bf764e62c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -366,7 +366,8 @@ private extension ChatFileService { let ownerId = accountService.account?.address, let recipientId = chatroom?.partner?.address, NetworkFileProtocolType(rawValue: file.storage) != nil, - (shouldDownloadOriginalFile || shouldDownloadPreviewFile) + (shouldDownloadOriginalFile || shouldDownloadPreviewFile), + !downloadingFilesIDsArray.contains(file.file.id) else { return } guard !file.file.id.isEmpty, From a65c67be44f13d513513cc93a9fcf3bbcb7c8ad4 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 6 May 2024 14:32:46 +0300 Subject: [PATCH 085/123] [trello.com/c/uxBZaznD] update chat preservation --- .../Chat/View/ChatViewController.swift | 1 + .../ViewModel/ChatPreservationProtocol.swift | 7 +++++ .../Chat/ViewModel/ChatViewModel.swift | 10 +++++++ .../ServiceProtocols/ChatPreservation.swift | 29 ++++++++++++++++--- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index b6add967a..989a15c65 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -157,6 +157,7 @@ final class ChatViewController: MessagesViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + viewModel.preserveFiles() viewModel.preserveMessage(inputBar.text) viewModel.preserveReplayMessage() viewModel.saveChatOffset( 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 aa36f6889..56375a0a6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -213,6 +213,11 @@ final class ChatViewModel: NSObject { fullscreenLoading = cachedMessages == nil replyMessage = chatPreservation.getReplyMessage(address: partnerAddress, thenRemoveIt: true) + + filesPicked = chatPreservation.getPreservedFiles( + for: partnerAddress, + thenRemoveIt: true + ) } } @@ -337,6 +342,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) 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 + } } From 4e642e3c43249b395abb52e7fe17364ce8b92ac5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 6 May 2024 14:37:20 +0300 Subject: [PATCH 086/123] [trello.com/c/uxBZaznD] small fixes --- Adamant/Modules/Chat/ChatFactory.swift | 10 ++++-- .../Container/ChatMediaContainerView.swift | 6 +++- .../FilesToolBarView/FilesToolbarView.swift | 10 +++++- .../Chat/ViewModel/ChatMessageFactory.swift | 2 +- .../Chat/ViewModel/ChatViewModel.swift | 34 ++++++++++++++++++- .../FilesStorageProprietiesService.swift | 14 ++++---- .../Localization/de.lproj/Localizable.strings | 2 +- .../Localization/en.lproj/Localizable.strings | 2 +- .../Localization/ru.lproj/Localizable.strings | 2 +- .../Localization/zh.lproj/Localizable.strings | 2 +- .../Helpers/FilePresentationHelper.swift | 12 +++++-- 11 files changed, 78 insertions(+), 18 deletions(-) diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index e2752ced4..9d797961e 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -31,7 +31,9 @@ struct ChatFactory { let filesStorage: FilesStorageProtocol let chatFileService: ChatFileProtocol let filesStorageProprieties: FilesStorageProprietiesProtocol - + let nodesStorage: NodesStorageProtocol + let reachabilityMonitor: ReachabilityMonitor + nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! dialogService = assembler.resolve(DialogService.self)! @@ -48,6 +50,8 @@ struct ChatFactory { 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)! } func makeViewController(screensFactory: ScreensFactory) -> ChatViewController { @@ -124,7 +128,9 @@ private extension ChatFactory { chatPreservation: chatPreservation, filesStorage: filesStorage, chatFileService: chatFileService, - filesStorageProprieties: filesStorageProprieties + filesStorageProprieties: filesStorageProprieties, + nodesStorage: nodesStorage, + reachabilityMonitor: reachabilityMonitor ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 9346f2a1d..3c5798f64 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -293,7 +293,11 @@ extension ChatMediaContainerView { actionHandler(.copy(text: model.content.comment.string)) } - return AMenuSection([reply, copy, report, remove]) + let actions: [AMenuItem] = model.content.comment.string.isEmpty + ? [reply, report, remove] + : [reply, copy, report, remove] + + return AMenuSection(actions) } } diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift index 70bd90e82..8d5646148 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -102,6 +102,11 @@ extension FilesToolbarView { func update(_ data: [FileResult]) { self.data = data collectionView.reloadData() + collectionView.scrollToItem( + at: .init(row: data.count - 1, section: .zero), + at: .right, + animated: true + ) } } @@ -139,7 +144,10 @@ extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource .init(width: self.frame.height - 10, height: self.frame.height - 10) } - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { openFileAction?(data[indexPath.row]) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index d9940fd32..a1a38691b 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -506,7 +506,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) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 56375a0a6..18af137d5 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -36,6 +36,8 @@ final class ChatViewModel: NSObject { private let filesStorage: FilesStorageProtocol private let chatFileService: ChatFileProtocol private let filesStorageProprieties: FilesStorageProprietiesProtocol + private let nodesStorage: NodesStorageProtocol + private let reachabilityMonitor: ReachabilityMonitor let chatMessagesListViewModel: ChatMessagesListViewModel @@ -159,7 +161,9 @@ final class ChatViewModel: NSObject { chatPreservation: ChatPreservationProtocol, filesStorage: FilesStorageProtocol, chatFileService: ChatFileProtocol, - filesStorageProprieties: FilesStorageProprietiesProtocol + filesStorageProprieties: FilesStorageProprietiesProtocol, + nodesStorage: NodesStorageProtocol, + reachabilityMonitor: ReachabilityMonitor ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -179,6 +183,8 @@ final class ChatViewModel: NSObject { self.filesStorage = filesStorage self.chatFileService = chatFileService self.filesStorageProprieties = filesStorageProprieties + self.nodesStorage = nodesStorage + self.reachabilityMonitor = reachabilityMonitor super.init() setupObservers() @@ -271,7 +277,26 @@ 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?.count ?? .zero > .zero { + guard nodesStorage.haveActiveNode(in: .ipfs) else { + dialog.send(.alert(ApiServiceError.noEndpointsAvailable( + coin: NodeGroup.ipfs.name + ).localizedDescription)) + return + } + Task { let replyMessage = replyMessage let filesPicked = filesPicked @@ -676,6 +701,13 @@ 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 } diff --git a/Adamant/Services/FilesStorageProprietiesService.swift b/Adamant/Services/FilesStorageProprietiesService.swift index 6aa12a084..1e7bfb35d 100644 --- a/Adamant/Services/FilesStorageProprietiesService.swift +++ b/Adamant/Services/FilesStorageProprietiesService.swift @@ -20,6 +20,8 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { @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 // MARK: Lifecycle @@ -49,8 +51,8 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { } private func userLoggedOut() { - setAutoDownloadPreview(.everybody) - setAutoDownloadFullMedia(.everybody) + setAutoDownloadPreview(autoDownloadPreviewDefaultState) + setAutoDownloadFullMedia(autoDownloadFullMediaDefaultState) } // MARK: Update data @@ -63,10 +65,10 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { guard let result: String = securedStore.get( StoreKey.storage.autoDownloadPreviewEnabled ) else { - return .everybody + return autoDownloadPreviewDefaultState } - return DownloadPolicy(rawValue: result) ?? .everybody + return DownloadPolicy(rawValue: result) ?? autoDownloadPreviewDefaultState } func setAutoDownloadPreview(_ value: DownloadPolicy) { @@ -82,10 +84,10 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { guard let result: String = securedStore.get( StoreKey.storage.autoDownloadFullMediaEnabled ) else { - return .everybody + return autoDownloadFullMediaDefaultState } - return DownloadPolicy(rawValue: result) ?? .everybody + return DownloadPolicy(rawValue: result) ?? autoDownloadFullMediaDefaultState } func setAutoDownloadFullMedia(_ value: DownloadPolicy) { diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 402d9a86a..c96d3807e 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -755,7 +755,7 @@ "Storage.AutoDownloadPreview.Header" = "Automatischer Download von Medien"; /* Storage usage: Clear Popup Title */ -"StorageUsage.Clear.Popup.Title" = "Sind Sie sicher, dass Sie die Daten aus dem Speicher löschen möchten?"; +"StorageUsage.Clear.Popup.Title" = "Sie werden erneut Dateien und Bilder herunterladen. Weiter?"; /* Storage usage: Clear Title */ "StorageUsage.Clear.Title" = "Klarer Speicher"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 6ad447828..8e8d89083 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -743,7 +743,7 @@ "StorageUsage.Clear.Title" = "Clear storage"; /* Storage usage: Clear Popup Title */ -"StorageUsage.Clear.Popup.Title" = "Are you sure you want to delete the data from storage?"; +"StorageUsage.Clear.Popup.Title" = "You'll download files and images again. Continue?"; /* Storage usage: Title */ "StorageUsage.Title" = "Storage and data"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index cb094917f..fc75d9223 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -743,7 +743,7 @@ "StorageUsage.Clear.Title" = "Очистить хранилище"; /* Storage usage: Clear Popup Title */ -"StorageUsage.Clear.Popup.Title" = "Вы уверены, что хотите удалить данные из хранилища ?"; +"StorageUsage.Clear.Popup.Title" = "Файлы и изображения придется скачивать повторно. Продолжить?"; /* Storage usage: Title */ "StorageUsage.Title" = "Память и данные"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index bd767a781..02000cf33 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -701,7 +701,7 @@ "StorageUsage.Clear.Title" = "清晰的存储"; /* Storage usage: Clear Popup Title */ -"StorageUsage.Clear.Popup.Title" = "您确定要从存储中删除数据吗?"; +"StorageUsage.Clear.Popup.Title" = "您将再次下载文件和图像。继续?"; /* Storage usage: Title */ "StorageUsage.Title" = "存储和数据"; diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift index 5ac9bb22f..2460529b0 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -13,8 +13,16 @@ public class FilePresentationHelper { otherFilesCount: Int, comment: String ) -> String { - let mediaText = mediaFilesCount > 0 ? "📸\(mediaFilesCount)" : .empty - let fileText = otherFilesCount > 0 ? "📄\(otherFilesCount)" : .empty + 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 From a4c437c2cb7630decdc837594ed6fe6cdf54e30a Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 9 May 2024 14:25:32 +0300 Subject: [PATCH 087/123] [trello.com/c/uxBZaznD] feat: save file encrypted --- .../Chat/ViewModel/ChatFileService.swift | 243 ++++++++++++++---- .../Chat/ViewModel/ChatMessageFactory.swift | 4 +- .../Chat/ViewModel/ChatViewModel.swift | 83 +++--- .../StorageUsage/StorageUsageView.swift | 21 ++ .../StorageUsage/StorageUsageViewModel.swift | 9 +- .../FilesStorageProprietiesProtocol.swift | 2 + .../FilesStorageProtocol.swift | 33 ++- .../FilesStorageProprietiesService.swift | 33 ++- .../CommonKit/AdamantDynamicResources.swift | 5 +- .../Localization/de.lproj/Localizable.strings | 6 + .../Localization/en.lproj/Localizable.strings | 6 + .../Localization/ru.lproj/Localizable.strings | 6 + .../Localization/zh.lproj/Localizable.strings | 6 + .../Sources/CommonKit/Core/SecuredStore.swift | 5 +- .../Sources/CommonKit/Models/FileResult.swift | 8 +- .../CommonKit/Models/RichMessage.swift | 11 +- .../Helpers/FilesPickerKitHelper.swift | 3 + .../Pickers/DocumentInteractionService.swift | 6 +- .../Pickers/MediaPickerService.swift | 4 +- .../FilesStorageKit/FilesStorageKit.swift | 220 ++++++++++++---- 20 files changed, 544 insertions(+), 170 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index bf764e62c..af63e3c5f 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -10,6 +10,7 @@ import Foundation import CommonKit import UIKit import Combine +import FilesStorageKit protocol ChatFileProtocol { var downloadingFilesIDs: Published<[String]>.Publisher { @@ -20,7 +21,12 @@ protocol ChatFileProtocol { get } - var updateFileFields: PassthroughSubject<(id: String, newId: String?, preview: UIImage?, cached: Bool), Never> { + var updateFileFields: PassthroughSubject<( + id: String, + newId: String?, + preview: UIImage?, + cached: Bool? + ), Never> { get } @@ -28,13 +34,15 @@ protocol ChatFileProtocol { text: String?, chatroom: Chatroom?, filesPicked: [FileResult]?, - replyMessage: MessageModel? + replyMessage: MessageModel?, + saveEncrypted: Bool ) async throws func downloadFile( file: ChatFile, isFromCurrentSender: Bool, - chatroom: Chatroom? + chatroom: Chatroom?, + saveEncrypted: Bool ) async throws func autoDownload( @@ -43,12 +51,19 @@ protocol ChatFileProtocol { chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, - fullMediaDownloadPolicy: DownloadPolicy + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool ) + + func getDecodedData( + file: FilesStorageKit.File, + nonce: String, + chatroom: Chatroom? + ) throws -> Data } final class ChatFileService: ChatFileProtocol { - typealias UploadResult = (data: Data, nonce: String, cid: String) + typealias UploadResult = (decodedData: Data, encodedData: Data, nonce: String, cid: String) // MARK: Dependencies @@ -75,7 +90,7 @@ final class ChatFileService: ChatFileProtocol { $uploadingFilesIDsArray } - let updateFileFields = ObservableSender<(id: String, newId: String?, preview: UIImage?, cached: Bool)>() + let updateFileFields = ObservableSender<(id: String, newId: String?, preview: UIImage?, cached: Bool?)>() init( accountService: AccountService, @@ -97,7 +112,8 @@ final class ChatFileService: ChatFileProtocol { text: String?, chatroom: Chatroom?, filesPicked: [FileResult]?, - replyMessage: MessageModel? + replyMessage: MessageModel?, + saveEncrypted: Bool ) async throws { guard let partnerAddress = chatroom?.partner?.address, let files = filesPicked, @@ -118,7 +134,11 @@ final class ChatFileService: ChatFileProtocol { name: $0.name, type: $0.extenstion, preview: $0.previewUrl.map { - RichMessageFile.Preview(id: $0.absoluteString, nonce: .empty) + RichMessageFile.Preview( + id: $0.absoluteString, + nonce: .empty, + extension: .empty + ) }, resolution: $0.resolution ) @@ -148,7 +168,12 @@ final class ChatFileService: ChatFileProtocol { } for url in files.compactMap({ $0.previewUrl }) { - filesStorage.cacheTemporaryFile(url: url) + filesStorage.cacheTemporaryFile( + url: url, + isEncrypted: false, + fileType: .image, + isPreview: true + ) } let txLocally = try await chatsProvider.sendFileMessageLocally( @@ -171,33 +196,42 @@ final class ChatFileService: ChatFileProtocol { ) try filesStorage.cacheFile( - id: result.file.cid, + id: result.file.cid, + fileExtension: file.extenstion ?? .empty, url: file.url, + decodedData: result.file.decodedData, + encodedData: result.file.encodedData, ownerId: ownerId, - recipientId: partnerAddress + recipientId: partnerAddress, + saveEncrypted: saveEncrypted, + fileType: file.type, + isPreview: false ) var preview: UIImage? if let previewUrl = file.previewUrl, - let previewId = result.preview?.cid { + let previewResult = result.preview { try filesStorage.cacheFile( - id: previewId, + id: previewResult.cid, + fileExtension: file.previewExtension ?? .empty, url: previewUrl, + decodedData: previewResult.decodedData, + encodedData: previewResult.encodedData, ownerId: ownerId, - recipientId: partnerAddress + recipientId: partnerAddress, + saveEncrypted: saveEncrypted, + fileType: .image, + isPreview: true ) - preview = filesStorage.getPreview( - for: previewId, - type: file.extenstion ?? .empty - ) + preview = filesStorage.getPreview(for: previewResult.cid) } let oldId = file.url.absoluteString uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) - let cached = filesStorage.isCached(result.file.cid) + let cached = filesStorage.isCachedLocally(result.file.cid) updateFileFields.send(( id: oldId, @@ -209,7 +243,11 @@ final class ChatFileService: ChatFileProtocol { var previewDTO: RichMessageFile.Preview? if let cid = result.preview?.cid, let nonce = result.preview?.nonce { - previewDTO = .init(id: cid, nonce: nonce) + previewDTO = .init( + id: cid, + nonce: nonce, + extension: file.previewExtension + ) } if let index = richFiles.firstIndex( @@ -268,14 +306,16 @@ final class ChatFileService: ChatFileProtocol { func downloadFile( file: ChatFile, isFromCurrentSender: Bool, - chatroom: Chatroom? + chatroom: Chatroom?, + saveEncrypted: Bool ) async throws { try await downloadFile( file: file, isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, shouldDownloadOriginalFile: true, - shouldDownloadPreviewFile: true + shouldDownloadPreviewFile: true, + saveEncrypted: saveEncrypted ) } @@ -285,7 +325,8 @@ final class ChatFileService: ChatFileProtocol { chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, - fullMediaDownloadPolicy: DownloadPolicy + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool ) { guard !downloadingFilesIDsArray.contains(file.file.id), !ignoreFilesIDsArray.contains(file.file.id) @@ -306,7 +347,10 @@ final class ChatFileService: ChatFileProtocol { havePartnerName: havePartnerName ) - guard shouldDownloadOriginalFile || shouldDownloadPreviewFile else { return } + guard shouldDownloadOriginalFile || shouldDownloadPreviewFile else { + cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) + return + } do { try await downloadFile( @@ -314,7 +358,8 @@ final class ChatFileService: ChatFileProtocol { isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, shouldDownloadOriginalFile: shouldDownloadOriginalFile, - shouldDownloadPreviewFile: shouldDownloadPreviewFile + shouldDownloadPreviewFile: shouldDownloadPreviewFile, + saveEncrypted: saveEncrypted ) } catch { let count = fileDownloadAttemptsCount[file.file.id] ?? .zero @@ -327,7 +372,8 @@ final class ChatFileService: ChatFileProtocol { chatroom: chatroom, havePartnerName: havePartnerName, previewDownloadPolicy: previewDownloadPolicy, - fullMediaDownloadPolicy: fullMediaDownloadPolicy + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted ) return } @@ -336,6 +382,33 @@ final class ChatFileService: ChatFileProtocol { } } } + + 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 + } } private extension ChatFileService { @@ -355,12 +428,62 @@ private extension ChatFileService { } private extension ChatFileService { + 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), + fileDTO.isPreview, + filesStorage.isCachedLocally(id), + !filesStorage.isCachedInMemory(id), + let image = try? cacheFileToMemory( + id: id, + file: fileDTO, + nonce: nonce, + chatroom: chatroom + ) + else { + return + } + + updateFileFields.send(( + id: file.file.id, + newId: nil, + preview: image, + cached: nil + )) + } + func cacheFileToMemory( + id: String, + file: FilesStorageKit.File, + nonce: String, + chatroom: Chatroom? + ) throws -> UIImage? { + print("try to cache \(id), is main thread = \(Thread.isMainThread), file=\(file)") + 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, isFromCurrentSender: Bool, chatroom: Chatroom?, shouldDownloadOriginalFile: Bool, - shouldDownloadPreviewFile: Bool + shouldDownloadPreviewFile: Bool, + saveEncrypted: Bool ) async throws { guard let keyPair = accountService.keypair, let ownerId = accountService.account?.address, @@ -385,7 +508,7 @@ private extension ChatFileService { if let previewDTO = file.file.preview { if shouldDownloadPreviewFile, - !filesStorage.isCached(previewDTO.id) { + !filesStorage.isCachedLocally(previewDTO.id) { try await downloadAndCacheFile( id: previewDTO.id, nonce: previewDTO.nonce, @@ -393,17 +516,18 @@ private extension ChatFileService { publicKey: chatroom?.partner?.publicKey ?? .empty, privateKey: keyPair.privateKey, ownerId: ownerId, - recipientId: recipientId + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: .image, + fileExtension: previewDTO.extension ?? .empty, + isPreview: true ) } - preview = filesStorage.getPreview( - for: previewDTO.id, - type: file.file.type ?? .empty - ) + preview = filesStorage.getPreview(for: previewDTO.id) if shouldDownloadPreviewFile { - let cached = filesStorage.isCached(file.file.id) + let cached = filesStorage.isCachedLocally(file.file.id) updateFileFields.send(( id: file.file.id, @@ -415,7 +539,7 @@ private extension ChatFileService { } if shouldDownloadOriginalFile, - !filesStorage.isCached(file.file.id) { + !filesStorage.isCachedLocally(file.file.id) { try await downloadAndCacheFile( id: file.file.id, nonce: file.nonce, @@ -423,10 +547,14 @@ private extension ChatFileService { publicKey: chatroom?.partner?.publicKey ?? .empty, privateKey: keyPair.privateKey, ownerId: ownerId, - recipientId: recipientId + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: file.fileType, + fileExtension: file.file.type ?? .empty, + isPreview: false ) - let cached = filesStorage.isCached(file.file.id) + let cached = filesStorage.isCachedLocally(file.file.id) updateFileFields.send(( id: file.file.id, @@ -444,21 +572,32 @@ private extension ChatFileService { publicKey: String, privateKey: String, ownerId: String, - recipientId: String + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + fileExtension: String, + isPreview: Bool ) async throws { - let data = try await downloadFile( + let result = try await downloadFile( id: id, storage: storage, senderPublicKey: publicKey, recipientPrivateKey: privateKey, - nonce: nonce + nonce: nonce, + saveEncrypted: saveEncrypted ) try filesStorage.cacheFile( id: id, - data: data, + fileExtension: fileExtension, + url: nil, + decodedData: result.decodedData, + encodedData: result.encodedData, ownerId: ownerId, - recipientId: recipientId + recipientId: recipientId, + saveEncrypted: saveEncrypted, + fileType: fileType, + isPreview: isPreview ) } @@ -473,11 +612,11 @@ private extension ChatFileService { case .nobody: shouldDownloadOriginalFile = false case .everybody: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia + shouldDownloadOriginalFile = !filesStorage.isCachedLocally(file.file.id) && isMedia ? true : false case .contacts: - shouldDownloadOriginalFile = !filesStorage.isCached(file.file.id) && isMedia + shouldDownloadOriginalFile = !filesStorage.isCachedLocally(file.file.id) && isMedia ? havePartnerName : false } @@ -511,7 +650,7 @@ private extension ChatFileService { if let previewId = file.file.preview?.id, file.file.preview?.nonce != nil, !ignoreFilesIDsArray.contains(previewId), - !filesStorage.isCached(previewId) { + !filesStorage.isCachedLocally(previewId) { return true } @@ -550,7 +689,7 @@ private extension ChatFileService { recipientPublicKey: String, senderPrivateKey: String, storageProtocol: NetworkFileProtocolType - ) async throws -> (data: Data, nonce: String, cid: String) { + ) async throws -> UploadResult { defer { url.stopAccessingSecurityScopedResource() } @@ -571,7 +710,7 @@ private extension ChatFileService { } let cid = try await filesNetworkManager.uploadFiles(encodedData, type: storageProtocol) - return (encodedData, nonce, cid) + return (data, encodedData, nonce, cid) } func downloadFile( @@ -579,8 +718,9 @@ private extension ChatFileService { storage: String, senderPublicKey: String, recipientPrivateKey: String, - nonce: String - ) async throws -> Data { + nonce: String, + saveEncrypted: Bool + ) async throws -> (decodedData: Data, encodedData: Data) { let encodedData = try await filesNetworkManager.downloadFile(id, type: storage) guard let decodedData = adamantCore.decodeData( @@ -588,11 +728,10 @@ private extension ChatFileService { rawNonce: nonce, senderPublicKey: senderPublicKey, privateKey: recipientPrivateKey - ) - else { + ) else { throw FileManagerError.cantDecryptFile } - return decodedData + return (decodedData, encodedData) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index a1a38691b..b6f1fbd8e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -400,10 +400,10 @@ private extension ChatMessageFactory { return ChatFile( file: RichMessageFile.File($0), - previewImage: filesStorage.getPreview(for: preview.id, type: fileType), + previewImage: filesStorage.getPreview(for: preview.id), isDownloading: downloadingFilesIDs.contains(fileId), isUploading: uploadingFilesIDs.contains(fileId), - isCached: filesStorage.isCached(fileId), + isCached: filesStorage.isCachedLocally(fileId), storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, isFromCurrentSender: isFromCurrentSender, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 18af137d5..c8bb488c9 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -135,11 +135,11 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } - private var downloadingFilesID: [String] = [] { + @Atomic private var downloadingFilesID: [String] = [] { didSet { updateDownloadingFiles(&messages) } } - private var uploadingFilesIDs: [String] = [] { + @Atomic private var uploadingFilesIDs: [String] = [] { didSet { updateUploadingFiles(&messages) } } @@ -309,12 +309,12 @@ final class ChatViewModel: NSObject { text: text, chatroom: chatroom, filesPicked: filesPicked, - replyMessage: replyMessage + replyMessage: replyMessage, + saveEncrypted: filesStorageProprieties.saveFileEncrypted() ) } catch { await handleMessageSendingError(error: error, sentText: text) } - } return } @@ -720,37 +720,13 @@ final class ChatViewModel: NSObject { case let(.file(fileModel)) = message?.content else { return } - guard !file.isCached else { - do { - _ = try filesStorage.getFileURL(with: file.file.id) - + guard !file.isCached, + !filesStorage.isCachedLocally(file.file.id) + else { + Task { let chatFiles = fileModel.value.content.fileModel.files - - let files: [FileResult] = chatFiles.compactMap { file in - guard file.isCached, - let url = try? filesStorage.getFileURL(with: file.file.id) else { - return nil - } - - return FileResult.init( - assetId: file.file.id, - url: url, - type: file.fileType, - preview: nil, - previewUrl: nil, - size: file.file.size, - name: file.file.name, - extenstion: file.file.type, - resolution: nil - ) - } - - let index = files.firstIndex(where: { $0.assetId == file.file.id }) ?? .zero - presentDocumentViewerVC.send((files, index)) - } catch { - dialog.send(.alert(error.localizedDescription)) + self.presentFileInFullScreen(id: file.file.id, chatFiles: chatFiles) } - return } @@ -759,7 +735,8 @@ final class ChatViewModel: NSObject { try await self?.chatFileService.downloadFile( file: file, isFromCurrentSender: isFromCurrentSender, - chatroom: self?.chatroom + chatroom: self?.chatroom, + saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true ) } catch { self?.dialog.send(.alert(error.localizedDescription)) @@ -787,7 +764,8 @@ final class ChatViewModel: NSObject { chatroom: chatroom, havePartnerName: havePartnerName, previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), - fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy() + fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + saveEncrypted: filesStorageProprieties.saveFileEncrypted() ) } @@ -1271,6 +1249,41 @@ 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, + let fileDTO = try? filesStorage.getFile(with: file.file.id) else { + return nil + } + + let data = try? chatFileService.getDecodedData( + file: fileDTO, + nonce: 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.type, + resolution: nil, + data: data + ) + } + + dialog.send(.progress(false)) + let index = files.firstIndex(where: { $0.assetId == id }) ?? .zero + presentDocumentViewerVC.send((files, index)) + } } private extension ChatMessage { diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index 268f8a061..5abe18894 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -22,6 +22,7 @@ struct StorageUsageView: View { List { storageSection autoDownloadSection + saveEncryptedSection } .listStyle(.insetGrouped) .navigationTitle(storageTitle) @@ -69,6 +70,24 @@ private extension StorageUsageView { ) } + 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) @@ -133,3 +152,5 @@ 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 index 8ae643592..c4f80709e 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -44,7 +44,8 @@ final class StorageUsageViewModel: ObservableObject { @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 @@ -72,9 +73,15 @@ final class StorageUsageViewModel: ObservableObject { 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) diff --git a/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift index 2ed8f86c8..9029cb59e 100644 --- a/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProprietiesProtocol.swift @@ -13,4 +13,6 @@ protocol FilesStorageProprietiesProtocol { func setAutoDownloadPreview(_ value: DownloadPolicy) func autoDownloadFullMediaPolicy() -> DownloadPolicy func setAutoDownloadFullMedia(_ value: DownloadPolicy) + func saveFileEncrypted() -> Bool + func setSaveFileEncrypted(_ value: Bool) } diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/Adamant/ServiceProtocols/FilesStorageProtocol.swift index 99c11163a..038bfbdff 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/Adamant/ServiceProtocols/FilesStorageProtocol.swift @@ -10,30 +10,41 @@ import Foundation import UIKit import CommonKit import FilesStorageKit +import Combine protocol FilesStorageProtocol { - func getPreview(for id: String, type: String) -> UIImage? + func cacheImageToMemoryIfNeeded(id: String, data: Data) -> UIImage? - func isCached(_ id: String) -> Bool + func getPreview(for id: String) -> UIImage? + + func isCachedLocally(_ id: String) -> Bool + + func isCachedInMemory(_ id: String) -> Bool func getFileURL(with id: String) throws -> URL - func cacheFile( - id: String, + func getFile(with id: String) throws -> FilesStorageKit.File + + func cacheTemporaryFile( url: URL, - ownerId: String, - recipientId: String - ) throws + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) func cacheFile( id: String, - data: Data, + fileExtension: String, + url: URL?, + decodedData: Data, + encodedData: Data, ownerId: String, - recipientId: String + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + isPreview: Bool ) throws - func cacheTemporaryFile(url: URL) - func getCacheSize() throws -> Int64 func clearCache() throws diff --git a/Adamant/Services/FilesStorageProprietiesService.swift b/Adamant/Services/FilesStorageProprietiesService.swift index 1e7bfb35d..d548e39c2 100644 --- a/Adamant/Services/FilesStorageProprietiesService.swift +++ b/Adamant/Services/FilesStorageProprietiesService.swift @@ -22,7 +22,9 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { 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) { @@ -48,22 +50,43 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { 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.autoDownloadPreviewEnabled + StoreKey.storage.autoDownloadPreview ) else { return autoDownloadPreviewDefaultState } @@ -72,7 +95,7 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { } func setAutoDownloadPreview(_ value: DownloadPolicy) { - securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadPreviewEnabled) + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadPreview) autoDownloadPreviewState = value } @@ -82,7 +105,7 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { func getAutoDownloadFullMedia() -> DownloadPolicy { guard let result: String = securedStore.get( - StoreKey.storage.autoDownloadFullMediaEnabled + StoreKey.storage.autoDownloadFullMedia ) else { return autoDownloadFullMediaDefaultState } @@ -91,7 +114,7 @@ final class FilesStorageProprietiesService: FilesStorageProprietiesProtocol { } func setAutoDownloadFullMedia(_ value: DownloadPolicy) { - securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadFullMediaEnabled) + securedStore.set(value.rawValue, for: StoreKey.storage.autoDownloadFullMedia) autoDownloadFullMediaState = value } } diff --git a/CommonKit/Sources/CommonKit/AdamantDynamicResources.swift b/CommonKit/Sources/CommonKit/AdamantDynamicResources.swift index 08da8dadf..5aebf8810 100644 --- a/CommonKit/Sources/CommonKit/AdamantDynamicResources.swift +++ b/CommonKit/Sources/CommonKit/AdamantDynamicResources.swift @@ -15,7 +15,10 @@ Node(url: URL(string: "http://5.161.53.74:36666")!), Node(url: URL(string: "http://184.94.215.92:45555")!), Node(url: URL(string: "https://node1.adamant.business")!, altUrl: URL(string: "http://194.233.75.29:45555")), Node(url: URL(string: "https://node2.blockchain2fa.io")!), -Node(url: URL(string: "https://sunshine.adamant.im")!), +Node(url: URL(string: "https://phecda.adm.im")!, altUrl: URL(string: "http://46.250.234.248:36666")), +Node(url: URL(string: "https://tegmine.adm.im")!, altUrl: URL(string: "http://5.104.87.219:36666")), +Node(url: URL(string: "https://tauri.adm.im")!, altUrl: URL(string: "http://154.26.159.245:36666")), +Node(url: URL(string: "https://dschubba.adm.im")!, altUrl: URL(string: "http://85.239.234.17:36666")), ] } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index c96d3807e..e21d1b4cc 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -733,6 +733,12 @@ /* 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 8e8d89083..3d11c7ebd 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -718,6 +718,12 @@ /* NodesEditor: Failed to build URL alert */ "NodesEditor.FailedToBuildURL" = "Invalid host"; +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Title" = "Keep the files encrypted"; + +/* Storage: Save Encrypted */ +"Storage.SaveEncrypted.Description" = "Store the files in encrypted form in local storage (May impact performance)"; + /* Storage: Auto download preview */ "Storage.AutoDownloadPreview.Title" = "Preview"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index fc75d9223..0c8f9accc 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -718,6 +718,12 @@ /* 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" = "Превью"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 02000cf33..e510c2f41 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -676,6 +676,12 @@ /* 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" = "预览"; diff --git a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift index 67705620a..7d40ff833 100644 --- a/CommonKit/Sources/CommonKit/Core/SecuredStore.swift +++ b/CommonKit/Sources/CommonKit/Core/SecuredStore.swift @@ -58,8 +58,9 @@ public extension StoreKey { } enum storage { - public static let autoDownloadPreviewEnabled = "autoDownloadPreviewEnabled" - public static let autoDownloadFullMediaEnabled = "autoDownloadFullMediaEnabled" + public static let autoDownloadPreview = "autoDownloadPreviewEnabled" + public static let autoDownloadFullMedia = "autoDownloadFullMediaEnabled" + public static let saveFileEncrypted = "saveFileEncrypted" } } diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index 9583fe56f..41dd16902 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -31,10 +31,12 @@ public struct FileResult { 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 init( assetId: String? = nil, @@ -42,19 +44,23 @@ public struct FileResult { type: FileType, preview: UIImage?, previewUrl: URL?, + previewExtension: String?, size: Int64, name: String?, extenstion: String?, - resolution: CGSize? + resolution: CGSize?, + data: Data? = 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 } } diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index 5ca1a550a..e177b00af 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -59,6 +59,7 @@ public enum RichContentKeys { public static let type = "type" public static let name = "name" public static let preview = "preview" + public static let `extension` = "extension" } } @@ -95,18 +96,22 @@ 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 + 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] { @@ -120,6 +125,10 @@ public struct RichMessageFile: RichMessage { contentDict[RichContentKeys.file.nonce] = nonce } + if !nonce.isEmpty { + contentDict[RichContentKeys.file.extension] = `extension` + } + return contentDict } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift index debab7804..2d2e37200 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift @@ -8,6 +8,8 @@ import AVFoundation import QuickLook final class FilesPickerKitHelper { + var previewExtension = "jpeg" + func validateFiles(_ files: [FileResult]) throws { guard files.count <= FilesConstants.maxFilesCount else { throw FileValidationError.tooManyFiles @@ -143,6 +145,7 @@ final class FilesPickerKitHelper { type: .other, preview: preview.image, previewUrl: preview.url, + previewExtension: previewExtension, size: fileSize, name: url.lastPathComponent, extenstion: url.pathExtension, diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index a8ea3b432..482c7f63d 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -33,7 +33,11 @@ public final class DocumentInteractionService: NSObject { try? FileManager.default.removeItem(at: copyURL) } - try? FileManager.default.copyItem(at: file.url, to: 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) } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index 0630f62fd..c634e75db 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -72,7 +72,8 @@ private extension MediaPickerService { url: url, type: .image, preview: resizedPreview, - previewUrl: previewUrl, + previewUrl: previewUrl, + previewExtension: helper.previewExtension, size: fileSize, name: itemProvider.suggestedName, extenstion: url.pathExtension, @@ -105,6 +106,7 @@ private extension MediaPickerService { type: .video, preview: thumbnailImage, previewUrl: previewUrl, + previewExtension: helper.previewExtension, size: fileSize, name: itemProvider.suggestedName, extenstion: url.pathExtension, diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index c7a2f2ad8..8a0e02454 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -3,73 +3,112 @@ import CommonKit import UIKit +import Combine public final class FilesStorageKit { - @Atomic private var cachedFilesUrl: [String: URL] = [:] - private var cachedFiles: NSCache = NSCache() + 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, type: String) -> UIImage? { + public func getPreview(for id: String) -> UIImage? { guard !id.isEmpty else { return nil } - if let image = cachedFiles.object(forKey: id as NSString) { + if let image = cachedImages.object(forKey: id as NSString) { return image } - guard let url = cachedFilesUrl[id], - let image = UIImage(contentsOfFile: url.path) else { + 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 } - cachedFiles.setObject(image, forKey: id as NSString) + cachedImages.setObject(image, forKey: id as NSString) return image } - public func isCached(_ id: String) -> Bool { - cachedFilesUrl[id] != nil + public func isCachedInMemory(_ id: String) -> Bool { + cachedImages.object(forKey: id as NSString) != nil } - public func getFileURL(with id: String) throws -> URL { - guard let url = cachedFilesUrl[id] else { + public func isCachedLocally(_ id: String) -> Bool { + cachedFiles[id] != nil + } + + public func getFile(with id: String) throws -> File { + guard let file = cachedFiles[id] else { throw FileValidationError.fileNotFound } - return url + return file } - public func cacheFile( - id: String, - url: URL, - ownerId: String, - recipientId: String - ) throws { - try cacheFile( - with: id, - localUrl: url, - ownerId: ownerId, - recipientId: recipientId - ) + public func getFileURL(with id: String) throws -> URL { + try getFile(with: id).url } public func cacheFile( id: String, - data: Data, + fileExtension: String, + url: URL?, + decodedData: Data, + encodedData: Data, ownerId: String, - recipientId: String + recipientId: String, + saveEncrypted: Bool, + fileType: FileType, + isPreview: Bool ) throws { - try cacheFile( + try saveFileLocally( with: id, - data: data, + fileExtension: fileExtension, + data: saveEncrypted ? encodedData : decodedData, + localUrl: url, ownerId: ownerId, - recipientId: recipientId + 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) { - cacheTemporaryFile(with: url) + public func cacheTemporaryFile( + url: URL, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool + ) { + cacheTemporaryFile( + with: url, + isEncrypted: isEncrypted, + fileType: fileType, + isPreview: isPreview + ) } public func getCacheSize() throws -> Int64 { @@ -99,8 +138,8 @@ public final class FilesStorageKit { try clearTempCache() - cachedFiles.removeAllObjects() - cachedFilesUrl.removeAll() + cachedImages.removeAllObjects() + cachedFiles.removeAll() } public func removeTempFiles(at urls: [URL]) { @@ -140,11 +179,30 @@ private extension FilesStorageKit { let files = getAllFiles(in: folder) + var previewFiles: [File] = [] + files.forEach { url in - cachedFilesUrl[url.lastPathComponent] = url + 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 let data = UIImage(contentsOfFile: url.path) { - self.cachedFiles.setObject(data, forKey: url.lastPathComponent as NSString) + 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) } } } @@ -172,19 +230,44 @@ private extension FilesStorageKit { return fileURLs } - func cacheTemporaryFile(with url: URL) { - cachedFilesUrl[url.absoluteString] = url - if let uiImage = UIImage(contentsOfFile: url.path) { - cachedFiles.setObject(uiImage, forKey: url.absoluteString as NSString) + 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[file.id] = file + + if fileType == .image, + isPreview, + let uiImage = UIImage(contentsOfFile: url.path) { + cachedImages.setObject(uiImage, forKey: file.id as NSString) } } - func cacheFile( + 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 + recipientId: String, + isEncrypted: Bool, + fileType: FileType, + isPreview: Bool ) throws { let folder = try FileManager.default.url( for: .cachesDirectory, @@ -195,27 +278,37 @@ private extension FilesStorageKit { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - let fileURL = folder.appendingPathComponent(id) - + 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]) - - cachedFilesUrl[id] = fileURL - if let uiImage = UIImage(data: data) { - cachedFiles.setObject(uiImage, forKey: id as NSString) - } } if let url = localUrl { - try FileManager.default.moveItem(at: url, to: fileURL) - - cachedFilesUrl[id] = fileURL - cachedFilesUrl[url.absoluteString] = fileURL - if let uiImage = UIImage(contentsOfFile: fileURL.path) { - cachedFiles.setObject(uiImage, forKey: id as NSString) - cachedFiles.setObject(uiImage, forKey: url.absoluteString as NSString) - } + try FileManager.default.removeItem(at: url) + cachedFiles[url.absoluteString] = file } + + cachedFiles[id] = file } func folderSize(at url: URL) throws -> Int64 { @@ -242,6 +335,19 @@ private extension FilesStorageKit { return 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" From 935b9636d3a199e7ca500362acb1a851926ad847 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 10 May 2024 15:19:54 +0300 Subject: [PATCH 088/123] [trello.com/c/uxBZaznD] feat: new style for file raw text --- Adamant/Helpers/Markdown+Adamant.swift | 48 +++++++++++++++++++ Adamant/Helpers/UIFont+adamant.swift | 1 + .../Chat/ViewModel/ChatFileService.swift | 1 - .../Chat/ViewModel/ChatMessageFactory.swift | 8 +++- .../ChatsList/ChatListViewController.swift | 6 ++- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/Adamant/Helpers/Markdown+Adamant.swift b/Adamant/Helpers/Markdown+Adamant.swift index 4ab3410a8..e8038aabf 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?" + } + + 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/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/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index af63e3c5f..2b233ca15 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -461,7 +461,6 @@ private extension ChatFileService { nonce: String, chatroom: Chatroom? ) throws -> UIImage? { - print("try to cache \(id), is main thread = \(Thread.isMainThread), file=\(file)") let data = try Data(contentsOf: file.url) guard file.isEncrypted else { diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index b6f1fbd8e..fd3592f10 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -38,7 +38,9 @@ struct ChatMessageFactory { font: .adamantCodeDefault, textHighlightColor: .adamant.codeBlockText, textBackgroundColor: .adamant.codeBlock - ) + ), + MarkdownFileRaw(emoji: "📸", font: .adamantChatFileRawDefault), + MarkdownFileRaw(emoji: "📄", font: .adamantChatFileRawDefault) ] ) @@ -60,7 +62,9 @@ struct ChatMessageFactory { MarkdownAdvancedAdm( font: .adamantChatDefault, color: .adamant.active - ) + ), + MarkdownFileRaw(emoji: "📸", font: .adamantChatFileRawDefault), + MarkdownFileRaw(emoji: "📄", font: .adamantChatFileRawDefault) ] ) diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 18d4d24d0..820f8d286 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, From c9de1a1253133eda8e125f9cef3dea2d3edf3e3b Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 10 May 2024 21:19:59 +0300 Subject: [PATCH 089/123] [trello.com/c/uxBZaznD] fix: reload the cache when the app becomes active --- .../Modules/Chat/View/ChatViewController.swift | 9 +++++++++ .../Modules/Chat/ViewModel/ChatFileService.swift | 1 + .../Modules/Chat/ViewModel/ChatViewModel.swift | 16 ++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 989a15c65..3803daaee 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -269,6 +269,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() } diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 2b233ca15..f0be2be0e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -455,6 +455,7 @@ private extension ChatFileService { cached: nil )) } + func cacheFileToMemory( id: String, file: FilesStorageKit.File, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index c8bb488c9..12996d0d8 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -823,6 +823,22 @@ final class ChatViewModel: NSObject { 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 } + + model.value.content.fileModel.files.forEach { file in + downloadPreviewIfNeeded( + messageId: message.messageId, + file: file, + isFromCurrentSender: file.isFromCurrentSender + ) + } + } + } } extension ChatViewModel { From 3e0c96aef342dd0903f5db7c406b39f8b30c4246 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 14 May 2024 12:02:39 +0300 Subject: [PATCH 090/123] [trello.com/c/uxBZaznD] feat: replaced ipfs nodes & api path --- Adamant/Services/FilesNetworkManager/IPFS+Constants.swift | 6 +++--- Adamant/Services/FilesNetworkManager/IPFSApiService.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift index afdd34a54..f04297300 100644 --- a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift +++ b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift @@ -24,9 +24,9 @@ extension IPFSApiService { static var nodes: [Node] { [ - Node(url: URL(string: "http://194.163.154.252:4000")!), - Node(url: URL(string: "http://154.26.159.245:4000")!), - Node(url: URL(string: "http://109.123.240.102:4000")!) + Node(url: URL(string: "https://ipfs1test.adamant.im")!), + Node(url: URL(string: "https://ipfs2test.adamant.im")!), + Node(url: URL(string: "https://ipfs3test.adamant.im")!) ] } diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 841f99def..6e7e91290 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -11,9 +11,9 @@ import CommonKit enum IPFSApiCommands { static let file = ( - upload: "file/upload", - download: "file/", - field: "files" + upload: "api/file/upload", + download: "api/file/", + field: "api/files" ) } From 40b0b87dcca83b1e9d1736cd79c4bb66437ca10f Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 15 May 2024 18:36:06 +0300 Subject: [PATCH 091/123] [trello.com/c/uxBZaznD] feat: improved performance --- Adamant/App/DI/AppAssembly.swift | 1 - Adamant/Models/ApiServiceError.swift | 4 +- .../Modules/Chat/View/Helpers/ChatFile.swift | 4 + .../ChatFileContainerView/ChatFileView.swift | 7 +- .../MediaContainerView/MediaContentView.swift | 7 +- .../Chat/ViewModel/ChatFileService.swift | 267 ++++++++++++------ .../Chat/ViewModel/ChatViewModel.swift | 100 +++---- 7 files changed, 219 insertions(+), 171 deletions(-) diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index d31e76aee..598bf791a 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -136,7 +136,6 @@ struct AppAssembly: Assembly { ) }.inObjectScope(.container) - // MARK: FilesNetworkManagerProtocol container.register(FilesNetworkManagerProtocol.self) { r in FilesNetworkManager(ipfsService: r.resolve(IPFSApiService.self)!) 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/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index d909f099f..4b7f72d4e 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -21,6 +21,10 @@ struct ChatFile: Equatable, Hashable { var isFromCurrentSender: Bool var fileType: FileType + var isBusy: Bool { + return isDownloading || isUploading + } + static let `default` = Self( file: .init([:]), previewImage: nil, diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 9e95b56e1..f822ce274 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -164,9 +164,9 @@ private extension ChatFileView { iconImageView.image = image } - downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading + downloadImageView.isHidden = model.isCached || model.isBusy - if model.isDownloading || model.isUploading { + if model.isBusy { spinner.startAnimating() } else { spinner.stopAnimating() @@ -184,8 +184,7 @@ private extension ChatFileView { videoIconIV.isHidden = !( model.isCached - && !model.isDownloading - && !model.isUploading + && !model.isBusy && model.fileType == .video ) } 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 index 28f1ca8ad..cda5049dd 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -115,16 +115,15 @@ private extension MediaContentView { imageView.image = image } - downloadImageView.isHidden = model.isCached || model.isDownloading || model.isUploading + downloadImageView.isHidden = model.isCached || model.isBusy videoIconIV.isHidden = !( model.isCached - && !model.isDownloading - && !model.isUploading + && !model.isBusy && model.fileType == .video ) - if model.isDownloading || model.isUploading { + if model.isBusy { spinner.startAnimating() } else { spinner.stopAnimating() diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index f0be2be0e..551766228 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -13,19 +13,17 @@ import Combine import FilesStorageKit protocol ChatFileProtocol { - var downloadingFilesIDs: Published<[String]>.Publisher { - get - } - - var uploadingFilesIDs: Published<[String]>.Publisher { - get - } + var downloadingFiles: [String] { get } + var uploadingFiles: [String] { get } var updateFileFields: PassthroughSubject<( id: String, newId: String?, preview: UIImage?, - cached: Bool? + needUpdatePreview: Bool, + cached: Bool?, + downloading: Bool?, + uploading: Bool? ), Never> { get } @@ -53,7 +51,7 @@ protocol ChatFileProtocol { previewDownloadPolicy: DownloadPolicy, fullMediaDownloadPolicy: DownloadPolicy, saveEncrypted: Bool - ) + ) async func getDecodedData( file: FilesStorageKit.File, @@ -73,24 +71,32 @@ final class ChatFileService: ChatFileProtocol { private let filesNetworkManager: FilesNetworkManagerProtocol private let adamantCore: AdamantCore - @Published private var downloadingFilesIDsArray: [String] = [] - @Published private var uploadingFilesIDsArray: [String] = [] + @Atomic private var downloadingFilesIDsArray: [String] = [] + @Atomic private var uploadingFilesIDsArray: [String] = [] + @Atomic private var ignoreFilesIDsArray: [String] = [] + @Atomic private var busyFilesIDs: [String] = [] + @Atomic private var fileDownloadAttemptsCount: [String: Int] = [:] - private var ignoreFilesIDsArray: [String] = [] private var subscriptions = Set() private let maxDownloadAttemptsCount = 3 - - @Atomic private var fileDownloadAttemptsCount: [String: Int] = [:] - - var downloadingFilesIDs: Published<[String]>.Publisher { - $downloadingFilesIDsArray + + var uploadingFiles: [String] { + uploadingFilesIDsArray } - var uploadingFilesIDs: Published<[String]>.Publisher { - $uploadingFilesIDsArray + var downloadingFiles: [String] { + downloadingFilesIDsArray } - let updateFileFields = ObservableSender<(id: String, newId: String?, preview: UIImage?, cached: Bool?)>() + let updateFileFields = ObservableSender<( + id: String, + newId: String?, + preview: UIImage?, + needUpdatePreview: Bool, + cached: Bool?, + downloading: Bool?, + uploading: Bool? + )>() init( accountService: AccountService, @@ -184,6 +190,7 @@ final class ChatFileService: ChatFileProtocol { richFiles.forEach { file in uploadingFilesIDsArray.append(file.id) + sendUpdate(for: [file.id], downloading: nil, uploading: true) } do { @@ -230,6 +237,7 @@ final class ChatFileService: ChatFileProtocol { let oldId = file.url.absoluteString uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) + sendUpdate(for: [oldId], downloading: nil, uploading: false) let cached = filesStorage.isCachedLocally(result.file.cid) @@ -237,7 +245,10 @@ final class ChatFileService: ChatFileProtocol { id: oldId, newId: result.file.cid, preview: preview, - cached: cached + needUpdatePreview: true, + cached: cached, + downloading: nil, + uploading: nil )) var previewDTO: RichMessageFile.Preview? @@ -292,6 +303,7 @@ final class ChatFileService: ChatFileProtocol { } catch { richFiles.forEach { file in uploadingFilesIDsArray.removeAll(where: { $0 == file.id }) + sendUpdate(for: [file.id], downloading: nil, uploading: false) } try? await chatsProvider.setTxMessageAsFailed( @@ -327,60 +339,29 @@ final class ChatFileService: ChatFileProtocol { previewDownloadPolicy: DownloadPolicy, fullMediaDownloadPolicy: DownloadPolicy, saveEncrypted: Bool - ) { + ) async { guard !downloadingFilesIDsArray.contains(file.file.id), - !ignoreFilesIDsArray.contains(file.file.id) + !ignoreFilesIDsArray.contains(file.file.id), + !busyFilesIDs.contains(file.file.id) else { return } - Task { - 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, - isFromCurrentSender: isFromCurrentSender, - chatroom: chatroom, - shouldDownloadOriginalFile: shouldDownloadOriginalFile, - shouldDownloadPreviewFile: shouldDownloadPreviewFile, - saveEncrypted: saveEncrypted - ) - } catch { - let count = fileDownloadAttemptsCount[file.file.id] ?? .zero - - guard count >= maxDownloadAttemptsCount else { - fileDownloadAttemptsCount[file.file.id] = count + 1 - autoDownload( - file: file, - isFromCurrentSender: isFromCurrentSender, - chatroom: chatroom, - havePartnerName: havePartnerName, - previewDownloadPolicy: previewDownloadPolicy, - fullMediaDownloadPolicy: fullMediaDownloadPolicy, - saveEncrypted: saveEncrypted - ) - return - } - - ignoreFilesIDsArray.append(file.file.id) - } + defer { + busyFilesIDs.removeAll(where: { $0 == file.file.id }) } + + busyFilesIDs.append(file.file.id) + + await handleAutoDownload( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) } func getDecodedData( @@ -415,19 +396,95 @@ private extension ChatFileService { func addObservers() { NotificationCenter.default .publisher(for: .AdamantReachabilityMonitor.reachabilityChanged) - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak self] data in let connection = data.userInfo?[AdamantUserInfoKey.ReachabilityMonitor.connection] as? Bool - if connection == true { - self?.ignoreFilesIDsArray.removeAll() - } + guard connection == true else { return } + self?.ignoreFilesIDsArray.removeAll() } .store(in: &subscriptions) } } private extension ChatFileService { + func handleAutoDownload( + file: ChatFile, + isFromCurrentSender: Bool, + 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, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + shouldDownloadOriginalFile: shouldDownloadOriginalFile, + shouldDownloadPreviewFile: shouldDownloadPreviewFile, + saveEncrypted: saveEncrypted + ) + } catch { + await handleDownloadError( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) + } + } + + func handleDownloadError( + file: ChatFile, + isFromCurrentSender: Bool, + chatroom: Chatroom?, + havePartnerName: Bool, + previewDownloadPolicy: DownloadPolicy, + fullMediaDownloadPolicy: DownloadPolicy, + saveEncrypted: Bool + ) async { + let count = fileDownloadAttemptsCount[file.file.id] ?? .zero + + guard count < maxDownloadAttemptsCount else { + ignoreFilesIDsArray.append(file.file.id) + return + } + + fileDownloadAttemptsCount[file.file.id] = count + 1 + + await handleAutoDownload( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: previewDownloadPolicy, + fullMediaDownloadPolicy: fullMediaDownloadPolicy, + saveEncrypted: saveEncrypted + ) + } + func cacheFileToMemoryIfNeeded( file: ChatFile, chatroom: Chatroom? @@ -438,12 +495,12 @@ private extension ChatFileService { fileDTO.isPreview, filesStorage.isCachedLocally(id), !filesStorage.isCachedInMemory(id), - let image = try? cacheFileToMemory( - id: id, - file: fileDTO, - nonce: nonce, - chatroom: chatroom - ) + let image = try? cacheFileToMemory( + id: id, + file: fileDTO, + nonce: nonce, + chatroom: chatroom + ) else { return } @@ -452,7 +509,10 @@ private extension ChatFileService { id: file.file.id, newId: nil, preview: image, - cached: nil + needUpdatePreview: true, + cached: nil, + downloading: nil, + uploading: nil )) } @@ -501,10 +561,11 @@ private extension ChatFileService { defer { downloadingFilesIDsArray.removeAll(where: { $0 == file.file.id }) + sendUpdate(for: [file.file.id], downloading: false, uploading: nil) } - downloadingFilesIDsArray.append(file.file.id) - var preview: UIImage? + downloadingFilesIDsArray.append(file.file.id) + sendUpdate(for: [file.file.id], downloading: true, uploading: nil) if let previewDTO = file.file.preview { if shouldDownloadPreviewFile, @@ -522,19 +583,20 @@ private extension ChatFileService { fileExtension: previewDTO.extension ?? .empty, isPreview: true ) - } - - preview = filesStorage.getPreview(for: previewDTO.id) - - if shouldDownloadPreviewFile { - let cached = filesStorage.isCachedLocally(file.file.id) + + let preview = filesStorage.getPreview(for: previewDTO.id) updateFileFields.send(( id: file.file.id, newId: nil, preview: preview, - cached: cached + needUpdatePreview: true, + cached: nil, + downloading: nil, + uploading: nil )) + } else if !filesStorage.isCachedInMemory(previewDTO.id) { + cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) } } @@ -549,8 +611,8 @@ private extension ChatFileService { ownerId: ownerId, recipientId: recipientId, saveEncrypted: saveEncrypted, - fileType: file.fileType, - fileExtension: file.file.type ?? .empty, + fileType: file.fileType, + fileExtension: file.file.type ?? .empty, isPreview: false ) @@ -559,8 +621,11 @@ private extension ChatFileService { updateFileFields.send(( id: file.file.id, newId: nil, - preview: preview, - cached: cached + preview: nil, + needUpdatePreview: false, + cached: cached, + downloading: nil, + uploading: nil )) } } @@ -735,3 +800,19 @@ private extension ChatFileService { return (decodedData, encodedData) } } + +private extension ChatFileService { + func sendUpdate(for files: [String], downloading: Bool?, uploading: Bool?) { + files.forEach { id in + updateFileFields.send(( + id: id, + newId: nil, + preview: nil, + needUpdatePreview: false, + cached: nil, + downloading: downloading, + uploading: uploading + )) + } + } +} diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 12996d0d8..98357d3e6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -135,14 +135,6 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } - @Atomic private var downloadingFilesID: [String] = [] { - didSet { updateDownloadingFiles(&messages) } - } - - @Atomic private var uploadingFilesIDs: [String] = [] { - didSet { updateUploadingFiles(&messages) } - } - init( chatsProvider: ChatsProvider, markdownParser: MarkdownParser, @@ -716,7 +708,7 @@ final class ChatViewModel: NSObject { let message = messages.first(where: { $0.messageId == messageId }) guard tx?.statusEnum == .delivered, - !downloadingFilesID.contains(file.file.id), + !chatFileService.downloadingFiles.contains(file.file.id), case let(.file(fileModel)) = message?.content else { return } @@ -739,6 +731,7 @@ final class ChatViewModel: NSObject { saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true ) } catch { + print("debug downloadFile error=\(error), localized=\(error.localizedDescription)") self?.dialog.send(.alert(error.localizedDescription)) } } @@ -755,18 +748,21 @@ final class ChatViewModel: NSObject { guard let message = message, tx?.statusEnum == .delivered || (message.status != .failed && message.status != .pending), (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || - filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) + filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody), + file.fileType == .image || file.fileType == .video else { return } - chatFileService.autoDownload( - file: file, - isFromCurrentSender: isFromCurrentSender, - chatroom: chatroom, - havePartnerName: havePartnerName, - previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), - fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy(), - saveEncrypted: filesStorageProprieties.saveFileEncrypted() - ) + Task { + await chatFileService.autoDownload( + file: file, + isFromCurrentSender: isFromCurrentSender, + chatroom: chatroom, + havePartnerName: havePartnerName, + previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), + fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) + } } func presentActionMenu() { @@ -908,20 +904,6 @@ private extension ChatViewModel { .sink { [weak self] _ in self?.inputTextUpdated() } .store(in: &subscriptions) - chatFileService.downloadingFilesIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] data in - self?.downloadingFilesID = data - } - .store(in: &subscriptions) - - chatFileService.uploadingFilesIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] data in - self?.uploadingFilesIDs = data - } - .store(in: &subscriptions) - chatFileService.updateFileFields .receive(on: DispatchQueue.main) .sink { [weak self] data in @@ -931,8 +913,10 @@ private extension ChatViewModel { &self.messages, id: data.id, preview: data.preview, - needToUpdatePeview: true, - cached: data.cached + needToUpdatePreview: data.needUpdatePreview, + cached: data.cached, + isUploading: data.uploading, + isDownloading: data.downloading ) } .store(in: &subscriptions) @@ -997,8 +981,8 @@ private extension ChatViewModel { sender: sender, isNeedToLoadMoreMessages: isNeedToLoadMoreMessages, expirationTimestamp: &expirationTimestamp, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesID + uploadingFilesIDs: chatFileService.uploadingFiles, + downloadingFilesIDs: chatFileService.downloadingFiles ) await setupNewMessages( @@ -1208,48 +1192,30 @@ private extension ChatViewModel { } } - func updateDownloadingFiles(_ messages: inout [ChatMessage]) { - messages.indices.forEach { index in - messages[index].getFiles().forEach { file in - messages[index].updateFields( - id: file.file.id, - preview: nil, - needToUpdatePeview: false, - isDownloading: downloadingFilesID.contains(file.file.id) - ) - } - } - } - - func updateUploadingFiles(_ messages: inout [ChatMessage]) { - messages.indices.forEach { index in - messages[index].getFiles().forEach { file in - messages[index].updateFields( - id: file.file.id, - preview: nil, - needToUpdatePeview: false, - isUploading: uploadingFilesIDs.contains(file.file.id) - ) - } - } - } - func updateFileFields( _ messages: inout [ChatMessage], id oldId: String, newId: String? = nil, preview: UIImage?, - needToUpdatePeview: Bool, + needToUpdatePreview: Bool, cached: Bool? = nil, isUploading: Bool? = nil, isDownloading: Bool? = nil ) { - messages.indices.forEach { index in + let indexes = messages.indices.filter { + messages[$0].getFiles().contains { $0.file.id == oldId } + } + + guard !indexes.isEmpty else { + return + } + + indexes.forEach { index in messages[index].updateFields( id: oldId, newId: newId, - preview: preview, - needToUpdatePeview: needToUpdatePeview, + preview: preview, + needToUpdatePeview: needToUpdatePreview, cached: cached, isUploading: isUploading, isDownloading: isDownloading From c2911645fcd81da53de0e3c9af720df1aef40f55 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 16 May 2024 16:25:30 +0300 Subject: [PATCH 092/123] [trello.com/c/uxBZaznD] fix: upload file to a node --- Adamant.xcodeproj/project.pbxproj | 8 ++++++-- Adamant/Models/MultipartFormDataModel.swift | 15 +++++++++++++++ Adamant/ServiceProtocols/APICoreProtocol.swift | 6 +++--- Adamant/Services/APICore.swift | 10 +++++----- .../FilesNetworkManager/IPFSApiService.swift | 13 +++++++++++-- 5 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 Adamant/Models/MultipartFormDataModel.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index cbae3cf32..b12906c87 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 3AA6DF402BA9941E00EA2E16 /* MediaContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */; }; 3AA6DF442BA997C000EA2E16 /* FileContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF432BA997C000EA2E16 /* FileContainerView.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 */; }; @@ -733,6 +734,7 @@ 3AA6DF3F2BA9941E00EA2E16 /* MediaContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainerView.swift; sourceTree = ""; }; 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContainerView.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 = ""; }; @@ -2206,6 +2208,7 @@ 3AA388022B67F47600125684 /* RPCResponseModel.swift */, 3AA3880D2B6A356900125684 /* RpcRequestModel.swift */, 936658902B0AB9DC00BDB2D3 /* NodeWithGroup.swift */, + 3AB87CD52BF6237100AE8743 /* MultipartFormDataModel.swift */, ); path = Models; sourceTree = ""; @@ -3372,6 +3375,7 @@ 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 */, 93B28ECA2B076E88007F268B /* DashErrorDTO.swift in Sources */, @@ -3948,7 +3952,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6.0; + MARKETING_VERSION = 3.7.0; PRODUCT_BUNDLE_IDENTIFIER = "im.adamant.adamant-messenger-dev"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3979,7 +3983,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6.0; + MARKETING_VERSION = 3.7.0; PRODUCT_BUNDLE_IDENTIFIER = "im.adamant.adamant-messenger"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; 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/ServiceProtocols/APICoreProtocol.swift b/Adamant/ServiceProtocols/APICoreProtocol.swift index f4a235bac..daf622b04 100644 --- a/Adamant/ServiceProtocols/APICoreProtocol.swift +++ b/Adamant/ServiceProtocols/APICoreProtocol.swift @@ -17,7 +17,7 @@ protocol APICoreProtocol: Actor { func sendRequestMultipartFormData( node: Node, path: String, - data: [String: Data] + models: [MultipartFormDataModel] ) async -> APIResponseModel func sendRequestBasic( @@ -115,12 +115,12 @@ extension APICoreProtocol { func sendRequestMultipartFormDataJsonResponse( node: Node, path: String, - data: [String: Data] + models: [MultipartFormDataModel] ) async -> ApiServiceResult { await sendRequestMultipartFormData( node: node, path: path, - data: data + models: models ).result.flatMap { parseJSON(data: $0) } } diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index f3b9c759c..b90b1ff0e 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -28,15 +28,15 @@ actor APICore: APICoreProtocol { func sendRequestMultipartFormData( node: Node, path: String, - data: [String: Data] + models: [MultipartFormDataModel] ) async -> APIResponseModel { do { let request = AF.upload(multipartFormData: { multipartFormData in - data.forEach { file in + models.forEach { file in multipartFormData.append( - file.value, - withName: file.key, - fileName: "file" + file.data, + withName: file.keyName, + fileName: file.fileName ) } }, to: try buildUrl(node: node, path: path)) diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 6e7e91290..71144e8c4 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -13,7 +13,7 @@ enum IPFSApiCommands { static let file = ( upload: "api/file/upload", download: "api/file/", - field: "api/files" + fieldName: "files" ) } @@ -35,11 +35,18 @@ final class IPFSApiService: FileApiServiceProtocol { } func uploadFile(data: Data) async throws -> String { + let model: MultipartFormDataModel = .init( + keyName: IPFSApiCommands.file.fieldName, + fileName: defaultFileName, + data: data + ) + let result: IpfsDTO = try await request { core, node in await core.sendRequestMultipartFormDataJsonResponse( node: node, path: IPFSApiCommands.file.upload, - data: [IPFSApiCommands.file.field: data]) + models: [model] + ) }.get() guard let cid = result.cids.first else { @@ -60,3 +67,5 @@ final class IPFSApiService: FileApiServiceProtocol { return result } } + +private let defaultFileName = "fileName" From 419ce2231eed47828742750155f3a07098f2e16a Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 17 May 2024 10:51:15 +0300 Subject: [PATCH 093/123] [trello.com/c/uxBZaznD] feat: open file in full screen if its uploaded (message not sent yet) --- .../Chat/ViewModel/ChatFileService.swift | 10 ++++++++-- .../Modules/Chat/ViewModel/ChatViewModel.swift | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 551766228..344e889d6 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -19,6 +19,7 @@ protocol ChatFileProtocol { var updateFileFields: PassthroughSubject<( id: String, newId: String?, + fileNonce: String?, preview: UIImage?, needUpdatePreview: Bool, cached: Bool?, @@ -91,6 +92,7 @@ final class ChatFileService: ChatFileProtocol { let updateFileFields = ObservableSender<( id: String, newId: String?, + fileNonce: String?, preview: UIImage?, needUpdatePreview: Bool, cached: Bool?, @@ -237,18 +239,18 @@ final class ChatFileService: ChatFileProtocol { let oldId = file.url.absoluteString uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) - sendUpdate(for: [oldId], downloading: nil, uploading: false) let cached = filesStorage.isCachedLocally(result.file.cid) updateFileFields.send(( id: oldId, newId: result.file.cid, + fileNonce: result.file.nonce, preview: preview, needUpdatePreview: true, cached: cached, downloading: nil, - uploading: nil + uploading: false )) var previewDTO: RichMessageFile.Preview? @@ -508,6 +510,7 @@ private extension ChatFileService { updateFileFields.send(( id: file.file.id, newId: nil, + fileNonce: nil, preview: image, needUpdatePreview: true, cached: nil, @@ -589,6 +592,7 @@ private extension ChatFileService { updateFileFields.send(( id: file.file.id, newId: nil, + fileNonce: nil, preview: preview, needUpdatePreview: true, cached: nil, @@ -621,6 +625,7 @@ private extension ChatFileService { updateFileFields.send(( id: file.file.id, newId: nil, + fileNonce: nil, preview: nil, needUpdatePreview: false, cached: cached, @@ -807,6 +812,7 @@ private extension ChatFileService { updateFileFields.send(( id: id, newId: nil, + fileNonce: nil, preview: nil, needUpdatePreview: false, cached: nil, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 98357d3e6..8b3b8a7f0 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -707,8 +707,8 @@ final class ChatViewModel: NSObject { let tx = chatTransactions.first(where: { $0.txId == messageId }) let message = messages.first(where: { $0.messageId == messageId }) - guard tx?.statusEnum == .delivered, - !chatFileService.downloadingFiles.contains(file.file.id), + guard !chatFileService.downloadingFiles.contains(file.file.id), + !chatFileService.uploadingFiles.contains(file.file.id), case let(.file(fileModel)) = message?.content else { return } @@ -722,6 +722,8 @@ final class ChatViewModel: NSObject { return } + guard tx?.statusEnum == .delivered else { return } + Task { [weak self] in do { try await self?.chatFileService.downloadFile( @@ -731,7 +733,6 @@ final class ChatViewModel: NSObject { saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true ) } catch { - print("debug downloadFile error=\(error), localized=\(error.localizedDescription)") self?.dialog.send(.alert(error.localizedDescription)) } } @@ -912,6 +913,8 @@ private extension ChatViewModel { self.updateFileFields( &self.messages, id: data.id, + newId: data.newId, + fileNonce: data.fileNonce, preview: data.preview, needToUpdatePreview: data.needUpdatePreview, cached: data.cached, @@ -1196,6 +1199,7 @@ private extension ChatViewModel { _ messages: inout [ChatMessage], id oldId: String, newId: String? = nil, + fileNonce: String? = nil, preview: UIImage?, needToUpdatePreview: Bool, cached: Bool? = nil, @@ -1214,6 +1218,7 @@ private extension ChatViewModel { messages[index].updateFields( id: oldId, newId: newId, + fileNonce: fileNonce, preview: preview, needToUpdatePeview: needToUpdatePreview, cached: cached, @@ -1237,13 +1242,14 @@ private extension ChatViewModel { let files: [FileResult] = chatFiles.compactMap { file in guard file.isCached, + !file.isBusy, let fileDTO = try? filesStorage.getFile(with: file.file.id) else { return nil } let data = try? chatFileService.getDecodedData( file: fileDTO, - nonce: file.nonce, + nonce: file.file.nonce, chatroom: chatroom ) @@ -1313,6 +1319,7 @@ private extension ChatMessage { mutating func updateFields( id oldId: String, newId: String? = nil, + fileNonce: String? = nil, preview: UIImage?, needToUpdatePeview: Bool, cached: Bool? = nil, @@ -1329,6 +1336,9 @@ private extension ChatMessage { if let newId = newId { model.content.fileModel.files[index].file.id = newId } + if let fileNonce = fileNonce { + model.content.fileModel.files[index].file.nonce = fileNonce + } if let value = cached { model.content.fileModel.files[index].isCached = value } From 29c045d148e26ab4abf8f4010042abc874d0ccf4 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 17 May 2024 13:57:48 +0300 Subject: [PATCH 094/123] [trello.com/c/uxBZaznD] fix: data racing --- .../Chat/View/Managers/ChatAction.swift | 2 +- .../View/Managers/ChatDataSourceManager.swift | 7 ++- .../FileContainerView.swift | 12 ++--- .../MediaContainerView.swift | 12 ++--- .../Chat/ViewModel/ChatFileService.swift | 51 ++++++++----------- .../Chat/ViewModel/ChatViewModel.swift | 43 ++++++++-------- .../FilesStorageKit/FilesStorageKit.swift | 6 +-- 7 files changed, 61 insertions(+), 72 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index fe2c1d09f..56f96b6de 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -22,5 +22,5 @@ enum ChatAction { case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) case openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) - case downloadPreviewIfNeeded(messageId: String, file: ChatFile, isFromCurrentSender: Bool) + case downloadPreviewIfNeeded(messageId: String, files: [ChatFile], isFromCurrentSender: Bool) } diff --git a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift index d6ec28bfc..624c87f7e 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -196,8 +196,11 @@ private extension ChatDataSourceManager { viewModel.copyTextInPartAction(text) case let .openFile(messageId, file, isFromCurrentSender): viewModel.openFile(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) - case let .downloadPreviewIfNeeded(messageId, file, isFromCurrentSender): - viewModel.downloadPreviewIfNeeded(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) + case let .downloadPreviewIfNeeded(messageId, files, _): + viewModel.downloadPreviewIfNeeded( + messageId: messageId, + files: files + ) } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index 3c4ae97e9..abefd9816 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -59,13 +59,11 @@ private extension FileContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) - fileList.forEach { file in - actionHandler(.downloadPreviewIfNeeded( - messageId: model.messageId, - file: file, - isFromCurrentSender: model.isFromCurrentSender - )) - } + actionHandler(.downloadPreviewIfNeeded( + messageId: model.messageId, + files: Array(fileList), + isFromCurrentSender: model.isFromCurrentSender + )) filesStack.arrangedSubviews.forEach { $0.isHidden = true } 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 index fc1e6e816..b9dcd4ff6 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -82,13 +82,11 @@ private extension MediaContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) - fileList.forEach { file in - actionHandler(.downloadPreviewIfNeeded( - messageId: model.messageId, - file: file, - isFromCurrentSender: model.isFromCurrentSender - )) - } + actionHandler(.downloadPreviewIfNeeded( + messageId: model.messageId, + files: Array(fileList), + isFromCurrentSender: model.isFromCurrentSender + )) for (index, stackView) in filesStack.arrangedSubviews.enumerated() { guard let horizontalStackView = stackView as? UIStackView else { continue } diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 344e889d6..a8b497b50 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -39,14 +39,12 @@ protocol ChatFileProtocol { func downloadFile( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, saveEncrypted: Bool ) async throws func autoDownload( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, @@ -82,11 +80,11 @@ final class ChatFileService: ChatFileProtocol { private let maxDownloadAttemptsCount = 3 var uploadingFiles: [String] { - uploadingFilesIDsArray + $uploadingFilesIDsArray.wrappedValue } var downloadingFiles: [String] { - downloadingFilesIDsArray + $downloadingFilesIDsArray.wrappedValue } let updateFileFields = ObservableSender<( @@ -191,7 +189,7 @@ final class ChatFileService: ChatFileProtocol { ) richFiles.forEach { file in - uploadingFilesIDsArray.append(file.id) + $uploadingFilesIDsArray.mutate { $0.append(file.id) } sendUpdate(for: [file.id], downloading: nil, uploading: true) } @@ -238,8 +236,9 @@ final class ChatFileService: ChatFileProtocol { } let oldId = file.url.absoluteString - uploadingFilesIDsArray.removeAll(where: { $0 == oldId }) - + $uploadingFilesIDsArray.mutate { + $0.removeAll(where: { $0 == oldId }) + } let cached = filesStorage.isCachedLocally(result.file.cid) updateFileFields.send(( @@ -304,7 +303,9 @@ final class ChatFileService: ChatFileProtocol { ) } catch { richFiles.forEach { file in - uploadingFilesIDsArray.removeAll(where: { $0 == file.id }) + $uploadingFilesIDsArray.mutate { + $0.removeAll(where: { $0 == file.id }) + } sendUpdate(for: [file.id], downloading: nil, uploading: false) } @@ -319,13 +320,11 @@ final class ChatFileService: ChatFileProtocol { func downloadFile( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, saveEncrypted: Bool ) async throws { try await downloadFile( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, shouldDownloadOriginalFile: true, shouldDownloadPreviewFile: true, @@ -335,29 +334,27 @@ final class ChatFileService: ChatFileProtocol { func autoDownload( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, fullMediaDownloadPolicy: DownloadPolicy, saveEncrypted: Bool ) async { - guard !downloadingFilesIDsArray.contains(file.file.id), - !ignoreFilesIDsArray.contains(file.file.id), - !busyFilesIDs.contains(file.file.id) + guard !downloadingFiles.contains(file.file.id), + !$ignoreFilesIDsArray.wrappedValue.contains(file.file.id), + !$busyFilesIDs.wrappedValue.contains(file.file.id) else { return } defer { - busyFilesIDs.removeAll(where: { $0 == file.file.id }) + $busyFilesIDs.mutate { $0.removeAll(where: { $0 == file.file.id }) } } - busyFilesIDs.append(file.file.id) + $busyFilesIDs.mutate { $0.append(file.file.id) } await handleAutoDownload( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, havePartnerName: havePartnerName, previewDownloadPolicy: previewDownloadPolicy, @@ -412,7 +409,6 @@ private extension ChatFileService { private extension ChatFileService { func handleAutoDownload( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, @@ -439,7 +435,6 @@ private extension ChatFileService { do { try await downloadFile( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, shouldDownloadOriginalFile: shouldDownloadOriginalFile, shouldDownloadPreviewFile: shouldDownloadPreviewFile, @@ -448,7 +443,6 @@ private extension ChatFileService { } catch { await handleDownloadError( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, havePartnerName: havePartnerName, previewDownloadPolicy: previewDownloadPolicy, @@ -460,25 +454,23 @@ private extension ChatFileService { func handleDownloadError( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, havePartnerName: Bool, previewDownloadPolicy: DownloadPolicy, fullMediaDownloadPolicy: DownloadPolicy, saveEncrypted: Bool ) async { - let count = fileDownloadAttemptsCount[file.file.id] ?? .zero + let count = $fileDownloadAttemptsCount.wrappedValue[file.file.id] ?? .zero guard count < maxDownloadAttemptsCount else { - ignoreFilesIDsArray.append(file.file.id) + $ignoreFilesIDsArray.mutate { $0.append(file.file.id) } return } - fileDownloadAttemptsCount[file.file.id] = count + 1 + $fileDownloadAttemptsCount.mutate { $0[file.file.id] = count + 1 } await handleAutoDownload( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: chatroom, havePartnerName: havePartnerName, previewDownloadPolicy: previewDownloadPolicy, @@ -542,7 +534,6 @@ private extension ChatFileService { func downloadFile( file: ChatFile, - isFromCurrentSender: Bool, chatroom: Chatroom?, shouldDownloadOriginalFile: Bool, shouldDownloadPreviewFile: Bool, @@ -553,7 +544,7 @@ private extension ChatFileService { let recipientId = chatroom?.partner?.address, NetworkFileProtocolType(rawValue: file.storage) != nil, (shouldDownloadOriginalFile || shouldDownloadPreviewFile), - !downloadingFilesIDsArray.contains(file.file.id) + !downloadingFiles.contains(file.file.id) else { return } guard !file.file.id.isEmpty, @@ -563,11 +554,11 @@ private extension ChatFileService { } defer { - downloadingFilesIDsArray.removeAll(where: { $0 == file.file.id }) + $downloadingFilesIDsArray.mutate { $0.removeAll(where: { $0 == file.file.id }) } sendUpdate(for: [file.file.id], downloading: false, uploading: nil) } - downloadingFilesIDsArray.append(file.file.id) + $downloadingFilesIDsArray.mutate { $0.append(file.file.id) } sendUpdate(for: [file.file.id], downloading: true, uploading: nil) if let previewDTO = file.file.preview { @@ -719,7 +710,7 @@ private extension ChatFileService { func needsPreviewDownload(file: ChatFile) -> Bool { if let previewId = file.file.preview?.id, file.file.preview?.nonce != nil, - !ignoreFilesIDsArray.contains(previewId), + !$ignoreFilesIDsArray.wrappedValue.contains(previewId), !filesStorage.isCachedLocally(previewId) { return true } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 1752cd2c3..58e6b4474 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -733,7 +733,6 @@ final class ChatViewModel: NSObject { do { try await self?.chatFileService.downloadFile( file: file, - isFromCurrentSender: isFromCurrentSender, chatroom: self?.chatroom, saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true ) @@ -745,8 +744,7 @@ final class ChatViewModel: NSObject { func downloadPreviewIfNeeded( messageId: String, - file: ChatFile, - isFromCurrentSender: Bool + files: [ChatFile] ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) let message = messages.first(where: { $0.messageId == messageId }) @@ -754,20 +752,24 @@ final class ChatViewModel: NSObject { guard let message = message, tx?.statusEnum == .delivered || (message.status != .failed && message.status != .pending), (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || - filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody), - file.fileType == .image || file.fileType == .video + filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) else { return } - Task { - await chatFileService.autoDownload( - file: file, - isFromCurrentSender: isFromCurrentSender, - chatroom: chatroom, - havePartnerName: havePartnerName, - previewDownloadPolicy: filesStorageProprieties.autoDownloadPreviewPolicy(), - fullMediaDownloadPolicy: filesStorageProprieties.autoDownloadFullMediaPolicy(), - saveEncrypted: filesStorageProprieties.saveFileEncrypted() - ) + 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() + ) + } } } @@ -832,13 +834,10 @@ final class ChatViewModel: NSObject { case let .file(model) = message.content else { return } - model.value.content.fileModel.files.forEach { file in - downloadPreviewIfNeeded( - messageId: message.messageId, - file: file, - isFromCurrentSender: file.isFromCurrentSender - ) - } + downloadPreviewIfNeeded( + messageId: message.messageId, + files: model.value.content.fileModel.files + ) } } } diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 8a0e02454..92e80b820 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -243,7 +243,7 @@ private extension FilesStorageKit { fileType: fileType, isPreview: isPreview ) - cachedFiles[file.id] = file + $cachedFiles.mutate { $0[file.id] = file } if fileType == .image, isPreview, @@ -305,10 +305,10 @@ private extension FilesStorageKit { if let url = localUrl { try FileManager.default.removeItem(at: url) - cachedFiles[url.absoluteString] = file + $cachedFiles.mutate { $0[url.absoluteString] = file } } - cachedFiles[id] = file + $cachedFiles.mutate { $0[id] = file } } func folderSize(at url: URL) throws -> Int64 { From fd53e93d0054322466ec7acc7613e2ffcb165ef2 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 17 May 2024 14:05:14 +0300 Subject: [PATCH 095/123] [trello.com/c/uxBZaznD] fix: show failed dialog --- Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift | 2 +- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 5 +++++ Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift b/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift index 0cf4eaa80..3dbb6e878 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDialogManager.swift @@ -236,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/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 58e6b4474..ae9bb23d4 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -712,6 +712,11 @@ final class ChatViewModel: NSObject { let tx = chatTransactions.first(where: { $0.txId == messageId }) let message = messages.first(where: { $0.messageId == messageId }) + if tx?.statusEnum == .failed { + dialog.send(.failedMessageAlert(id: messageId, sender: nil)) + return + } + guard !chatFileService.downloadingFiles.contains(file.file.id), !chatFileService.uploadingFiles.contains(file.file.id), case let(.file(fileModel)) = message?.content diff --git a/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift b/Adamant/Modules/Chat/ViewModel/Models/ChatDialog.swift index c1e889fef..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, From bb76358fb90d27559d200fa2dd951605b100d9c5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 21 May 2024 13:55:27 +0300 Subject: [PATCH 096/123] [trello.com/c/uxBZaznD] code improvements --- Adamant.xcodeproj/project.pbxproj | 6 +- Adamant/App/DI/AppAssembly.swift | 5 + Adamant/Modules/Chat/ChatFactory.swift | 6 +- .../Chat/View/ChatViewController.swift | 69 ++-------- .../Chat/ViewModel/ChatViewModel.swift | 55 +++++++- .../StorageUsage/StorageUsageFactory.swift | 1 + .../StorageUsage/StorageUsageViewModel.swift | 1 + FilesPickerKit/Package.swift | 5 +- ...erKitHelper.swift => FilesPickerKit.swift} | 120 +++++------------- .../Pickers/DocumentPickerService.swift | 9 +- .../Pickers/DropInteractionService.swift | 9 +- .../Pickers/MediaPickerService.swift | 9 +- ....swift => FilePickerServiceProtocol.swift} | 2 +- .../Protocols/FilesPickerProtocol.swift | 42 ++++++ .../FilesStorageKit/FilesStorageKit.swift | 74 ++++++++++- .../Protocols}/FilesStorageProtocol.swift | 18 +-- 16 files changed, 253 insertions(+), 178 deletions(-) rename FilesPickerKit/Sources/FilesPickerKit/Helpers/{FilesPickerKitHelper.swift => FilesPickerKit.swift} (67%) rename FilesPickerKit/Sources/FilesPickerKit/Protocols/{FilePickerProtocol.swift => FilePickerServiceProtocol.swift} (88%) create mode 100644 FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift rename {Adamant/ServiceProtocols => FilesStorageKit/Sources/FilesStorageKit/Protocols}/FilesStorageProtocol.swift (77%) diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 8816e3c9a..25474cf4f 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -9,9 +9,9 @@ /* Begin PBXBuildFile section */ 265AA1622B74E6B900CF98B0 /* ChatPreservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265AA1612B74E6B900CF98B0 /* ChatPreservation.swift */; }; 269E13522B594B2D008D1CA7 /* AccountFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269E13512B594B2D008D1CA7 /* AccountFooterView.swift */; }; - 3A075C9E2B98A3B100714E3B /* FilesPickerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3A075C9D2B98A3B100714E3B /* FilesPickerKit */; }; 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 */; }; @@ -39,7 +39,6 @@ 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 */; }; - 3A833C3E2B99CCD600238F6A /* FilesStorageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.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 */; }; @@ -717,7 +716,6 @@ 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 = ""; }; - 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesStorageProtocol.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 = ""; }; @@ -2128,7 +2126,6 @@ 3A96E37B2AED27F8001F5A52 /* PartnerQRService.swift */, 3AF08D602B4EB3C400EB82B1 /* LanguageStorageProtocol.swift */, 265AA1612B74E6B900CF98B0 /* ChatPreservation.swift */, - 3A833C3D2B99CCD600238F6A /* FilesStorageProtocol.swift */, 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */, 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */, 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */, @@ -3538,7 +3535,6 @@ A50A41132822FC35006BDFE1 /* BtcWalletService+Send.swift in Sources */, A5E04229282A998C0076CD13 /* BtcTransactionResponse.swift in Sources */, 4186B334294200C5006594A3 /* EthWalletService+DynamicConstants.swift in Sources */, - 3A833C3E2B99CCD600238F6A /* FilesStorageProtocol.swift in Sources */, 93CCAE802B06E2D100EA5B94 /* ApiServiceError+Extension.swift in Sources */, 644EC34F20EFA77A00F40C73 /* Delegate.swift in Sources */, 64EAB37622463F680018D9B2 /* AdamantCurrencyInfoService.swift in Sources */, diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index 598bf791a..cd195d425 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -10,6 +10,7 @@ import Swinject import BitcoinKit import CommonKit import FilesStorageKit +import FilesPickerKit struct AppAssembly: Assembly { func assemble(container: Container) { @@ -20,6 +21,10 @@ struct AppAssembly: Assembly { // 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) diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 9d797961e..8bb6f87c9 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -12,6 +12,7 @@ import InputBarAccessoryView import Combine import Swinject import FilesStorageKit +import FilesPickerKit @MainActor struct ChatFactory { @@ -33,6 +34,7 @@ struct ChatFactory { let filesStorageProprieties: FilesStorageProprietiesProtocol let nodesStorage: NodesStorageProtocol let reachabilityMonitor: ReachabilityMonitor + let filesPickerKit: FilesPickerProtocol nonisolated init(assembler: Assembler) { chatsProvider = assembler.resolve(ChatsProvider.self)! @@ -52,6 +54,7 @@ struct ChatFactory { 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 { @@ -130,7 +133,8 @@ private extension ChatFactory { chatFileService: chatFileService, filesStorageProprieties: filesStorageProprieties, nodesStorage: nodesStorage, - reachabilityMonitor: reachabilityMonitor + reachabilityMonitor: reachabilityMonitor, + filesPicker: filesPickerKit ) } diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 60202f0f3..0fc6b3a6e 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -79,11 +79,6 @@ final class ChatViewController: MessagesViewController { return data }() - private lazy var mediaPickerDelegate = MediaPickerService() - private lazy var documentPickerDelegate = DocumentPickerService() - private lazy var documentViewerService = DocumentInteractionService() - private lazy var dropInteractionService = DropInteractionService() - init( viewModel: ChatViewModel, walletServiceCompose: WalletServiceCompose, @@ -459,25 +454,7 @@ private extension ChatViewController { $0.directionalEdges.equalTo(view.safeAreaLayoutGuide).inset(5) } - view.addInteraction(UIDropInteraction(delegate: dropInteractionService)) - - dropInteractionService.onPreparedDataCallback = { [weak self] result in - DispatchQueue.main.async { - self?.viewModel.dropSessionUpdated(false) - self?.viewModel.presentDialog(progress: false) - self?.viewModel.processFileResult(result) - } - } - - dropInteractionService.onPreparingDataCallback = { [weak self] in - DispatchQueue.main.async { - self?.viewModel.presentDialog(progress: true) - } - } - - dropInteractionService.onSessionCallback = { [weak self] fileOnScreen in - self?.viewModel.dropSessionUpdated(fileOnScreen) - } + view.addInteraction(UIDropInteraction(delegate: viewModel.dropInteractionService)) } func configureLayout() { @@ -573,20 +550,7 @@ private extension ChatViewController { func presentMediaPicker() { messageInputBar.inputTextView.resignFirstResponder() - mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in - DispatchQueue.main.async { - self?.viewModel.presentDialog(progress: false) - self?.viewModel.processFileResult(result) - } - } - - mediaPickerDelegate.onPreparingDataCallback = { [weak self] in - DispatchQueue.main.async { - self?.viewModel.presentDialog(progress: true) - } - } - - mediaPickerDelegate.preSelectedFiles = viewModel.filesPicked ?? [] + viewModel.mediaPickerDelegate.preSelectedFiles = viewModel.filesPicked ?? [] let assetIds = viewModel.filesPicked?.compactMap { $0.assetId } ?? [] @@ -597,43 +561,30 @@ private extension ChatViewController { phPickerConfig.selection = .ordered let phPickerVC = PHPickerViewController(configuration: phPickerConfig) - phPickerVC.delegate = mediaPickerDelegate + phPickerVC.delegate = viewModel.mediaPickerDelegate present(phPickerVC, animated: true) } func presentDocumentPicker() { messageInputBar.inputTextView.resignFirstResponder() - documentPickerDelegate.onPreparedDataCallback = { [weak self] result in - DispatchQueue.main.async { - self?.viewModel.presentDialog(progress: false) - self?.viewModel.processFileResult(result) - } - } - - documentPickerDelegate.onPreparingDataCallback = { [weak self] in - DispatchQueue.main.async { - self?.viewModel.presentDialog(progress: true) - } - } - let documentPicker = UIDocumentPickerViewController( forOpeningContentTypes: [.data, .content], asCopy: false ) documentPicker.allowsMultipleSelection = true - documentPicker.delegate = documentPickerDelegate + documentPicker.delegate = viewModel.documentPickerDelegate present(documentPicker, animated: true) } func presentDocumentViewer(files: [FileResult], selectedIndex: Int) { - documentViewerService.openFile( + viewModel.documentViewerService.openFile( files: files ) let quickVC = QLPreviewController() - quickVC.delegate = documentViewerService - quickVC.dataSource = documentViewerService + quickVC.delegate = viewModel.documentViewerService + quickVC.dataSource = viewModel.documentViewerService quickVC.modalPresentationStyle = .fullScreen quickVC.currentPreviewItemIndex = selectedIndex @@ -645,11 +596,11 @@ private extension ChatViewController { } func presentDocumentViewer(url: URL) { - documentViewerService.openFile(url: url) + viewModel.documentViewerService.openFile(url: url) let quickVC = QLPreviewController() - quickVC.delegate = documentViewerService - quickVC.dataSource = documentViewerService + quickVC.delegate = viewModel.documentViewerService + quickVC.dataSource = viewModel.documentViewerService quickVC.modalPresentationStyle = .fullScreen if let splitViewController = splitViewController { diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index ae9bb23d4..59f51391c 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -14,6 +14,7 @@ import CommonKit import AdvancedContextMenuKit import ElegantEmojiPicker import FilesPickerKit +import FilesStorageKit @MainActor final class ChatViewModel: NSObject { @@ -38,6 +39,7 @@ final class ChatViewModel: NSObject { private let filesStorageProprieties: FilesStorageProprietiesProtocol private let nodesStorage: NodesStorageProtocol private let reachabilityMonitor: ReachabilityMonitor + private let filesPicker: FilesPickerProtocol let chatMessagesListViewModel: ChatMessagesListViewModel @@ -136,6 +138,11 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } + lazy var mediaPickerDelegate = MediaPickerService(helper: filesPicker) + lazy var documentPickerDelegate = DocumentPickerService(helper: filesPicker) + lazy var documentViewerService = DocumentInteractionService() + lazy var dropInteractionService = DropInteractionService(helper: filesPicker) + init( chatsProvider: ChatsProvider, markdownParser: MarkdownParser, @@ -156,7 +163,8 @@ final class ChatViewModel: NSObject { chatFileService: ChatFileProtocol, filesStorageProprieties: FilesStorageProprietiesProtocol, nodesStorage: NodesStorageProtocol, - reachabilityMonitor: ReachabilityMonitor + reachabilityMonitor: ReachabilityMonitor, + filesPicker: FilesPickerProtocol ) { self.chatsProvider = chatsProvider self.markdownParser = markdownParser @@ -178,6 +186,7 @@ final class ChatViewModel: NSObject { self.filesStorageProprieties = filesStorageProprieties self.nodesStorage = nodesStorage self.reachabilityMonitor = reachabilityMonitor + self.filesPicker = filesPicker super.init() setupObservers() @@ -951,6 +960,50 @@ private extension ChatViewModel { } .store(in: &subscriptions) }.stored(in: tasksStorage) + + dropInteractionService.onPreparedDataCallback = { [weak self] result in + DispatchQueue.onMainAsync { + self?.dropSessionUpdated(false) + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + dropInteractionService.onPreparingDataCallback = { [weak self] in + DispatchQueue.onMainAsync { + self?.presentDialog(progress: true) + } + } + + dropInteractionService.onSessionCallback = { [weak self] fileOnScreen in + self?.dropSessionUpdated(fileOnScreen) + } + + mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in + DispatchQueue.onMainAsync { + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + mediaPickerDelegate.onPreparingDataCallback = { [weak self] in + DispatchQueue.onMainAsync { + self?.presentDialog(progress: true) + } + } + + documentPickerDelegate.onPreparedDataCallback = { [weak self] result in + DispatchQueue.onMainAsync { + self?.presentDialog(progress: false) + self?.processFileResult(result) + } + } + + documentPickerDelegate.onPreparingDataCallback = { [weak self] in + DispatchQueue.onMainAsync { + self?.presentDialog(progress: true) + } + } } func loadMessages(address: String, offset: Int) async { diff --git a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift index 9087cb08b..3bca6cc47 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift @@ -8,6 +8,7 @@ import Swinject import SwiftUI +import FilesStorageKit struct StorageUsageFactory { private let assembler: Assembler diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index c4f80709e..10cdb3e37 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -9,6 +9,7 @@ import Foundation import CommonKit import SwiftUI +import FilesStorageKit public extension Notification.Name { struct Storage { diff --git a/FilesPickerKit/Package.swift b/FilesPickerKit/Package.swift index 16f60b983..a6e60c84c 100644 --- a/FilesPickerKit/Package.swift +++ b/FilesPickerKit/Package.swift @@ -15,14 +15,15 @@ let package = Package( targets: ["FilesPickerKit"]), ], dependencies: [ - .package(path: "../CommonKit") + .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"] + dependencies: ["CommonKit", "FilesStorageKit"] ), .testTarget( name: "FilesPickerKitTests", diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift similarity index 67% rename from FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift rename to FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index 2d2e37200..e4ca970e7 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKitHelper.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -6,11 +6,25 @@ import UIKit import SwiftUI import AVFoundation import QuickLook +import FilesStorageKit -final class FilesPickerKitHelper { - var previewExtension = "jpeg" +public final class FilesPickerKit: FilesPickerProtocol { + private let storageKit: FilesStorageProtocol + public var previewExtension: String { "jpeg" } - func validateFiles(_ files: [FileResult]) throws { + public init(storageKit: FilesStorageProtocol) { + self.storageKit = storageKit + } + + public func getFileSize(from url: URL) throws -> Int64 { + try storageKit.getFileSize(from: url) + } + + 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 } @@ -22,66 +36,13 @@ final class FilesPickerKitHelper { } } - func getUrl(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(cachePath) - - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - - let fileURL = folder.appendingPathComponent(name) - - try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) - - return fileURL - } - - func copyFile(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(cachePath) - - 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 - } - - func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { + public func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { let newSize = getPreviewSize(from: image.size) return image.imageResized(to: newSize) } - func getOriginalSize(for url: URL) -> CGSize? { + public func getOriginalSize(for url: URL) -> CGSize? { guard let track = AVURLAsset(url: url).tracks( withMediaType: AVMediaType.video ).first @@ -92,7 +53,7 @@ final class FilesPickerKitHelper { return .init(width: abs(naturalSize.width), height: abs(naturalSize.height)) } - func getThumbnailImage( + public func getThumbnailImage( forUrl url: URL, originalSize: CGSize? ) async throws -> UIImage? { @@ -116,29 +77,10 @@ final class FilesPickerKitHelper { return image } - func getFileSize(from fileURL: URL) throws -> Int64 { - 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 fileSize - } catch { - throw error - } - } - - func getFileResult(for url: URL) throws -> FileResult { - let newUrl = try copyFile(from: url) + public func getFileResult(for url: URL) throws -> FileResult { + let newUrl = try storageKit.copyFileToTempCache(from: url) let preview = getPreview(for: newUrl) - let fileSize = try getFileSize(from: newUrl) + let fileSize = try storageKit.getFileSize(from: newUrl) return FileResult( assetId: url.absoluteString, url: newUrl, @@ -154,7 +96,7 @@ final class FilesPickerKitHelper { } @MainActor - func getUrlConforms( + public func getUrlConforms( to type: UTType, for itemProvider: NSItemProvider ) async throws -> URL { @@ -174,7 +116,7 @@ final class FilesPickerKitHelper { } @MainActor - func getUrl(for itemProvider: NSItemProvider) async throws -> URL { + public func getUrl(for itemProvider: NSItemProvider) async throws -> URL { for type in itemProvider.registeredTypeIdentifiers { do { return try await getFileURL(by: type, itemProvider: itemProvider) @@ -187,16 +129,16 @@ final class FilesPickerKitHelper { } @MainActor - func getFileURL( + public func getFileURL( by type: String, itemProvider: NSItemProvider ) async throws -> URL { try await withCheckedThrowingContinuation { continuation in - itemProvider.loadFileRepresentation(forTypeIdentifier: type) { url, error 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.copyFile(from: url) { + if let targetURL = try? self?.storageKit.copyFileToTempCache(from: url) { continuation.resume(returning: targetURL) } else { continuation.resume(throwing: FileValidationError.fileNotFound) @@ -209,7 +151,7 @@ final class FilesPickerKitHelper { } } -private extension FilesPickerKitHelper { +private extension FilesPickerKit { func getPreviewSize(from originalSize: CGSize?) -> CGSize { guard let size = originalSize else { return FilesConstants.previewSize } @@ -273,7 +215,7 @@ private extension FilesPickerKitHelper { image: image, targetSize: FilesConstants.previewSize ) - let imageURL = try? getUrl( + let imageURL = try? storageKit.getTempUrl( for: resizedImage, name: FilesConstants.previewTag + url.lastPathComponent ) @@ -295,5 +237,3 @@ private extension FilesPickerKitHelper { } } } - -private let cachePath = "downloads/cache" diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index 7455923f2..f84726f8c 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -11,13 +11,16 @@ import CommonKit import MobileCoreServices import AVFoundation -public final class DocumentPickerService: NSObject, FilePickerProtocol { - private var helper = FilesPickerKitHelper() +public final class DocumentPickerService: NSObject, FilePickerServiceProtocol { + private var helper: FilesPickerProtocol public var onPreparedDataCallback: ((Result<[FileResult], Error>) -> Void)? public var onPreparingDataCallback: (() -> Void)? - public override init() { } + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } } extension DocumentPickerService: UIDocumentPickerDelegate { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift index 4dca7807c..b7485e10b 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DropInteractionService.swift @@ -11,14 +11,17 @@ import UIKit import UniformTypeIdentifiers @MainActor -public final class DropInteractionService: NSObject { - private var helper = FilesPickerKitHelper() +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 override init() { } + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } } extension DropInteractionService: UIDropInteractionDelegate { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index c634e75db..a8954943e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -11,14 +11,17 @@ import Photos import PhotosUI @MainActor -public final class MediaPickerService: NSObject, FilePickerProtocol { - private var helper = FilesPickerKitHelper() +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 override init() { } + public init(helper: FilesPickerProtocol) { + self.helper = helper + super.init() + } } extension MediaPickerService: PHPickerViewControllerDelegate { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift similarity index 88% rename from FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift rename to FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift index 2de4153f1..d9729d1f5 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift @@ -10,7 +10,7 @@ import UIKit import CommonKit @MainActor -protocol FilePickerProtocol { +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..f1f52d9f0 --- /dev/null +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift @@ -0,0 +1,42 @@ +// +// 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 +} diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index 92e80b820..b8b187697 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -5,7 +5,7 @@ import CommonKit import UIKit import Combine -public final class FilesStorageKit { +public final class FilesStorageKit: FilesStorageProtocol { public struct File { public let id: String public let isEncrypted: Bool @@ -166,6 +166,78 @@ public final class FilesStorageKit { 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) throws -> Int64 { + 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 fileSize + } catch { + throw error + } + } } private extension FilesStorageKit { diff --git a/Adamant/ServiceProtocols/FilesStorageProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift similarity index 77% rename from Adamant/ServiceProtocols/FilesStorageProtocol.swift rename to FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift index 038bfbdff..aab12f2cb 100644 --- a/Adamant/ServiceProtocols/FilesStorageProtocol.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift @@ -1,18 +1,14 @@ // // FilesStorageProtocol.swift -// Adamant // -// Created by Stanislav Jelezoglo on 07.03.2024. -// Copyright © 2024 Adamant. All rights reserved. +// +// Created by Stanislav Jelezoglo on 21.05.2024. // -import Foundation import UIKit import CommonKit -import FilesStorageKit -import Combine -protocol FilesStorageProtocol { +public protocol FilesStorageProtocol { func cacheImageToMemoryIfNeeded(id: String, data: Data) -> UIImage? func getPreview(for id: String) -> UIImage? @@ -52,6 +48,10 @@ protocol FilesStorageProtocol { 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) throws -> Int64 } - -extension FilesStorageKit: FilesStorageProtocol { } From 5f247b0dd196eec49d4c86f6ef0c4f9a029960c5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 22 May 2024 10:39:01 +0300 Subject: [PATCH 097/123] [trello.com/c/uxBZaznD] feat: allow paste image --- .../Chat/View/Subviews/ChatInputBar.swift | 19 +++++++++++++++++++ .../Chat/ViewModel/ChatViewModel.swift | 18 ++++++++++++++++++ .../NotificationsService.swift | 7 +++++-- .../Helpers/FilesPickerKit.swift | 18 ++++++++++++++++++ .../Protocols/FilesPickerProtocol.swift | 2 ++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift index 19b8133bf..52eedae7b 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift @@ -225,6 +225,25 @@ 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 image = UIPasteboard.general.image else { return } + NotificationCenter.default.post(name: .AdamantInputText.pastedImage, object: image) + } +} + private let attachmentButtonSize: CGFloat = 36 private let baseInsetSize: CGFloat = 6 private let buttonHeight: CGFloat = 36 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 59f51391c..8cecd46e8 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -952,6 +952,24 @@ private extension ChatViewModel { .sink { [weak self] _ in self?.updateAttachmentButtonAvailability() } .store(in: &subscriptions) + NotificationCenter.default + .publisher(for: .AdamantInputText.pastedImage) + .sink { [weak self] data in + guard let image = data.object as? UIImage else { + return + } + + do { + guard let file = try self?.filesPicker.getFileResult(for: image) + else { return } + + self?.processFileResult(.success([file])) + } catch { + self?.processFileResult(.failure(error)) + } + } + .store(in: &subscriptions) + Task { await chatsProvider.stateObserver .receive(on: DispatchQueue.main) diff --git a/Adamant/ServiceProtocols/NotificationsService.swift b/Adamant/ServiceProtocols/NotificationsService.swift index b02c913ff..af3b6b131 100644 --- a/Adamant/ServiceProtocols/NotificationsService.swift +++ b/Adamant/ServiceProtocols/NotificationsService.swift @@ -121,11 +121,14 @@ 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() {} + } + + enum AdamantInputText { + static let pastedImage = Notification.Name("pastedImage") } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index e4ca970e7..f44c9395e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -95,6 +95,24 @@ public final class FilesPickerKit: FilesPickerProtocol { ) } + public func getFileResult(for image: UIImage) throws -> FileResult { + let fileName = "image.\(previewExtension)" + let newUrl = try storageKit.getTempUrl(for: image, name: fileName) + let preview = getPreview(for: newUrl) + let fileSize = try storageKit.getFileSize(from: newUrl) + return FileResult( + url: newUrl, + type: .other, + preview: preview.image, + previewUrl: preview.url, + previewExtension: previewExtension, + size: fileSize, + name: fileName, + extenstion: previewExtension, + resolution: preview.resolution + ) + } + @MainActor public func getUrlConforms( to type: UTType, diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift index f1f52d9f0..63e96f08a 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift @@ -39,4 +39,6 @@ public protocol FilesPickerProtocol { by type: String, itemProvider: NSItemProvider ) async throws -> URL + + func getFileResult(for image: UIImage) throws -> FileResult } From e2bbd182040af98d815c3d18822303b9d7d95651 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 22 May 2024 11:40:48 +0300 Subject: [PATCH 098/123] [trello.com/c/uxBZaznD] feat: upload code improvements --- .../Chat/ViewModel/ChatFileService.swift | 467 ++++++++++-------- 1 file changed, 268 insertions(+), 199 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index a8b497b50..4cc26bdaa 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -11,6 +11,7 @@ import CommonKit import UIKit import Combine import FilesStorageKit +import CoreData protocol ChatFileProtocol { var downloadingFiles: [String] { get } @@ -129,58 +130,16 @@ final class ChatFileService: ChatFileProtocol { else { return } let storageProtocol = NetworkFileProtocolType.ipfs + var richFiles = createRichFiles(from: files) - let replyMessage = replyMessage - - var richFiles: [RichMessageFile.File] = files.compactMap { - .init( - id: $0.url.absoluteString, - size: $0.size, - nonce: .empty, - name: $0.name, - type: $0.extenstion, - preview: $0.previewUrl.map { - RichMessageFile.Preview( - id: $0.absoluteString, - nonce: .empty, - extension: .empty - ) - }, - resolution: $0.resolution - ) - } - - let messageLocally: AdamantMessage + let messageLocally = createAdamantMessage( + with: richFiles, + text: text, + replyMessage: replyMessage, + storageProtocol: storageProtocol + ) - if let replyMessage = replyMessage { - messageLocally = .richMessage( - payload: RichFileReply( - replyto_id: replyMessage.id, - reply_message: RichMessageFile( - files: richFiles, - storage: .init(id: storageProtocol.rawValue), - comment: text - ) - ) - ) - } else { - messageLocally = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: .init(id: storageProtocol.rawValue), - comment: text - ) - ) - } - - for url in files.compactMap({ $0.previewUrl }) { - filesStorage.cacheTemporaryFile( - url: url, - isEncrypted: false, - fileType: .image, - isPreview: true - ) - } + cachePreviewFiles(files) let txLocally = try await chatsProvider.sendFileMessageLocally( messageLocally, @@ -188,111 +147,26 @@ final class ChatFileService: ChatFileProtocol { from: chatroom ) - richFiles.forEach { file in - $uploadingFilesIDsArray.mutate { $0.append(file.id) } - sendUpdate(for: [file.id], downloading: nil, uploading: true) - } + updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: true) do { - for file in files { - let result = try await uploadFileToServer( - file: file, - recipientPublicKey: chatroom?.partner?.publicKey ?? .empty, - senderPrivateKey: keyPair.privateKey, - storageProtocol: storageProtocol - ) - - try filesStorage.cacheFile( - id: result.file.cid, - fileExtension: file.extenstion ?? .empty, - url: file.url, - decodedData: result.file.decodedData, - encodedData: result.file.encodedData, - ownerId: ownerId, - recipientId: partnerAddress, - saveEncrypted: saveEncrypted, - fileType: file.type, - isPreview: false - ) - - var preview: UIImage? - - if let previewUrl = file.previewUrl, - let previewResult = result.preview { - 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 - ) - - preview = filesStorage.getPreview(for: previewResult.cid) - } - - let oldId = file.url.absoluteString - $uploadingFilesIDsArray.mutate { - $0.removeAll(where: { $0 == oldId }) - } - let cached = filesStorage.isCachedLocally(result.file.cid) - - updateFileFields.send(( - id: oldId, - newId: result.file.cid, - fileNonce: result.file.nonce, - preview: preview, - needUpdatePreview: true, - cached: cached, - downloading: nil, - uploading: false - )) - - var previewDTO: RichMessageFile.Preview? - if let cid = result.preview?.cid, - let nonce = result.preview?.nonce { - previewDTO = .init( - id: cid, - nonce: nonce, - extension: file.previewExtension - ) - } - - if let index = richFiles.firstIndex( - where: { $0.id == oldId } - ) { - richFiles[index].id = result.file.cid - richFiles[index].nonce = result.file.nonce - richFiles[index].preview = previewDTO - } - } - - let message: AdamantMessage + try await processFilesUpload( + files: files, + chatroom: chatroom, + keyPair: keyPair, + storageProtocol: storageProtocol, + ownerId: ownerId, + partnerAddress: partnerAddress, + saveEncrypted: saveEncrypted, + richFiles: &richFiles + ) - if let replyMessage = replyMessage { - message = .richMessage( - payload: RichFileReply( - replyto_id: replyMessage.id, - reply_message: RichMessageFile( - files: richFiles, - storage: .init(id: NetworkFileProtocolType.ipfs.rawValue), - comment: text - ) - ) - ) - } else { - message = .richMessage( - payload: RichMessageFile( - files: richFiles, - storage: .init(id: NetworkFileProtocolType.ipfs.rawValue), - comment: text - ) - ) - } + let message = createAdamantMessage( + with: richFiles, + text: text, + replyMessage: replyMessage, + storageProtocol: storageProtocol + ) _ = try await chatsProvider.sendFileMessage( message, @@ -302,15 +176,9 @@ final class ChatFileService: ChatFileProtocol { from: chatroom ) } catch { - richFiles.forEach { file in - $uploadingFilesIDsArray.mutate { - $0.removeAll(where: { $0 == file.id }) - } - sendUpdate(for: [file.id], downloading: nil, uploading: false) - } - - try? await chatsProvider.setTxMessageAsFailed( - transactionLocaly: txLocally.tx, + await handleUploadError( + for: richFiles, + tx: txLocally.tx, context: txLocally.context ) @@ -718,6 +586,245 @@ private extension ChatFileService { return false } + func downloadFile( + id: String, + storage: String, + senderPublicKey: String, + recipientPrivateKey: String, + nonce: String, + saveEncrypted: Bool + ) async throws -> (decodedData: Data, encodedData: Data) { + let encodedData = try await filesNetworkManager.downloadFile(id, type: storage) + + 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], downloading: Bool?, uploading: Bool?) { + files.forEach { id in + updateFileFields.send(( + id: id, + newId: nil, + fileNonce: nil, + preview: nil, + needUpdatePreview: false, + cached: nil, + downloading: downloading, + uploading: uploading + )) + } + } +} + +// MARK: Upload +private extension ChatFileService { + func createRichFiles(from files: [FileResult]) -> [RichMessageFile.File] { + files.compactMap { + .init( + id: $0.url.absoluteString, + size: $0.size, + nonce: .empty, + name: $0.name, + type: $0.extenstion, + preview: $0.previewUrl.map { + RichMessageFile.Preview( + id: $0.absoluteString, + nonce: .empty, + extension: .empty + ) + }, + resolution: $0.resolution + ) + } + } + + 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: [FileResult]) { + for url in files.compactMap({ $0.previewUrl }) { + filesStorage.cacheTemporaryFile( + url: url, + isEncrypted: false, + fileType: .image, + isPreview: true + ) + } + } + + 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, downloading: nil, uploading: uploading) + } + + func processFilesUpload( + files: [FileResult], + chatroom: Chatroom?, + keyPair: Keypair, + storageProtocol: NetworkFileProtocolType, + ownerId: String, + partnerAddress: String, + saveEncrypted: Bool, + richFiles: inout [RichMessageFile.File] + ) async throws { + for file in files { + let result = try await uploadFileToServer( + file: file, + recipientPublicKey: chatroom?.partner?.publicKey ?? .empty, + senderPrivateKey: keyPair.privateKey, + storageProtocol: storageProtocol + ) + + try cacheUploadedFile( + fileResult: result.file, + previewResult: result.preview, + file: file, + ownerId: ownerId, + partnerAddress: partnerAddress, + saveEncrypted: saveEncrypted + ) + + updateRichFile( + oldId: file.url.absoluteString, + fileResult: result.file, + previewResult: result.preview, + richFiles: &richFiles, + file: file + ) + } + } + + 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?, + richFiles: inout [RichMessageFile.File], + file: FileResult + ) { + let cached = filesStorage.isCachedLocally(fileResult.cid) + + updateFileFields.send(( + id: oldId, + newId: fileResult.cid, + fileNonce: fileResult.nonce, + preview: filesStorage.getPreview(for: previewResult?.cid ?? .empty), + needUpdatePreview: true, + cached: cached, + downloading: nil, + uploading: false + )) + + 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 + } + } + + func handleUploadError( + for richFiles: [RichMessageFile.File], + tx: RichMessageTransaction, + context: NSManagedObjectContext + ) async { + updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: false) + + try? await chatsProvider.setTxMessageAsFailed( + transactionLocaly: tx, + context: context + ) + } + func uploadFileToServer( file: FileResult, recipientPublicKey: String, @@ -774,42 +881,4 @@ private extension ChatFileService { return (data, encodedData, nonce, cid) } - func downloadFile( - id: String, - storage: String, - senderPublicKey: String, - recipientPrivateKey: String, - nonce: String, - saveEncrypted: Bool - ) async throws -> (decodedData: Data, encodedData: Data) { - let encodedData = try await filesNetworkManager.downloadFile(id, type: storage) - - 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], downloading: Bool?, uploading: Bool?) { - files.forEach { id in - updateFileFields.send(( - id: id, - newId: nil, - fileNonce: nil, - preview: nil, - needUpdatePreview: false, - cached: nil, - downloading: downloading, - uploading: uploading - )) - } - } } From b06e14bf935d7235aa0ce54f37420d356dfaef39 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 22 May 2024 17:17:50 +0300 Subject: [PATCH 099/123] [trello.com/c/uxBZaznD] feat: retry uploading file message --- Adamant.xcodeproj/project.pbxproj | 4 + .../Chat/ViewModel/ChatFileService.swift | 313 +++++++++++------- .../Chat/ViewModel/ChatViewModel.swift | 17 +- .../ServiceProtocols/ChatFileProtocol.swift | 68 ++++ 4 files changed, 283 insertions(+), 119 deletions(-) create mode 100644 Adamant/ServiceProtocols/ChatFileProtocol.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 25474cf4f..8a33a10cc 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ 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 */; }; + 3AF9DF0B2BFE306C009A43A8 /* ChatFileProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.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 */; }; @@ -754,6 +755,7 @@ 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 = ""; }; + 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileProtocol.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 = ""; }; @@ -2129,6 +2131,7 @@ 3ACD307F2BBD86C800ABF671 /* FilesStorageProprietiesProtocol.swift */, 3AE0A4322BC6A9EB00BF7125 /* FileApiServiceProtocol.swift */, 3AE0A4362BC6AA6000BF7125 /* FilesNetworkManagerProtocol.swift */, + 3AF9DF0A2BFE306C009A43A8 /* ChatFileProtocol.swift */, ); path = ServiceProtocols; sourceTree = ""; @@ -3177,6 +3180,7 @@ E9CAE8D22018AA7700345E76 /* AdamantApi+Accounts.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 */, diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 4cc26bdaa..04025daa9 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -13,51 +13,19 @@ import Combine import FilesStorageKit import CoreData -protocol ChatFileProtocol { - var downloadingFiles: [String] { get } - var uploadingFiles: [String] { get } - - var updateFileFields: PassthroughSubject<( - id: String, - newId: String?, - fileNonce: String?, - preview: UIImage?, - needUpdatePreview: Bool, - cached: Bool?, - downloading: Bool?, - uploading: Bool? - ), Never> { - get - } - - func sendFile( - text: String?, - chatroom: Chatroom?, - filesPicked: [FileResult]?, - replyMessage: MessageModel?, - saveEncrypted: Bool - ) async throws - - func downloadFile( - file: ChatFile, - chatroom: Chatroom?, - saveEncrypted: 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 +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 tx: RichMessageTransaction? + var context: NSManagedObjectContext? } final class ChatFileService: ChatFileProtocol { @@ -76,6 +44,7 @@ final class ChatFileService: ChatFileProtocol { @Atomic private var ignoreFilesIDsArray: [String] = [] @Atomic private var busyFilesIDs: [String] = [] @Atomic private var fileDownloadAttemptsCount: [String: Int] = [:] + @Atomic private var uploadingFilesDictionary: [String: FileMessage] = [:] private var subscriptions = Set() private let maxDownloadAttemptsCount = 3 @@ -122,68 +91,46 @@ final class ChatFileService: ChatFileProtocol { replyMessage: MessageModel?, saveEncrypted: Bool ) async throws { - guard let partnerAddress = chatroom?.partner?.address, - let files = filesPicked, - let keyPair = accountService.keypair, - let ownerId = accountService.account?.address, - chatroom?.partner?.isDummy != true - else { return } + guard let filesPicked = filesPicked else { return } - let storageProtocol = NetworkFileProtocolType.ipfs - var richFiles = createRichFiles(from: files) + let files = filesPicked.map { + FileUpload( + file: $0, + isUploaded: false, + serverFileID: nil, + fileNonce: nil, + preview: nil + ) + } - let messageLocally = createAdamantMessage( - with: richFiles, + let fileMessage = FileMessage.init(files: files) + + try await sendFile( text: text, + chatroom: chatroom, + fileMessage: fileMessage, replyMessage: replyMessage, - storageProtocol: storageProtocol + 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 } - cachePreviewFiles(files) - - let txLocally = try await chatsProvider.sendFileMessageLocally( - messageLocally, - recipientId: partnerAddress, - from: chatroom + try await sendFile( + text: text, + chatroom: chatroom, + fileMessage: fileMessage, + replyMessage: replyMessage, + saveEncrypted: saveEncrypted ) - - updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: true) - - do { - try await processFilesUpload( - files: files, - chatroom: chatroom, - keyPair: keyPair, - storageProtocol: storageProtocol, - ownerId: ownerId, - partnerAddress: partnerAddress, - saveEncrypted: saveEncrypted, - richFiles: &richFiles - ) - - let message = createAdamantMessage( - with: richFiles, - text: text, - replyMessage: replyMessage, - storageProtocol: storageProtocol - ) - - _ = try await chatsProvider.sendFileMessage( - message, - recipientId: partnerAddress, - transactionLocaly: txLocally.tx, - context: txLocally.context, - from: chatroom - ) - } catch { - await handleUploadError( - for: richFiles, - tx: txLocally.tx, - context: txLocally.context - ) - - throw error - } } func downloadFile( @@ -628,22 +575,110 @@ private extension ChatFileService { // MARK: Upload private extension ChatFileService { - func createRichFiles(from files: [FileResult]) -> [RichMessageFile.File] { + 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 txLocaly = try await sendMessageLocallyIfNeeded( + fileMessage: fileMessage, + partnerAddress: partnerAddress, + chatroom: chatroom, + messageLocally: messageLocally + ) + + fileMessage.tx = txLocaly.tx + fileMessage.context = txLocaly.context + + let txId = txLocaly.tx.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 + ) + + let message = createAdamantMessage( + with: richFiles, + text: text, + replyMessage: replyMessage, + storageProtocol: storageProtocol + ) + + _ = try await chatsProvider.sendFileMessage( + message, + recipientId: partnerAddress, + transactionLocaly: txLocaly.tx, + context: txLocaly.context, + from: chatroom + ) + + $uploadingFilesDictionary.mutate { + $0[txId] = nil + } + } catch { + await handleUploadError( + for: needToLoadFiles, + tx: txLocaly.tx, + context: txLocaly.context + ) + + throw error + } + } + + func createRichFiles(from files: [FileUpload]) -> [RichMessageFile.File] { files.compactMap { .init( - id: $0.url.absoluteString, - size: $0.size, - nonce: .empty, - name: $0.name, - type: $0.extenstion, - preview: $0.previewUrl.map { + id: $0.serverFileID ?? $0.file.url.absoluteString, + size: $0.file.size, + nonce: $0.fileNonce ?? .empty, + name: $0.file.name, + type: $0.file.extenstion, + preview: $0.preview ?? $0.file.previewUrl.map { RichMessageFile.Preview( id: $0.absoluteString, nonce: .empty, extension: .empty ) }, - resolution: $0.resolution + resolution: $0.file.resolution ) } } @@ -676,8 +711,9 @@ private extension ChatFileService { ) } - func cachePreviewFiles(_ files: [FileResult]) { - for url in files.compactMap({ $0.previewUrl }) { + func cachePreviewFiles(_ files: [FileUpload]) { + let needToCache = files.filter { !$0.isUploaded } + for url in needToCache.compactMap({ $0.file.previewUrl }) { filesStorage.cacheTemporaryFile( url: url, isEncrypted: false, @@ -687,6 +723,32 @@ private extension ChatFileService { } } + func sendMessageLocallyIfNeeded( + fileMessage: FileMessage, + partnerAddress: String, + chatroom: Chatroom?, + messageLocally: AdamantMessage + ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) { + let tx: RichMessageTransaction + let context: NSManagedObjectContext + + if let transaction = fileMessage.tx, + let txContext = fileMessage.context { + tx = transaction + context = txContext + } else { + let txLocally = try await chatsProvider.sendFileMessageLocally( + messageLocally, + recipientId: partnerAddress, + from: chatroom + ) + tx = txLocally.tx + context = txLocally.context + } + + return (tx, context) + } + func updateUploadingFilesIDs(with ids: [String], uploading: Bool) { $uploadingFilesIDsArray.mutate { currentIDs in if uploading { @@ -701,16 +763,21 @@ private extension ChatFileService { } func processFilesUpload( - files: [FileResult], + fileMessage: inout FileMessage, chatroom: Chatroom?, keyPair: Keypair, storageProtocol: NetworkFileProtocolType, ownerId: String, partnerAddress: String, saveEncrypted: Bool, + txId: String, richFiles: inout [RichMessageFile.File] ) async throws { - for file in files { + let files = fileMessage.files + + for i in files.indices where !files[i].isUploaded { + let file = files[i].file + let result = try await uploadFileToServer( file: file, recipientPublicKey: chatroom?.partner?.publicKey ?? .empty, @@ -731,8 +798,10 @@ private extension ChatFileService { oldId: file.url.absoluteString, fileResult: result.file, previewResult: result.preview, + fileMessage: &fileMessage, richFiles: &richFiles, - file: file + file: file, + txId: txId ) } } @@ -779,8 +848,10 @@ private extension ChatFileService { oldId: String, fileResult: UploadResult, previewResult: UploadResult?, + fileMessage: inout FileMessage, richFiles: inout [RichMessageFile.File], - file: FileResult + file: FileResult, + txId: String ) { let cached = filesStorage.isCachedLocally(fileResult.cid) @@ -810,6 +881,19 @@ private extension ChatFileService { 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 + } + } } func handleUploadError( @@ -880,5 +964,4 @@ private extension ChatFileService { let cid = try await filesNetworkManager.uploadFiles(encodedData, type: storageProtocol) return (data, encodedData, nonce, cid) } - } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8cecd46e8..70358990a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -494,7 +494,18 @@ final class ChatViewModel: NSObject { let message = messages.first(where: { $0.messageId == id }) - if case (.file) = message?.content { + if case let .file(model) = message?.content { + do { + try await chatFileService.resendMessage( + with: id, + text: model.value.content.comment.string, + chatroom: chatroom, + replyMessage: nil, + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) + } catch { + dialog.send(.error(error.localizedDescription, supportEmail: false)) + } return } @@ -761,10 +772,8 @@ final class ChatViewModel: NSObject { files: [ChatFile] ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - let message = messages.first(where: { $0.messageId == messageId }) - guard let message = message, - tx?.statusEnum == .delivered || (message.status != .failed && message.status != .pending), + guard tx?.statusEnum == .delivered, (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) else { return } diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift new file mode 100644 index 000000000..0cf7c5ea8 --- /dev/null +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -0,0 +1,68 @@ +// +// 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 + +protocol ChatFileProtocol { + var downloadingFiles: [String] { get } + var uploadingFiles: [String] { get } + + var updateFileFields: PassthroughSubject<( + id: String, + newId: String?, + fileNonce: String?, + preview: UIImage?, + needUpdatePreview: Bool, + cached: Bool?, + downloading: Bool?, + uploading: Bool? + ), Never> { + get + } + + func sendFile( + text: String?, + chatroom: Chatroom?, + filesPicked: [FileResult]?, + replyMessage: MessageModel?, + saveEncrypted: Bool + ) async throws + + func downloadFile( + file: ChatFile, + chatroom: Chatroom?, + saveEncrypted: 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 +} From e42caaeb0221bddeda39a462aa7ed59343c8d4b5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 29 May 2024 18:37:35 +0300 Subject: [PATCH 100/123] [trello.com/c/uxBZaznD] feat: loading indicator --- Adamant.xcodeproj/project.pbxproj | 8 + Adamant/Modules/Chat/ChatFactory.swift | 3 +- .../Modules/Chat/View/Helpers/ChatFile.swift | 8 +- .../View/Helpers/CircularProgressView.swift | 58 ++++++ .../Chat/View/Helpers/FileMessageStatus.swift | 34 ++++ .../Chat/View/Managers/ChatAction.swift | 5 +- .../View/Managers/ChatDataSourceManager.swift | 8 +- .../ChatMediaContainerView+Model.swift | 26 ++- .../Container/ChatMediaContainerView.swift | 26 +++ .../Content/ChatMediaContnentView.swift | 6 + .../ChatFileContainerView/ChatFileView.swift | 60 +++++- .../FileContainerView.swift | 6 +- .../MediaContainerView.swift | 6 +- .../MediaContainerView/MediaContentView.swift | 64 +++++- .../FilesToolbarCollectionViewCell.swift | 2 +- .../Chat/ViewModel/ChatFileService.swift | 188 +++++++++++++++--- .../Chat/ViewModel/ChatMessageFactory.swift | 47 ++++- .../ViewModel/ChatMessagesListFactory.swift | 12 +- .../Chat/ViewModel/ChatViewModel.swift | 61 ++++-- .../Wallets/Doge/DogeWalletService+Send.swift | 3 +- .../ServiceProtocols/APICoreProtocol.swift | 48 ++++- .../ServiceProtocols/ChatFileProtocol.swift | 3 +- .../DataProviders/ChatsProvider.swift | 5 +- .../FileApiServiceProtocol.swift | 11 +- .../FilesNetworkManagerProtocol.swift | 13 +- Adamant/Services/APICore.swift | 9 +- .../DataProviders/AdamantChatsProvider.swift | 7 +- .../FilesNetworkManager.swift | 16 +- .../FilesNetworkManager/IPFSApiService.swift | 16 +- .../Localization/de.lproj/Localizable.strings | 3 + .../Localization/en.lproj/Localizable.strings | 3 + .../Localization/ru.lproj/Localizable.strings | 3 + .../Localization/zh.lproj/Localizable.strings | 3 + .../Contents.json | 0 .../file-default-box.png | Bin .../defaultMediaBlur.imageset/Contents.json | 52 +++++ .../defaultMediaBlur.imageset/image-4.jpg | Bin 0 -> 27701 bytes ...-blue-modern-elegant-background-vector.jpg | Bin 0 -> 10545 bytes .../Contents.json | 7 +- .../download-circular.png | Bin 0 -> 679 bytes .../download-circular@2x.png | Bin 0 -> 1496 bytes .../download-circular@3x.png | Bin 0 -> 2416 bytes .../file-image-box.jpg | Bin 25815 -> 0 bytes .../FilesStorageKit/FilesStorageKit.swift | 4 +- 44 files changed, 712 insertions(+), 122 deletions(-) create mode 100644 Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift create mode 100644 Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/{file-default-box.imageset => defaultFileIcon.imageset}/Contents.json (100%) rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/{file-default-box.imageset => defaultFileIcon.imageset}/file-default-box.png (100%) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/image-4.jpg create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultMediaBlur.imageset/light-blue-modern-elegant-background-vector.jpg rename CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/{file-image-box.imageset => download-circular.imageset}/Contents.json (56%) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular.imageset/download-circular@3x.png delete mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-image-box.imageset/file-image-box.jpg diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 8a33a10cc..b192c7b22 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 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 */; }; @@ -78,6 +79,7 @@ 3AF53F8D2B3DCFA300B30312 /* NodeGroup+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF53F8C2B3DCFA300B30312 /* NodeGroup+Constants.swift */; }; 3AF53F8F2B3EE0DA00B30312 /* DogeNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF53F8E2B3EE0DA00B30312 /* DogeNodeInfo.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 */; }; @@ -717,6 +719,7 @@ 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 = ""; }; @@ -756,6 +759,7 @@ 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 = ""; }; 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 = ""; }; @@ -1545,8 +1549,10 @@ children = ( 41330F7529F1509400CB587C /* AdamantCellAnimation.swift */, 416380E02A51765F00F90E6D /* ChatReactionsView.swift */, + 3AF9DF0C2C049161009A43A8 /* CircularProgressView.swift */, 3A2478AD2BB42967009D89E9 /* ChatDropView.swift */, 3A299C7C2B85F98700B54C61 /* ChatFile.swift */, + 3A7FD6F42C076D85002AF7D9 /* FileMessageStatus.swift */, ); path = Helpers; sourceTree = ""; @@ -3418,6 +3424,7 @@ 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 */, @@ -3517,6 +3524,7 @@ 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 */, diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 8bb6f87c9..95edd0596 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -113,7 +113,8 @@ private extension ChatFactory { transfersProvider: transferProvider, chatMessagesListFactory: .init(chatMessageFactory: .init( walletServiceCompose: walletServiceCompose, - filesStorage: filesStorage + filesStorage: filesStorage, + filesStorageProprieties: filesStorageProprieties )), addressBookService: addressBookService, visibleWalletService: visibleWalletService, diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 4b7f72d4e..47306ba83 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -20,7 +20,9 @@ struct ChatFile: Equatable, Hashable { var nonce: String var isFromCurrentSender: Bool var fileType: FileType - + var progress: Int + var isPreviewDownloadAllowed: Bool + var isBusy: Bool { return isDownloading || isUploading } @@ -34,6 +36,8 @@ struct ChatFile: Equatable, Hashable { storage: .empty, nonce: .empty, isFromCurrentSender: false, - fileType: .other + fileType: .other, + progress: .zero, + isPreviewDownloadAllowed: 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..0b340238a --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift @@ -0,0 +1,58 @@ +// +// CircularProgressView.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 27.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import SwiftUI +import UIKit + +class CircularProgressState: ObservableObject { + @Published var lineWidth: CGFloat = 6 + @Published var backgroundColor: UIColor = .lightGray + @Published var progressColor: UIColor = .blue + @Published var progress: Double = 0 + @Published var hidden: Bool = false + + init( + lineWidth: CGFloat, + backgroundColor: UIColor, + progressColor: UIColor, + progress: Double, + hidden: Bool + ) { + self.lineWidth = lineWidth + self.backgroundColor = backgroundColor + self.progressColor = progressColor + self.progress = progress + self.hidden = hidden + } +} + +struct CircularProgressView: View { + @EnvironmentObject private var state: CircularProgressState + + 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..e85941af9 --- /dev/null +++ b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift @@ -0,0 +1,34 @@ +// +// FileMessageStatus.swift +// Adamant +// +// Created by Stanislav Jelezoglo on 29.05.2024. +// Copyright © 2024 Adamant. All rights reserved. +// + +import Foundation +import UIKit +import CommonKit + +enum FileMessageStatus { + case busy + case needToDownload + 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 .needToDownload: 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 56f96b6de..80e2d2921 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -21,6 +21,7 @@ enum ChatAction { case remove(id: String) case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) - case openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) - case downloadPreviewIfNeeded(messageId: String, files: [ChatFile], isFromCurrentSender: Bool) + case openFile(messageId: String, file: ChatFile) + case downloadPreviewIfNeeded(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 624c87f7e..2455ff45d 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -194,13 +194,15 @@ private extension ChatDataSourceManager { viewModel.presentMenu(arg: arg) case .copyInPart(text: let text): viewModel.copyTextInPartAction(text) - case let .openFile(messageId, file, isFromCurrentSender): - viewModel.openFile(messageId: messageId, file: file, isFromCurrentSender: isFromCurrentSender) - case let .downloadPreviewIfNeeded(messageId, files, _): + case let .openFile(messageId, file): + viewModel.openFile(messageId: messageId, file: file) + case let .downloadPreviewIfNeeded(messageId, files): viewModel.downloadPreviewIfNeeded( messageId: messageId, files: files ) + case let .forceDownloadAllFiles(messageId, files): + viewModel.forceDownloadAllFiles(messageId: messageId, files: files) } } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index a1bebfd14..6c07ebedc 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -17,6 +17,29 @@ extension ChatMediaContainerView { var content: ChatMediaContentView.Model let address: String let opponentAddress: String + let txStatus: MessageStatus + + var status: FileMessageStatus { + if txStatus == .failed { + return .failed + } + + if content.fileModel.files.first(where: { $0.isBusy }) != nil { + return .busy + } + + if content.fileModel.files.contains(where: { + !$0.isCached || + ($0.isCached + && $0.file.preview != nil + && $0.previewImage == nil + && ($0.fileType == .image || $0.fileType == .video)) + }) { + return .needToDownload + } + + return .success + } static let `default` = Self( id: "", @@ -24,7 +47,8 @@ extension ChatMediaContainerView { reactions: nil, content: .default, address: "", - opponentAddress: "" + opponentAddress: "", + txStatus: .failed ) func makeReplyContent() -> NSAttributedString { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 3c5798f64..6cda45b63 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -82,11 +82,18 @@ final class ChatMediaContainerView: UIView, ChatModelView { stack.axis = .vertical stack.spacing = 12 + 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) @@ -127,6 +134,18 @@ final class ChatMediaContainerView: UIView, ChatModelView { super.init(coder: coder) configure() } + + @objc func onStatusButtonTap() { + guard model.status == .needToDownload else { return } + + let fileModel = model.content.fileModel + let fileList = Array(fileModel.files.prefix(FilesConstants.maxFilesCount)) + + actionHandler(.forceDownloadAllFiles( + messageId: fileModel.messageId, + files: fileList + )) + } } extension ChatMediaContainerView { @@ -163,6 +182,13 @@ extension ChatMediaContainerView { 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 || status == .failed } func updateLayout() { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index bca71fb72..9cf8f20b8 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -155,6 +155,12 @@ final class ChatMediaContentView: UIView { super.init(coder: coder) configure() } + + override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + layer.borderColor = model.backgroundColor.uiColor.cgColor + } } private extension ChatMediaContentView { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index f822ce274..21c8ffaa1 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -8,6 +8,7 @@ import UIKit import CommonKit +import SwiftUI class ChatFileView: UIView { private lazy var iconImageView: UIImageView = UIImageView() @@ -36,7 +37,12 @@ class ChatFileView: UIView { 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 previewDownloadNotAllowedLabel = UILabel( + font: previewDownloadNotAllowedFont, + textColor: .adamant.textColor.withAlphaComponent(0.4) + ) + private lazy var vStack: UIStackView = { let stack = UIStackView() stack.alignment = .leading @@ -45,7 +51,21 @@ class ChatFileView: UIView { 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 controller = UIHostingController(rootView: progressBar.environmentObject(progressState)) + controller.view.backgroundColor = .clear + stack.addArrangedSubview(sizeLabel) + stack.addArrangedSubview(controller.view) return stack }() @@ -55,6 +75,17 @@ class ChatFileView: UIView { return btn }() + private lazy var progressBar = CircularProgressView() + private lazy var progressState: CircularProgressState = { + .init( + lineWidth: 2.0, + backgroundColor: .white, + progressColor: .lightGray, + progress: .zero, + hidden: true + ) + }() + var model: ChatFile = .default { didSet { guard oldValue != model else { return } @@ -128,11 +159,20 @@ private extension ChatFileView { make.size.equalTo(imageSize / 2) } + addSubview(previewDownloadNotAllowedLabel) + previewDownloadNotAllowedLabel.snp.makeConstraints { make in + make.centerY.equalTo(iconImageView.snp.centerY) + make.horizontalEdges.equalTo(iconImageView).inset(5) + } + addSubview(tapBtn) tapBtn.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } + previewDownloadNotAllowedLabel.text = previewDownloadNotAllowedText + previewDownloadNotAllowedLabel.numberOfLines = .zero + previewDownloadNotAllowedLabel.textAlignment = .center nameLabel.lineBreakMode = .byTruncatingMiddle nameLabel.textAlignment = .left sizeLabel.textAlignment = .left @@ -145,6 +185,7 @@ private extension ChatFileView { videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) + previewDownloadNotAllowedLabel.addShadow() } func update() { @@ -152,12 +193,17 @@ private extension ChatFileView { if let previewImage = model.previewImage { image = previewImage additionalLabel.isHidden = true + previewDownloadNotAllowedLabel.isHidden = true } else { image = model.fileType == .image || model.fileType == .video ? defaultMediaImage : defaultImage - additionalLabel.isHidden = false + previewDownloadNotAllowedLabel.isHidden = model.isPreviewDownloadAllowed + || model.isBusy + || !(model.fileType == .image || model.fileType == .video) + + additionalLabel.isHidden = !previewDownloadNotAllowedLabel.isHidden } if iconImageView.image != image { @@ -168,8 +214,12 @@ private extension ChatFileView { if model.isBusy { spinner.startAnimating() + progressState.hidden = false + progressState.progress = Double(model.progress) / 100 } else { spinner.stopAnimating() + progressState.hidden = true + progressState.progress = .zero } let fileType = model.file.type.map { ".\($0)" } ?? .empty @@ -204,5 +254,7 @@ 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: "file-default-box") -private let defaultMediaImage: UIImage? = .asset(named: "file-image-box") +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/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index abefd9816..31d7083b0 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -61,8 +61,7 @@ private extension FileContainerView { actionHandler(.downloadPreviewIfNeeded( messageId: model.messageId, - files: Array(fileList), - isFromCurrentSender: model.isFromCurrentSender + files: Array(fileList) )) filesStack.arrangedSubviews.forEach { $0.isHidden = true } @@ -75,8 +74,7 @@ private extension FileContainerView { self?.actionHandler( .openFile( messageId: model.messageId, - file: file, - isFromCurrentSender: model.isFromCurrentSender + file: file ) ) } 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 index b9dcd4ff6..0845b6ba9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -84,8 +84,7 @@ private extension MediaContainerView { actionHandler(.downloadPreviewIfNeeded( messageId: model.messageId, - files: Array(fileList), - isFromCurrentSender: model.isFromCurrentSender + files: Array(fileList) )) for (index, stackView) in filesStack.arrangedSubviews.enumerated() { @@ -106,8 +105,7 @@ private extension MediaContainerView { self?.actionHandler( .openFile( messageId: model.messageId, - file: file, - isFromCurrentSender: model.isFromCurrentSender + file: file ) ) } 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 index cda5049dd..5e52f28f7 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import SnapKit +import SwiftUI final class MediaContentView: UIView { private lazy var imageView: UIImageView = UIImageView() @@ -29,6 +30,22 @@ final class MediaContentView: UIView { return btn }() + private lazy var previewDownloadNotAllowedLabel = UILabel( + font: previewDownloadNotAllowedFont, + textColor: .adamant.textColor.withAlphaComponent(0.4) + ) + + private lazy var progressBar = CircularProgressView() + private lazy var progressState: CircularProgressState = { + .init( + lineWidth: 2.0, + backgroundColor: .white, + progressColor: .lightGray, + progress: .zero, + hidden: true + ) + }() + var model: ChatFile = .default { didSet { update() @@ -92,25 +109,42 @@ private extension MediaContentView { make.size.equalTo(imageSize / 1.6) } + addSubview(previewDownloadNotAllowedLabel) + previewDownloadNotAllowedLabel.snp.makeConstraints { make in + make.centerY.equalTo(imageView.snp.centerY) + make.horizontalEdges.equalTo(imageView).inset(5) + } + + let controller = UIHostingController(rootView: progressBar.environmentObject(progressState)) + + controller.view.backgroundColor = .clear + addSubview(controller.view) + controller.view.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(15) + make.size.equalTo(15) + } + imageView.layer.masksToBounds = true imageView.contentMode = .scaleAspectFill videoIconIV.tintColor = .adamant.active - + previewDownloadNotAllowedLabel.text = previewDownloadNotAllowedText + previewDownloadNotAllowedLabel.numberOfLines = .zero + previewDownloadNotAllowedLabel.textAlignment = .center + videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) + controller.view.addShadow() + previewDownloadNotAllowedLabel.addShadow() } func update() { - let image: UIImage? - if let previewImage = model.previewImage { - image = previewImage - } else { - image = model.fileType == .image || model.fileType == .video + let image = model.previewImage + ?? (model.fileType == .image || model.fileType == .video ? defaultMediaImage : defaultImage - } - + ) + if imageView.image != image { imageView.image = image } @@ -125,14 +159,24 @@ private extension MediaContentView { if model.isBusy { spinner.startAnimating() + progressState.hidden = false + progressState.progress = Double(model.progress) / 100 } else { spinner.stopAnimating() + progressState.hidden = true + progressState.progress = .zero } + + previewDownloadNotAllowedLabel.isHidden = model.isPreviewDownloadAllowed + || model.isBusy + || model.previewImage != nil } } private let imageSize: CGFloat = 70 private let stackSpacing: CGFloat = 12 private let verticalStackSpacing: CGFloat = 3 -private let defaultImage: UIImage? = .asset(named: "file-default-box") -private let defaultMediaImage: UIImage? = .asset(named: "file-image-box") +private let defaultImage: UIImage? = .asset(named: "defaultFileIcon") +private let defaultMediaImage: UIImage? = .asset(named: "defaultMediaBlur") +private let previewDownloadNotAllowedFont = UIFont.systemFont(ofSize: 8) +private var previewDownloadNotAllowedText: String { .localized("Chats.AutoDownloadPreview.Disabled") } diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift index e2cc85531..a7b0306b3 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarCollectionViewCell.swift @@ -137,6 +137,6 @@ private extension FilesToolbarCollectionViewCell { } } -private let defaultImage: UIImage? = .asset(named: "file-default-box") +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/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 04025daa9..b05756649 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -65,7 +65,8 @@ final class ChatFileService: ChatFileProtocol { needUpdatePreview: Bool, cached: Bool?, downloading: Bool?, - uploading: Bool? + uploading: Bool?, + progress: Int? )>() init( @@ -138,11 +139,14 @@ final class ChatFileService: ChatFileProtocol { chatroom: Chatroom?, saveEncrypted: 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: true, - shouldDownloadPreviewFile: true, + shouldDownloadOriginalFile: !isCachedOriginal, + shouldDownloadPreviewFile: !isCachedPreview, saveEncrypted: saveEncrypted ) } @@ -322,7 +326,8 @@ private extension ChatFileService { needUpdatePreview: true, cached: nil, downloading: nil, - uploading: nil + uploading: nil, + progress: nil )) } @@ -369,16 +374,50 @@ private extension ChatFileService { } defer { - $downloadingFilesIDsArray.mutate { $0.removeAll(where: { $0 == file.file.id }) } + $downloadingFilesIDsArray.mutate { + $0.removeAll(where: { $0 == file.file.id }) + } sendUpdate(for: [file.file.id], downloading: false, uploading: nil) } $downloadingFilesIDsArray.mutate { $0.append(file.file.id) } - sendUpdate(for: [file.file.id], downloading: true, uploading: nil) + sendUpdate( + for: [file.file.id], + downloading: true, + uploading: nil, + progress: .zero + ) + + let downloadFile = shouldDownloadOriginalFile + && !filesStorage.isCachedLocally(file.file.id) + + let downloadPreview = file.file.preview != nil + && shouldDownloadPreviewFile + && !filesStorage.isCachedLocally(file.file.preview?.id ?? .empty) + + let totalProgress = Progress(totalUnitCount: 100) + 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 + } + + 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 shouldDownloadPreviewFile, - !filesStorage.isCachedLocally(previewDTO.id) { + if downloadPreview { try await downloadAndCacheFile( id: previewDTO.id, nonce: previewDTO.nonce, @@ -390,7 +429,15 @@ private extension ChatFileService { saveEncrypted: saveEncrypted, fileType: .image, fileExtension: previewDTO.extension ?? .empty, - isPreview: true + isPreview: true, + downloadProgress: { [weak self] value in + previewProgress.completedUnitCount = Int64(value.fractionCompleted * Double(previewWeight)) + + self?.sendProgress( + for: file.file.id, + progress: Int(totalProgress.fractionCompleted * 100) + ) + } ) let preview = filesStorage.getPreview(for: previewDTO.id) @@ -403,15 +450,15 @@ private extension ChatFileService { needUpdatePreview: true, cached: nil, downloading: nil, - uploading: nil + uploading: nil, + progress: nil )) } else if !filesStorage.isCachedInMemory(previewDTO.id) { cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) } } - if shouldDownloadOriginalFile, - !filesStorage.isCachedLocally(file.file.id) { + if downloadFile { try await downloadAndCacheFile( id: file.file.id, nonce: file.nonce, @@ -423,7 +470,15 @@ private extension ChatFileService { saveEncrypted: saveEncrypted, fileType: file.fileType, fileExtension: file.file.type ?? .empty, - isPreview: false + 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) @@ -436,7 +491,8 @@ private extension ChatFileService { needUpdatePreview: false, cached: cached, downloading: nil, - uploading: nil + uploading: nil, + progress: nil )) } } @@ -452,7 +508,8 @@ private extension ChatFileService { saveEncrypted: Bool, fileType: FileType, fileExtension: String, - isPreview: Bool + isPreview: Bool, + downloadProgress: @escaping ((Progress) -> Void) ) async throws { let result = try await downloadFile( id: id, @@ -460,7 +517,8 @@ private extension ChatFileService { senderPublicKey: publicKey, recipientPrivateKey: privateKey, nonce: nonce, - saveEncrypted: saveEncrypted + saveEncrypted: saveEncrypted, + downloadProgress: downloadProgress ) try filesStorage.cacheFile( @@ -539,9 +597,14 @@ private extension ChatFileService { senderPublicKey: String, recipientPrivateKey: String, nonce: String, - saveEncrypted: Bool + saveEncrypted: Bool, + downloadProgress: @escaping ((Progress) -> Void) ) async throws -> (decodedData: Data, encodedData: Data) { - let encodedData = try await filesNetworkManager.downloadFile(id, type: storage) + let encodedData = try await filesNetworkManager.downloadFile( + id, + type: storage, + downloadProgress: downloadProgress + ) guard let decodedData = adamantCore.decodeData( encodedData, @@ -557,7 +620,12 @@ private extension ChatFileService { } private extension ChatFileService { - func sendUpdate(for files: [String], downloading: Bool?, uploading: Bool?) { + func sendUpdate( + for files: [String], + downloading: Bool?, + uploading: Bool?, + progress: Int? = nil + ) { files.forEach { id in updateFileFields.send(( id: id, @@ -567,10 +635,25 @@ private extension ChatFileService { needUpdatePreview: false, cached: nil, downloading: downloading, - uploading: uploading + uploading: uploading, + progress: progress )) } } + + func sendProgress(for fileId: String, progress: Int) { + updateFileFields.send(( + id: fileId, + newId: nil, + fileNonce: nil, + preview: nil, + needUpdatePreview: false, + cached: nil, + downloading: nil, + uploading: nil, + progress: progress + )) + } } // MARK: Upload @@ -736,6 +819,12 @@ private extension ChatFileService { let txContext = fileMessage.context { tx = transaction context = txContext + + try? await chatsProvider.setTxMessageStatus( + transactionLocaly: tx, + context: context, + status: .pending + ) } else { let txLocally = try await chatsProvider.sendFileMessageLocally( messageLocally, @@ -778,11 +867,19 @@ private extension ChatFileService { 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 + storageProtocol: storageProtocol, + progress: uploadProgress ) try cacheUploadedFile( @@ -855,6 +952,8 @@ private extension ChatFileService { ) { let cached = filesStorage.isCachedLocally(fileResult.cid) + $uploadingFilesIDsArray.mutate { $0.removeAll { $0 == oldId } } + updateFileFields.send(( id: oldId, newId: fileResult.cid, @@ -863,7 +962,8 @@ private extension ChatFileService { needUpdatePreview: true, cached: cached, downloading: nil, - uploading: false + uploading: false, + progress: nil )) var previewDTO: RichMessageFile.Preview? @@ -903,9 +1003,10 @@ private extension ChatFileService { ) async { updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: false) - try? await chatsProvider.setTxMessageAsFailed( + try? await chatsProvider.setTxMessageStatus( transactionLocaly: tx, - context: context + context: context, + status: .failed ) } @@ -913,13 +1014,33 @@ private extension ChatFileService { file: FileResult, recipientPublicKey: String, senderPrivateKey: String, - storageProtocol: NetworkFileProtocolType + 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 + storageProtocol: storageProtocol, + uploadProgress: { value in + fileProgress.completedUnitCount = Int64(value.fractionCompleted * Double(fileWeight)) + progress(Int(totalProgress.fractionCompleted * 100)) + } ) var preview: UploadResult? @@ -929,7 +1050,11 @@ private extension ChatFileService { url: url, recipientPublicKey: recipientPublicKey, senderPrivateKey: senderPrivateKey, - storageProtocol: storageProtocol + storageProtocol: storageProtocol, + uploadProgress: { value in + previewProgress.completedUnitCount = Int64(value.fractionCompleted * Double(previewWeight)) + progress(Int(totalProgress.fractionCompleted * 100)) + } ) } @@ -940,7 +1065,8 @@ private extension ChatFileService { url: URL, recipientPublicKey: String, senderPrivateKey: String, - storageProtocol: NetworkFileProtocolType + storageProtocol: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) ) async throws -> UploadResult { defer { url.stopAccessingSecurityScopedResource() @@ -961,7 +1087,11 @@ private extension ChatFileService { throw FileManagerError.cantEncryptFile } - let cid = try await filesNetworkManager.uploadFiles(encodedData, type: storageProtocol) + let cid = try await filesNetworkManager.uploadFiles( + encodedData, + type: storageProtocol, + uploadProgress: uploadProgress + ) return (data, encodedData, nonce, cid) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index fd3592f10..94ce1cc2d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -15,6 +15,7 @@ import FilesStorageKit struct ChatMessageFactory { private let walletServiceCompose: WalletServiceCompose private let filesStorage: FilesStorageProtocol + private let filesStorageProprieties: FilesStorageProprietiesProtocol static let markdownParser = MarkdownParser( font: .adamantChatDefault, @@ -69,10 +70,12 @@ struct ChatMessageFactory { ) init(walletServiceCompose: WalletServiceCompose, - filesStorage: FilesStorageProtocol + filesStorage: FilesStorageProtocol, + filesStorageProprieties: FilesStorageProprietiesProtocol ) { self.walletServiceCompose = walletServiceCompose self.filesStorage = filesStorage + self.filesStorageProprieties = filesStorageProprieties } func makeMessage( @@ -82,7 +85,8 @@ struct ChatMessageFactory { dateHeaderOn: Bool, topSpinnerOn: Bool, uploadingFilesIDs: [String], - downloadingFilesIDs: [String] + downloadingFilesIDs: [String], + havePartnerName: Bool ) -> ChatMessage { let sentDate = transaction.sentDate ?? .now let senderModel = ChatSender(transaction: transaction) @@ -108,7 +112,8 @@ struct ChatMessageFactory { isFromCurrentSender: currentSender.senderId == senderModel.senderId, backgroundColor: backgroundColor, uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs + downloadingFilesIDs: downloadingFilesIDs, + havePartnerName: havePartnerName ), backgroundColor: backgroundColor, bottomString: makeBottomString( @@ -130,7 +135,8 @@ private extension ChatMessageFactory { isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, uploadingFilesIDs: [String], - downloadingFilesIDs: [String] + downloadingFilesIDs: [String], + havePartnerName: Bool ) -> ChatMessage.Content { switch transaction { case let transaction as MessageTransaction: @@ -158,7 +164,8 @@ private extension ChatMessageFactory { isFromCurrentSender: isFromCurrentSender, backgroundColor: backgroundColor, uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs + downloadingFilesIDs: downloadingFilesIDs, + havePartnerName: havePartnerName ) } @@ -305,7 +312,8 @@ private extension ChatMessageFactory { isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, uploadingFilesIDs: [String], - downloadingFilesIDs: [String] + downloadingFilesIDs: [String], + havePartnerName: Bool ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" @@ -328,12 +336,15 @@ private extension ChatMessageFactory { ? transaction.recipientAddress : transaction.senderAddress + let isPreviewDownloadAllowed = isPreviewDownloadAllowed(havePartnerName) + let chatFiles = makeChatFiles( from: files, uploadingFilesIDs: uploadingFilesIDs, downloadingFilesIDs: downloadingFilesIDs, isFromCurrentSender: isFromCurrentSender, - storage: storage + storage: storage, + isPreviewDownloadAllowed: isPreviewDownloadAllowed ) let isMediaFilesOnly = chatFiles.allSatisfy { @@ -363,10 +374,23 @@ private extension ChatMessageFactory { backgroundColor: backgroundColor ), address: address, - opponentAddress: opponentAddress + opponentAddress: opponentAddress, + txStatus: transaction.statusEnum ))) } + func isPreviewDownloadAllowed(_ havePartnerName: Bool) -> Bool { + let policy = filesStorageProprieties.autoDownloadPreviewPolicy() + switch policy { + case .everybody: + return true + case .nobody: + return false + case .contacts: + return havePartnerName + } + } + func makeAttributed(_ text: String) -> NSMutableAttributedString { let attributedString = Self.markdownParser.parse(text) @@ -394,7 +418,8 @@ private extension ChatMessageFactory { uploadingFilesIDs: [String], downloadingFilesIDs: [String], isFromCurrentSender: Bool, - storage: String + storage: String, + isPreviewDownloadAllowed: Bool ) -> [ChatFile] { return files.map { let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] @@ -411,7 +436,9 @@ private extension ChatMessageFactory { storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, isFromCurrentSender: isFromCurrentSender, - fileType: FileType(raw: fileType) ?? .other + fileType: FileType(raw: fileType) ?? .other, + progress: .zero, + isPreviewDownloadAllowed: isPreviewDownloadAllowed ) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift index a42d50c4c..14cfe9084 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift @@ -24,7 +24,8 @@ actor ChatMessagesListFactory { isNeedToLoadMoreMessages: Bool, expirationTimestamp minExpTimestamp: inout TimeInterval?, uploadingFilesIDs: [String], - downloadingFilesIDs: [String] + downloadingFilesIDs: [String], + havePartnerName: Bool ) -> [ChatMessage] { assert(!Thread.isMainThread, "Do not process messages on main thread") @@ -48,7 +49,8 @@ actor ChatMessagesListFactory { topSpinnerOn: isNeedToLoadMoreMessages && index == .zero, willExpireAfter: &expTimestamp, uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs + downloadingFilesIDs: downloadingFilesIDs, + havePartnerName: havePartnerName ) if let timestamp = expTimestamp, timestamp < minExpTimestamp ?? .greatestFiniteMagnitude { @@ -68,7 +70,8 @@ private extension ChatMessagesListFactory { topSpinnerOn: Bool, willExpireAfter: inout TimeInterval?, uploadingFilesIDs: [String], - downloadingFilesIDs: [String] + downloadingFilesIDs: [String], + havePartnerName: Bool ) -> ChatMessage { var expireDate: Date? let message = chatMessageFactory.makeMessage( @@ -78,7 +81,8 @@ private extension ChatMessagesListFactory { dateHeaderOn: dateHeaderOn, topSpinnerOn: topSpinnerOn, uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs + downloadingFilesIDs: downloadingFilesIDs, + havePartnerName: havePartnerName ) willExpireAfter = expireDate?.timeIntervalSince1970 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 70358990a..ba819039a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -728,7 +728,7 @@ final class ChatViewModel: NSObject { return true } - func openFile(messageId: String, file: ChatFile, isFromCurrentSender: Bool) { + func openFile(messageId: String, file: ChatFile) { let tx = chatTransactions.first(where: { $0.txId == messageId }) let message = messages.first(where: { $0.messageId == messageId }) @@ -754,17 +754,7 @@ final class ChatViewModel: NSObject { guard tx?.statusEnum == .delivered else { return } - Task { [weak self] in - do { - try await self?.chatFileService.downloadFile( - file: file, - chatroom: self?.chatroom, - saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true - ) - } catch { - self?.dialog.send(.alert(error.localizedDescription)) - } - } + downloadFile(file: file) } func downloadPreviewIfNeeded( @@ -773,7 +763,7 @@ final class ChatViewModel: NSObject { ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - guard tx?.statusEnum == .delivered, + guard tx?.statusEnum == .delivered || tx?.statusEnum == nil, (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) else { return } @@ -796,6 +786,33 @@ final class ChatViewModel: NSObject { } } + func forceDownloadAllFiles(messageId: String, files: [ChatFile]) { + let needToDownload = files.filter { + !$0.isCached || + ($0.isCached + && ($0.fileType == .image || $0.fileType == .video) + && $0.previewImage == nil) + } + + needToDownload.forEach { file in + downloadFile(file: file) + } + } + + func downloadFile(file: ChatFile) { + Task { [weak self] in + do { + try await self?.chatFileService.downloadFile( + file: file, + chatroom: self?.chatroom, + saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true + ) + } catch { + self?.dialog.send(.alert(error.localizedDescription)) + } + } + } + func presentActionMenu() { dialog.send(.actionMenu) } @@ -950,7 +967,8 @@ private extension ChatViewModel { needToUpdatePreview: data.needUpdatePreview, cached: data.cached, isUploading: data.uploading, - isDownloading: data.downloading + isDownloading: data.downloading, + progress: data.progress ) } .store(in: &subscriptions) @@ -1078,7 +1096,8 @@ private extension ChatViewModel { isNeedToLoadMoreMessages: isNeedToLoadMoreMessages, expirationTimestamp: &expirationTimestamp, uploadingFilesIDs: chatFileService.uploadingFiles, - downloadingFilesIDs: chatFileService.downloadingFiles + downloadingFilesIDs: chatFileService.downloadingFiles, + havePartnerName: havePartnerName ) await setupNewMessages( @@ -1297,7 +1316,8 @@ private extension ChatViewModel { needToUpdatePreview: Bool, cached: Bool? = nil, isUploading: Bool? = nil, - isDownloading: Bool? = nil + isDownloading: Bool? = nil, + progress: Int? = nil ) { let indexes = messages.indices.filter { messages[$0].getFiles().contains { $0.file.id == oldId } @@ -1316,7 +1336,8 @@ private extension ChatViewModel { needToUpdatePeview: needToUpdatePreview, cached: cached, isUploading: isUploading, - isDownloading: isDownloading + isDownloading: isDownloading, + progress: progress ) } } @@ -1417,7 +1438,8 @@ private extension ChatMessage { needToUpdatePeview: Bool, cached: Bool? = nil, isUploading: Bool? = nil, - isDownloading: Bool? = nil + isDownloading: Bool? = nil, + progress: Int? = nil ) { guard case let .file(fileModel) = content else { return } var model = fileModel.value @@ -1444,6 +1466,9 @@ private extension ChatMessage { if needToUpdatePeview { model.content.fileModel.files[index].previewImage = preview } + if let progress = progress { + model.content.fileModel.files[index].progress = progress + } guard model != fileModel.value else { return diff --git a/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift b/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift index 4dc9955e9..64af3eaef 100644 --- a/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift +++ b/Adamant/Modules/Wallets/Doge/DogeWalletService+Send.swift @@ -74,7 +74,8 @@ extension DogeWalletService: WalletServiceTwoStepSend { path: DogeApiCommands.sendTransaction(), method: .post, parameters: ["rawtx": txHex], - encoding: .json + encoding: .json, + downloadProgress: { _ in } ) guard diff --git a/Adamant/ServiceProtocols/APICoreProtocol.swift b/Adamant/ServiceProtocols/APICoreProtocol.swift index daf622b04..bf77ca444 100644 --- a/Adamant/ServiceProtocols/APICoreProtocol.swift +++ b/Adamant/ServiceProtocols/APICoreProtocol.swift @@ -17,7 +17,8 @@ protocol APICoreProtocol: Actor { func sendRequestMultipartFormData( node: Node, path: String, - models: [MultipartFormDataModel] + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel func sendRequestBasic( @@ -25,7 +26,8 @@ protocol APICoreProtocol: Actor { path: String, method: HTTPMethod, parameters: Parameters, - encoding: APIParametersEncoding + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel /// jsonParameters - arrays and dictionaries are allowed only @@ -52,7 +54,26 @@ 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 } @@ -98,6 +119,21 @@ 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 sendRequestJsonResponse( node: Node, path: String, @@ -115,12 +151,14 @@ extension APICoreProtocol { func sendRequestMultipartFormDataJsonResponse( node: Node, path: String, - models: [MultipartFormDataModel] + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) ) async -> ApiServiceResult { await sendRequestMultipartFormData( node: node, path: path, - models: models + models: models, + uploadProgress: uploadProgress ).result.flatMap { parseJSON(data: $0) } } diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift index 0cf7c5ea8..c58a857bc 100644 --- a/Adamant/ServiceProtocols/ChatFileProtocol.swift +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -24,7 +24,8 @@ protocol ChatFileProtocol { needUpdatePreview: Bool, cached: Bool?, downloading: Bool?, - uploading: Bool? + uploading: Bool?, + progress: Int? ), Never> { get } diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index f0acab85c..1b237d9e5 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -231,9 +231,10 @@ protocol ChatsProvider: DataProvider, Actor { from chatroom: Chatroom? ) async throws -> ChatTransaction - func setTxMessageAsFailed( + func setTxMessageStatus( transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext + context: NSManagedObjectContext, + status: MessageStatus ) throws // MARK: - Delete local message diff --git a/Adamant/ServiceProtocols/FileApiServiceProtocol.swift b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift index 10f5d12ac..ce1831d84 100644 --- a/Adamant/ServiceProtocols/FileApiServiceProtocol.swift +++ b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift @@ -9,6 +9,13 @@ import Foundation protocol FileApiServiceProtocol: WalletApiService { - func uploadFile(data: Data) async throws -> String - func downloadFile(id: String) async throws -> Data + func uploadFile( + data: Data, + uploadProgress: @escaping ((Progress) -> Void) + ) async throws -> String + + func downloadFile( + id: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws -> Data } diff --git a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift index 1102a55d8..8d1fa3a1a 100644 --- a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift +++ b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift @@ -9,6 +9,15 @@ import Foundation protocol FilesNetworkManagerProtocol { - func uploadFiles(_ data: Data, type: NetworkFileProtocolType) async throws -> String - func downloadFile(_ id: String, type: String) async throws -> Data + func uploadFiles( + _ data: Data, + type: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) + ) async throws -> String + + func downloadFile( + _ id: String, + type: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws -> Data } diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index b90b1ff0e..2f76b87f4 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -28,7 +28,8 @@ actor APICore: APICoreProtocol { func sendRequestMultipartFormData( node: Node, path: String, - models: [MultipartFormDataModel] + models: [MultipartFormDataModel], + uploadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel { do { let request = AF.upload(multipartFormData: { multipartFormData in @@ -40,6 +41,7 @@ actor APICore: APICoreProtocol { ) } }, to: try buildUrl(node: node, path: path)) + .uploadProgress(queue: .global(), closure: uploadProgress) return await sendRequest(request: request) } catch { @@ -56,7 +58,8 @@ actor APICore: APICoreProtocol { path: String, method: HTTPMethod, parameters: Parameters, - encoding: APIParametersEncoding + encoding: APIParametersEncoding, + downloadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel { do { let request = session.request( @@ -65,7 +68,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 { diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index db0acaa99..160af572b 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -953,11 +953,12 @@ extension AdamantChatsProvider { return transaction } - func setTxMessageAsFailed( + func setTxMessageStatus( transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext + context: NSManagedObjectContext, + status: MessageStatus ) throws { - transactionLocaly.statusEnum = .failed + transactionLocaly.statusEnum = status try context.save() } diff --git a/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift index 97ac76dd0..0a41d306e 100644 --- a/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift +++ b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift @@ -17,22 +17,30 @@ final class FilesNetworkManager: FilesNetworkManagerProtocol { func uploadFiles( _ data: Data, - type: NetworkFileProtocolType + type: NetworkFileProtocolType, + uploadProgress: @escaping ((Progress) -> Void) ) async throws -> String { switch type { case .ipfs: - return try await ipfsService.uploadFile(data: data) + return try await ipfsService.uploadFile(data: data, uploadProgress: uploadProgress) } } - func downloadFile(_ id: String, type: String) async throws -> Data { + func downloadFile( + _ id: String, + type: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws -> Data { guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { throw FileManagerError.cantDownloadFile } switch netwrokProtocol { case .ipfs: - return try await ipfsService.downloadFile(id: id) + return try await ipfsService.downloadFile( + id: id, + downloadProgress: downloadProgress + ) } } } diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 71144e8c4..794395090 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -34,7 +34,10 @@ final class IPFSApiService: FileApiServiceProtocol { } } - func uploadFile(data: Data) async throws -> String { + func uploadFile( + data: Data, + uploadProgress: @escaping ((Progress) -> Void) + ) async throws -> String { let model: MultipartFormDataModel = .init( keyName: IPFSApiCommands.file.fieldName, fileName: defaultFileName, @@ -45,7 +48,8 @@ final class IPFSApiService: FileApiServiceProtocol { await core.sendRequestMultipartFormDataJsonResponse( node: node, path: IPFSApiCommands.file.upload, - models: [model] + models: [model], + uploadProgress: uploadProgress ) }.get() @@ -56,11 +60,15 @@ final class IPFSApiService: FileApiServiceProtocol { return cid } - func downloadFile(id: String) async throws -> Data { + func downloadFile( + id: String, + downloadProgress: @escaping ((Progress) -> Void) + ) async throws -> Data { let result: Data = try await request { core, node in await core.sendRequest( node: node, - path: "\(IPFSApiCommands.file.download)\(id)" + path: "\(IPFSApiCommands.file.download)\(id)", + downloadProgress: downloadProgress ) }.get() diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 4d38a69c6..95dfbdf72 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -781,6 +781,9 @@ /* 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."; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 433fed1d3..bec9ced5c 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -766,6 +766,9 @@ /* 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 preview 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 52692a258..2528b5549 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -766,6 +766,9 @@ /* 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"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index d796c1439..19ad1e597 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -724,6 +724,9 @@ /* Storage usage: Description */ "StorageUsage.Description" = "应用程序安全存储中的文件和图像总大小"; +/* Chats: Auto preview download is disabled */ +"Chats.AutoDownloadPreview.Disabled" = "自动预览下载已禁用"; + /* CoinsNodesList: Title */ "CoinsNodesList.Title" = "Coin和服务节点列表"; diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/Contents.json similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/Contents.json rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/Contents.json diff --git a/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/file-default-box.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/file-default-box.png similarity index 100% rename from CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/file-default-box.imageset/file-default-box.png rename to CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/defaultFileIcon.imageset/file-default-box.png 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 0000000000000000000000000000000000000000..2b98adb5f450867ddc57ba709039036ca706430c GIT binary patch literal 27701 zcmd6Q3wTuJx$c@vLJ|nq2`UjmNa!Rn79l1zR+#}NA(22Pi73(I7KL(? zVh@*Sqp)e3C1}zt_%`ua&}^KIyFF+z_ANcU@YD<1umnp_-0c?AIL$oFWEo~U^^!IQ zcgC6IA1~rRli6a8iyx4Xm^2UXYe(F;%rj&L4N803DmirQuMxJ;61*6g@e9xYdnR9t=-Vd(GpEli9P&lKg zxV+-pIoDl3xANBc3#xwPS$Nx>)hky1_$NPIwdTIH>+XNxL4V!mz?Q8KZ>!%PYHHrm zva_}A@jZLnpMK_7`=0%E$8TTy-OIm!<<-uE-94|r@uxQr^}h4&d+&en;YWQRAN|kI z{&DQ{FTVWeSI0RoP(@7(pJ$!dFwDzrwOXtLI4_g=$DH^uYuv?`#SeG72h3YGV%(&f z1l!b2jZeLpIDT^3f27>9yenyBTFyJ^|H-Mz%)Z^keE+SPMJ5)V*IU{&%`E>cX0s*E zVzI=<$KhXmLOlN^BqhkdB>mqY`8SyVhD83I!b41WhSh2vfd360m^g6gcm8+kZ3x$O zr+T$P788WYGEAGIoiO|D!T*1^4XcMZhP&Nej^Pz|{oHTY4j*&7G}}41{p*iu?t_!u|eun|s#b+nqNqwtb3gJ3!aOye?h*cI7Q=2~T)c`14YrqWbR7gAan{lkaG{BX~BO1gLKFy*J=;~{#~@M{L4|= zVw)Q>q_#1qx=su~lPnP}Oy~>wHT!qk9_6IJ(;a-n<~xFMA&g&3J@buJB*c~C-Y9K_@DxPtatuD~0eea`(oRGiKN+;%Yu2St_km4<5<2HpDxRcl8&wZ4XZ6+B*qHm_spJ1dY4|g-LkJ@ z%V=9NnONtmgogQE6-7Gt=kP4kiM5kxKVrA0bDNLIW1tVY z-n5p#LD!*1sq1rNC6Ln93Pu@j0w!U}<%5qy`f140I{Yi&D|pZGYyoCDz)ltxI;TUK zp#1BPLF6G5BMua~3SFKVCp*@6)E692dTz`<_rCJHuKnJgdq9VoA>X1Y(76`3 zv$)o;Q2;pVT`y3b@_HJM=G#Y8+mARNw72{s{(_xz)_??iFFDnH!o2y89ijGd?_=O| zZv7BPeLh`BtsicUCFj3xe=^>(#(~jlkZky*x~6_A+!Rd2Oe0jsJkGMe1SUEWY;cQn zGAL#m4O;_mRYV-XWy%*(vaIB0Wpqa}55fkD_2b zG~sxyzoesPNjBWy`j^{N?sw08x7I$!t`7fI9bS_i z%SFe*UG07n+4ob(Hn^9R{P}2G>5uLR;UXAZd+_c?5ca~;KAHm%kRwhfPE{4{haDQQ z9L8_+YAq$b(52PWNe_W#vK#)+pf~hmlz3LK)Mc|ly~|*Laa|XU=gRjSyYlhJMoH!tcEMUTXlGE^RB*i3o7QSru&D4+>DNpI zvRmT>>N~qhKHV1zdMMjlRpedMb_pA2+lfVnlQmY_xww|pqs6uUcpe~&BW(WQ6UV#MDnTuzKurrQ zVqD!)(%})|8QtEXS4r7Ze>(mdOyOXvb6(g31%)&bEvIaQE&eOV3U6t7mO?_#8)C_C zvOb12XY9w?S9K$yobqKcPEFWStL*a`tb>bi20x{VGUnL-n*dN3UVK+nYovamV+8z_ zSX%&8Xqz*c@{&d}Z0d3qR$llH2$sY7H1lH~)$r|J-2ukI3j*cGg&xi- zv}pkeDX-ZQv>JyIfi3WuP6z`*FoX@-3Pu^`l4As|1A!{tOKeZqm+bjn%1wZoU)}#V zTRZRzIgmgIB4*FmO*U;foN*iNpdv#1WGZRaBsm4qP(UX!h23)}5O`sB-EBw%=8*p_$YggGO(@o-~Co!gaCKM&&+H|JHJ4U?X7 zs;eJ=&dwtaNas`LRiCGyC5M4<8R|LakFNOp=;C1tszo)bAlx44#+;k6W84>CqZz0L`eT4ZxS)L~cvUoadz`K8`^DDq(-@e?33lQL-5<4z&U?gIJT?up3lBMU@c6ri znT$kx6yCl4MEo>C^Cll#3KNL{2E$|a0u+z1f$Z>*Yuu?aSfKNT2i;*S48HKexJ>6K z{EqRUsP)&Kk$y(MdD=X@wM^-i0`|DkN-4gv5og>804hfufZZ&e!7zX)wn>mZ2-6H_aQ2&vhlbE2LZei3FMU*7Rze_0p$Ty4DLj?lysYEbWu@zSvYo@rdc<&_d?pC z90Ero%9z$R{1-ieWU?WFm_vbvbr0GB^^;~|VDUhmWf1`t$scg48yo@x8BF^p!lB1Z z{Y?{J@*TFA&><3p`Nda7gtDIiz*@=UWmX~$&=#HLATq>yf#T>?YEWF7z=xz-ps5K? z@M8vjRin_a5bS73J_c?bhM@}ba!(b~B7hsnTzFdhF8!@~7?@+tr8sOu5~-})vj--2 zS3lHb#JA#u6Ni(c2Rb0l5#POuls#Zp?tQnGao mSQWh5U8iQO_^z(YaaV?mkg! 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 L^(=o|P96Hcq&Y4j literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6cba28b292fe2662c8b1328bbabbad7f5d0172b7 GIT binary patch literal 10545 zcmb7Ke{39CegD4a8D~6pVAh_sb8aZ(CCQDqzL12RXsedKadu9|x>IM(E<{!+z6hvE zRrOFgwf|Il8Tnwd;g6i^6Ctfm&(qv7afFmYA|X^!bG=j*DkZrlh_)c2s;Z*vKOmjd z3L#xT-#6>C6F5r0>z#SO-}imKKi>Df^_$;&^Bx6@PkitR5`yGOd6MYOf1?xR4G-@b zcD+4&_Kb{p{#dwoY;<(&k@1OlhVPnw_piQddjJ0Dz~M)u#@u`M?~mUCpKVq8kE*yT4h6NT z^YEZA(X8?_$HaOzlh4I+N!Oh=8n%7^U>vWzfg8NuzcHJ%Oq@v_>p1HMZm00UIEgcK zU4@I+tMOI!;_{v3piJs$klJ|(pfTV$mcVVNhDMUsOPHC_pHk{wEvlrX8&x8BplVSvcw7`q8SBkxbn)9_vDW~Kbi;82xlTZ@{i)1czp^;G zsLwCz#{##;J!1Kq+1q{rnPq+aP<*u|l*o{S4K(kFRvdrwwsJC479Nl5>pk4*7m*++ z%VO5yHbU%H1QD}dC@xn?9l9X1NwXf7@(u%89(3>9=lBch#ODN~8uwZ7rs&dr(PcdV zZpNU$?3wE+(W$$iNV_F6&^c9qCeAWH~kx=#`B)s{+wHE>c(wqPik-@HLS?;Sdfudn|`BTgdx?b9TJb8 zSw%tuD|kDyAO?&PNba<%bVfrnJYSPLirCjNo^e0E!OYR7?vwH2zS)_O7}$r%mPnYYwJ%?n+x1aYDIxN~ zq+wo167_=wuFgEBM2wf`=kf(|V6G#|uh&CBFlm~1SnFRi^;WR7k!--!;M9yyao_E* z0k=c9@K_k;stCK|>tW)Kv*}|l&;#bKY!O{altWw(b}b27Y~C59p{Yaw z(ub9lkq#X|3Bf2}N;IyQOcWJ&#`ANF{?t2AateywZhqf=Nrm(I3$)x3n;$+?Tt9Yw zaueV&mLzXeCRDrCA<@cM=6>lL4{C|-PGt=+eqTi^azkeSU!*=))Uy$Oz) zOP?7mbPkILZF7sH-SDysR##O{hOl(@Kga?YJQ)?(3%|Q6P{b+;>>zzfk|@te`4Q%U zH)pqF&`{$sm?rivW1f^~3^=qhs<*Q4-*>u{*4mwveJR&r%Cwe|TSNM7NO3vZS(dEC z7&>H|cQ9-afX@(vb|3fX6-0X7?fyw~rMv2Wz!efxYOa}fp2 z_ij;m_0su#^DD({51Y`YM0;7%VK}E@*_Sc(8@mvsUBnT@q8~L8C|Jx~u7k+&H?F}X zbjEWbS8yoYWlhtFpumD;r!;Kh$P;Fr*BIR77Rmu)&`_*36HrEU6Hg=a9VqA#)L@|@ z1n+jX>g}vPMWWN`P}rU{BscPssL?BO+EC~=pyF&6q8u|;1rfgtKOz7F8)l|m3Y0)P zTeKo3I*7Um9}PCYt|^jv>;?SR+!JW_NESU*YcIZNns&hW^vm%@*aqv}>7w6+ksof- zt^R1M)^WpBi4-8TL#%)V?Cr_#`BxCagf^0~qw<5fj5q-@*apBHdRdWY!ih+pQ$sDA z4PZGbOid7WrU1P(%}i|SrM%zJ%DHbE&#pAOwbZ%~`06XF9V+;?I>tQOR#-aFBk@da zDyZ`&K3*R7iW1Cb?XcZ`#yt^aVVhkGShbFoxd1^H6=6yj16sim4UAYtBkT)X`{JniY_^{vs?`6!$whnSkiKo=sf~|oZ7>uxh9#jdd zf+lD!=A+GnVY`WHyB_Wl7ybpfWoN+{2pK(OujEaUigPbEW${zA$$-xmR@BMr5cH|L zzdwatNGS@Bpc3@+Y#kT|Zb>p*GPaGBFWGm?_;S7k*P`fR zb7Q;o4ITpxr?{29Q+x7eFzKz>VgqG0G~C37qfxN=wZ`YQkFt9Vb^ma*cckA09Cj_J z=gr>O_u%1)tcotSp@?h+xXOVZ;yi{ANj8^h4W}3(KiqSfc@|C_Ur)E7Yzk?tHtgj2 zJ`N^(d10>f&tg;<*hQ#B2^cgQnR3J6MOS}JT^RmHoaMb%y7J2XQ!L!MbM2^2&iv^q69Uafbd3<#%|qP8_gdcSc4S;ZqqQDu5F}yAHT!tIMDa z%<)w18LT0wJJeRK8B|#_KE;iOhO9AD=agGZS~p27qY$=|*4a(%P!(SC>J@uLP-LIH zZcZHJ`AF;qFUk=dRRAO_d^`xdAVTaE$c69Q;^t1>_ho4~37HpK7V{7+;LPI&(ddg@ zJgV~x7_z(c-mOoJb==OaqaT5p59^RO9z+)4&~Uqo8mz>iX^sd%k$nP4&gx3BbU_UR zUpSJ$-6%@P4InGPQW3-=T5v0jT0^%otT{v|mbhtHMKaQFMkXpv>6wO@l(V)Q?}QYd z`ccXTxUVeAi)Qn)Z~{KW=%YnZ9Zj*ruxVMERSJFTRN}P4oZ=a4%QBQyAo3BaMXL5} zT{uH60kjbOggDi>?@Vlea$gOzg4L`nivm5#_hQdrdKmR4VjW9Oh`nZ93+v%`(CP!51-Aw8 zSebNikUH`c+@SziKr1hnWB;{+(2_;ZRLXOGyim1G-VLN6QBaLUf}KLIS0yefglJ+Y zp`q*zf(1QA6DjkEzly+87;4PU8b%>3kmcTiM(Ljfq)Tynp}HL503nA*(HQDj8eNYq{FntDDY13MtBFDIj;Drz(nRI zoaE!S2x1P<1P!$mgo|LoaXzY0*??~0GiT$3Yvek8nnN+WW3`rf6lly4<5c3{SQBzs z+1N@k5C%Z_$crKerryW?Lk(;3{X;PgDcB8|k2qL1)P$-)J(u`EchChpAaDcoEdixA zjHd*MkwsQ(B&n}aLeW~gI}dO==0pgSYiY!}gjn2#d8;(y z8W@M@8J?VZZ$UTVh`6{;f5@)@KmuNN=I|dEQG&&H*cAb}^-$UH4~JCCF}5%$4D;;4 zNyEUjeDAB^w>wZ`^adZ;zo>ZuHvyz8=ioc6LTDsl;%Eb(Xa$TUIJH9OIswLPc!zC{ ziU#;*@9~!+Ky z2@+7Uwv`WLjWB`@kRw2Gy5~pS(7^Gj$}CT^S3Qdu9n*P&f(5e%A$94I?`mEN?M)*F zj81Q4nc)J^dbr3hZLdqf=RF!p+a%&9Uq0f-fmv6}xH$2(y$`EE;%*s^nt*VNIkZT^ zvDv*7!^PDdm{^G_)k6_)c;Gf61i?4}!m?B0B5dL<-)-H_GV!?ip+XZGADGIhB-+SH z_H}%o3!PhdSQt|`Su%g4un zIpBrj2nvKeN}N;wfH(sl$DVI^IZj7ACx;1SfF@pS+~l35@7{(8u)$iHo~oUREw&hW z9gS415KHQaXI><4gZGg-q%HM%hB8zHR%_2%A40SD(Fy|bUWQ?69IZz+yo9Ol=0dpZ zJ~E&n1Y=XeHHTW!QxG`)W zINs;sWf%>-C5t(xNF-<;sBLqGfZ1TO@JLpN?@Iv;_Hr(T!B}MQz$EbVD^4ngo(ZwL zL|hFUXIw-wNtYwf25=x-Kw>y(M#&Axveei!W!t<|SLfFlpjG{Dh6w8_(2fQa_X;z#!)*bD{j47@oCv4m_A=4==k zexW@YK>~lmcq#M+XO|Fjhs-ZnK;(^MQ;HfsJm8s%qf6)yG_YX&aT)ZOZ42RyU4<>d zBXyunqVBA^!ACjPr^jL4AeVy#kovtkHGh)B0iv&}smBwJWt_)_Xy~)%QPyU}UldGy zq^(e+E>Lv{gwJuHU-(|D##IBK$OhiL^%9`I6wXr>Ci(17_}a_8SJ}@ulg=s+WZU zu_{(;aRiiB(NcpQ2eFD0DofhpG(%`fB(8`|L)*+X%_a{_b`io{go@yCbv2?()AYOo zX9X0LJIWF^q)ikPR>*&3Fiksm7uuU)KXt#-wmr~s{w&Y z@GDlhzdzy`!-dcj9056;uSl$T1}T|qo1Zz_(71b9LNt;rcysmu-?%*d{8EfyNVRY) zQ>#1hX5#S=br7_1ACX{h$1Ce7ay|yeHFD8k-2WppTO!56VH9CPU~v=77(@>mOV72; z6P1z+2f&4vum+H8%Gp4zZJP`XLMtwZ2O!IOrl1g$7@JYWPAz#zI$M0Uc-aT30!%@7H`gzQEUP(Hh9;`24N`slvHzV zB}IoCIbtQ=g&+z`13)=6swhU-tv&3NqNWm-L+kbu?q6}yqWk*HbQ9@3!u;Tpe&yc} z|CF(u=(=P0c`7$N1dQ5TDm7B8dYbJ~V!0Ab$6Sl2L00=QjpE(`a+$9xVt7Kxz?UhJ zLJFImR@Q{Xip>rpH>45b7@%iMF&08Bit~K#i3=S{_6ir~I`O*kOpiu3p?5cn_`s!}HZif(2izS16t3Vrl+ZzMc`JIG?}mF&n2DIc)$yXnr>-Z zM^F_O5i^FvZ&~DiEa)V()dw4WA8FeT58sJ|NAol*B;pHv<|C)ehgF_+KoC1rkFA8i z3YYl#UpgLFN&_$&x(6%5sqvq#_e+@Y8Fnp)$_AR9#m_az1Keq~ZP`<%K~TM@j8dgi>v??Lzwd*r(MK<(<5U1JAe&hUkuSPut9 zg1>{eYvheH{h4}@JX?3};{4anIARJqxba4uhQ$!gzzzJGI@H5DjJLs;_as&rCJxqd zq)}PwTL0)`8_?Hq#S$SR$K_uL=|4=^Np=-!q_L<Xn!}MXzzG3ACp>wGCPuXL3N^Om(?{sylXPgJYJ{g%b-ZzYe5-6H zAwoChKnAPEyXLA|QVZLB1_aNoRy~5P&2vQ1Yr^F#jVyvE9QE_B7@fLu4k~c^wC3GF zNq9T{al`j#p~MZ%W(k<^{aHYFIH(qcOTFDiJ88H)+^b^`_o1yCrsSE=_xoM}U+Cc) z1{X*K2%{^)jUq29dVvns;FKxdO2;yhWZBqF9mmlq&V{bC5IY>K>EZ&Eg(0D=w(=ID zJCjy?@8lyK1(DMO2ewnesn?hX$vm=Q%SImIGANImuo|1>Hc(pal}(_8V6Pv+AJ-5? zA(%Mn0IC>)j?>W3r+K6AUE}s{z7a}rs5ny*tojOeJ7Xe6kWNLgBS~`$l%#4p0rZy`dEqXG6oC?bUmP&==LuU9N^-qE3G&{dV3ZfNm}>@_2@5w6>#Yx*DG z(~g4k;Unk6dgHvnwzvhaxY_hQsH$2pXz~1t|D@Xpoh`q1UI{@Rh-FzA$1>XxM z3tl|Jo6~@-c0vxj{p+C0mbOCN3Vk0Ygw;)B4`mYxQlNkL^>`K@ z261+KB{F3iA-346t(dhg1W{~Nqap&IAs9{;{6d|S10L+l;K^U6fB?Zgd%F~Sv%)<$ zSaOmuhIt=%uGu4XMwxo7o#PiU)k!@soe00cuCX+L%DtI-x?pf{`r>&~jlOCJIDC_K z;7C7M=;swT`elg`WP$V7s-2)YJn_@4_VlFmg%^mY-Lx;~2!a2D1%aiYN*3Nv2n~3O|&i~HjZ`_}m`cl&P<_hO=%u(PLYN94mG{DP;jVB|_z@ET1v|o9 zBSiLvm&!CZcO_gEPNO24M(|E}B5Vm8Lu{SS9hwDjQEfz^CfqOqwuN6J{GG6C{MUpZ z%A9C$fWeCAQ5|y{<56SQij3EcsKfYhMtozGQG3FoR{UCHH}jLzW@WPo!-{Y@&~{#9 zGg<3)VpS*h5RL~tFE$k0s*(5sUih94I)uX?ODK3VcrE$VQyf^-Acu%UtT1t^eha)l ze|7K(?~i@`|DS)(5x>nqwI^<iZH=CpK_}|32zDvo$y%r9p(5bJQIE^ zuwjCI=7+MC`{bj;4>O+k`=vG_>qWNBeH5c(#Ak*G&lu69Bo_5}yxBq1p%E$W zg=0jW+A*PDDoP9Gu;RW1`p=yJ|CZXaTgh4)emOxH61?s>LPZJRDE#4@qUla z$ht;+4%NXU^DR#oft}_$23gCba0mxq_`AlRyI70x&(7o01mGQPjaWoO=lG(x#ifag zU*005NHO>c)6dOOFhQ=V8wY$#aSK^z?{kY_y=UoV6`TqQ_5`yd(L2lpQT`ukN)&8zpitLIC;q^tUEf4}YDx&z@bLpVpc zNH||OS2$BRRX9c1A-pZTD!e4TAUr8NAUrF)hBm--;n%{$!UqL;RJc|cp=97py(`=y zyo2I`+W5EdJ(K`TgvXF?(8Iz(;c?+!;cmn$;k@u(&Nljmi_rpf3I9R*!0sd7i(7@u zh4X}yGTNJnc%Ls7ZmHGH68v{GajEwRZ>9`nNVrk>DvD#aaFg(2N?+H7D-aORA^!E# zmfFf%nGpRh6IN6D-i84EJ}*3B>Flm>gYZd&2bcXiq@U=W@PtTs+R{m{a3PuxZkN55 z@tlK)eLW5KrNqOi1Fgdo!XMB$u0F)8(FxoaM=Xu5LV2(TjpO6idlnAZW@+q8ln)$} z9_RNMBR;bp1hSK#CM!o`A3u=JXg#yQ&-5fpMO&o_n?#$;6w&E5ZTc0{8{5N$Lis4Bc5322|m7@Q;eo{wes!dL(6_abvwgyH&xe z@NxJ@RrTQ;)cX37l*MXcZC8bsAq+wbOz2a4hb|DIadxL744NS@m8K!1A37%JjlqOk zfe%!Lzl1l?zTlO##dzk{Oqh~9Jj@xMg>Y!b3=_Ov?i+no>R)ssDeM7+MY9AZ^h8eP zl73wkI)?Fs{p$HG3cN_YJc#jwih5p1fwi{Nhw+1b>UjYLe#LmfO@12V2RtM08qaem z@Jq&v3#vf7gZJ-c<9Q|pe$jYw74G<4_=)go_`@@Zy&50EUClN1i4kSWv@UkT4L>32 zOzX-hoA+AksIS=1yRIRYd&cJu3Vh3W z@f6(ft9pI|u`%9-v4eGZooGA!XSiXC@tOAh$U#+~@I2E ze#}>$E$gmbl3F&y0aQF}dCz4^ZqXVd$*eqQrHM%)eU?UBo#etpR41{J^r=rnK}5nc zmd1G^&PDUVYqA%O*Er(C6jaOIj0<^f%{@J?3PXy z@u}#&(^|hwVdzikn^|{GsfKyMEWPU~oev7XgP(vpZE~8aLn(bxSNIf7LWITyHcfg? z=whK?i4n?)8SPO^dQJE-VjnjH*;!5lYJ-IjCl^pp9YD6=b#m`<=^sKI(&3>;PbK}v z!#UfaakB&^0E6oMtIdM|WCW1r(#67>64sErn;6_w_e?M-@W(U z>V|Bgx3EDtN_dNKh;W#2fbcTmC3zC(glB{&g-3+@g@=XT2%Cl93;#k!&<%dkS2$WY zUN}ZLR@fcMfDd7}@H643!mosXqKjdoaHa4UVOvT(C|oU^faD%#CUJ%E8Q}q>CtQte z!#v?ts3B0lz9KvY{{k-M3E^U44vyhcNB0rFAe@7sH&pb#kBLx|VAP7AjSmk4JHM|01j%YWsOE^P#CDKEG;bh@X zL~G+u2nI~wCU6IO36~>U<2MTLfq&4v+<<6pUM0K;o8Np;EYq$%+q$aHW zdys-9L1RR5}&QaJ$>f2HMV6U~Q628sD;0QScdP=+a55j9u zJID#YgMPpCmn7~hp?R~%a?!65=(t(~T?aO+gcetCBm^4lt1K5|L*h)8s}1y-vmyQyFXE4+Diy3!jBwQ*s^5vZ2gk83v1> z>t$bN9G&{b5C2yLDG5G61K=3UGHQx?k_PCrqS&uSg7^@f+w&3pp%MDP*gwh%mO;0d zeLBIL(YZ~{@CSM%H>1p6&Ct&Y<=}DfixzZlQw#iJ49v1(#NRrtsS&D;p9Q(B+F4p7~W8} z-(R|c9x5gQn(XFCrb-IPa*t-njQwxdTgqA1CE4&&0a3cZ-+90?=t6>{g{TglPwMZThVi=KfH@%!AX%RpE^!DRi%PnX*izwDM_cZy<@n}d2L-@8y(q^~;SGC%NZ41-K5j1<% zYnJP<4X&1dY&Bf3-M0S1@WUav!;>%%zy-1wn$$>CK~u=D!Y<@T;SQ$!lP~$h8l^!l zlgpVX7^X%_g&kGUZ^NIExs=Y-iQeOn+S4Biw+K@sOG@?+6LgRKF*cp-dbQX{SXAN^ z<;R(D4`#~S)ChZLp=a9!O(8FZy(d2e_n56*zx~=bAzF`eg)lY3H;%%#3EE~|xKD8OjP%BeWniK{vs!E}lnvFom+{;^rk&nV6)sWnuA7TKcJvWfWm*B#eA3<|BP* zX7ZTgo#>M`Eft`D!bgPyR*H$MMhZQ7Hqr;v=1i+37(nSXAJIl`U&|>tgN)O&(=y>q zm<)59R-1O7sO-E8{t#7-_)-@IXmy$1DSO)8%@t8dsU@|0Dnm!^wWIhQG8wNQ2~E|}u1 zS!J700dmj>M$Y4Fsr*_Pa?tfMRYt{SkY_=ERanr;RkC1~!1)EU`87EtR*}b6wwRyY zsP%Gew%T_rRhL+K4NeYh<%`*nS}e;(GmafeyKfdBB)DAfQ*lOLi zz~TL}2&}D~F$as(%Kkb8Eh^L6_ySo7*Lf{zu6ePQHKzGf&E?wYa@A_vk-YcRIukEs zD{oA4)j%~BhNWx4lnGi)zRk{SN0)-HP7CY}W6Y5=%*=fG!2o&$=`7RAs};!c$yv?l zFf_nk?zq@08HenF5o%Mxfl|`q^8Q^dnm4wx$HincA`A7I7@E_HPL-0&GJN~KJ3!UI z&`s3OHZkDK$Z?w0ZK#7Aq1Qh$>Enva6}tlG`&xxwO^4Iv_yFA02H_-XYW^C>W3-FT zw4ta1%s~8)k}+?M;W8#IGV-mJx6sfhf6Pn-nXo+9>6mR`o1F~aF0BDNz^P6c@+o&9 zVU)WCL4j=Awp?pnNF-!VG#>1E3Am z@BCCXCnQ@DxI0h56S6in<`M*24y^OU_#C4*^er%Hh7W}gp^(ILHczffrQd}vOM0000wgDIlGV(G2AP@jB|M&s^odYnX+|0gv0)PM*z<-T^e`^5A zj}HIeuKz&(zae~#{?`k@L;x}anW2H004Pi#G$!!h0Du$#0K)!<{W0DDy|zuz~UUb$v5fR6E!>oHw`{5~c|S z&Ox*FyB|Y|fq;(z|4(E95b6UN7XITUFXn#_aBxsSXz2g`{sR|)`GNZ%YW_7=VEhLv z_RbA76{m!TsdG?5{cHhb_unc21seD5392`0THE5{ZHTp@wRhLd%i+$A z%hL{pMKAL{<+_fba)B0EhA3i#5LRtk`S9+1w>bnp{p4xyI!bLpP7YDFsy`OSngf`f zv~Ne|sIQHaUpY$D5S*1X*uY_=;xl+|$HTkM9YTBqe^*rJqNXstaDXNsDnG)d@!ecw`6MZh>J$1_oB!-yR z@0~y^Eg>Q9p$pp}MrYT_opwP|#}~%{8?*$`M z(CoT3>9a`6vi9eElY~n!wmkO34fBZ63ABr4RL0FOOlgGIGaMPB^E22m5F>8!cj>~( zFHBya@jgBLiT6{}*V<*FS{bLn5m?lEojRt9JLTvVT0XvYKTk=Q{Ck4YhLtebJxozV zTvhwd#hrb%UxR#DAFu*g=^VKzld=N$9PG8=rN-flRGoaOo|m#R5^H6>yMdF_S;sUe z?%AtE(B#M60lRhmWG(cGD*>^SUIM48cC)}Vh7fZ(DTeQX_wKWX!l%KF)&%ZHjGz6g z+EkuqBzZ9^W;=KyT;Ti#@azq-dF=xcd@00a7@Q+`pr$j1f3oa+9}lPgywR~hBvu5W z6@Y~xVS6qZ9=hYmX|KtC*#IS6j8z!p_jM`N23&h$5MoC^-BxPx!*m zP5fjdhcOd!ONGy)PD7NvGBweq-3SfdBZ-&5&VEG*bJd?>4>{M&l_0eB#hJ1@NgX?2!=*iP1mWv*OxCIO3SM4$?wR{G_3z;{-Dx>& zQ4iOWl=^uw*zfZ$^CuA;;u=x`JN)~J29)3Bql8NYMe|s?gC6q08R(K8R{v=Q`ewJ5 zbVCHyH;)(negxvpjr36taV;%z4=j&osVrhH46Fkt+R-)Ok?AS{3&l2ijoV24hmI)- z(2ii?eS%2qqo~?890{~+v*Q>@BJYeg66Y+T!Wja94-4_LGf!993$tydO+Dkq2vZkg9s(AD5Tdc`4a`Q# zJnH<&0&hdgJ4Wk&^Ep6pVhlPA)4pJU5GL+YAn9AfHtOcP6uR3_jP?Y|hn@S`H@H2u zD6>H(KgACmhcV>wwUyMGb;xa?FydXfhdF>4l}g5ohxtXA_!t?xkd(v+$YsNd)+eLi zPhJBqAR58=S!&xD(+$IU1n;7O#ovDOGEX@x=*7R~zVt8^j#Lmy22z*#K5KwrYw7jR zR^lQ&=%d==w;M3!J`<>7=sxQWpsrtd3T@w^`!ReLYS`L`YvK_vU(fjmpiT&zaxvuK z{i=MN+k`1`hkLb}VsngYJuq80b2$Xqemowd&8kvQ7MS3eu2*zn*H6|PM8M7) z)e;HSf=h^9SA*{FAsMrwLLr%`7D@SjM+&Q=7K^(Oi4dP_9hISY_fW}GIu>X`yx}Ip zz~~%2f()We3*--qhGUxin^R&sSQcQwZ5mR}@I90a4mCaI;;clo?mG$^D|sf@2!3Aq za7hzW6wS0L?fPXji*1(`1y1Fa$Gr^WgOn6<1WrREo>*qVv>CJ?twm|#gpH^Qcz7yR zBy0jzM*%UoKpr{P?;<_wAjPMWt(4LZg@C=Qa;B4qmZC;T`$^#-xYydV@kZe^N`+i9 z%`Gng>S4-XS3gW$yCxSVL}SN!b=DNhfbbQ~QHL*9aYlitf`;RaX5&N2QVIIE^23Ew zf{D_T?@g! z!z4pwNTOR+m8^}oURuN97j!3!^2BGb$AokW~1%Y$7=3i1NI-vUC9NFrTE< z_*Fgu7#TqnS@$|rq>N^C@t6(>VWjU>bIANY_~%FC;i!qDoItF@jMyvW>_)VNGJIfy zsRo?kB*B87NMv520ZWhhdPtPSor{I;@5P8yU9n43SztFs`*{NZKs6JVkP9ft9p?c1*h>yh1N*~g!mc0J!o zuPy*Zg->ZESJ`W6dTI1@(4^UR-h_UBd6i)jKm4vWeGiSkQF*GgBf0$=F6_w+Ap*81 z83$D{xYiSK44Th$T`oBGN!XRy8+YR92i0JrF6YK3B`T?NSJfQl+uAe(Cm)xNj|XA( zsqppOFNm21u#^>+C19br#J?o*lvh7fScip4o^;*mOQIGTuIS%dKky9V1j!4IM7 zXTeP3$}r9&punh&@-B<&3>Gj1bk+^?+hnok@Un=3x}v}#JW_^*Qef7`EVniyCGD&! zF;;?2sc4&w*1Z({Z}vn-wrP$}{RqV*TZ^6|i~+c1SOhuluF1(?7&e;Np@|>9vwk8T zNrznQH$lB-WL^(DU)HF%flI^9?+Db4q7yw!YGzugEL|aBYFF*hTjN#D?AA;u!Y7M% zG@E0W)v%D4A|Gl#R+QX4Jf0LLD5<)v0DNS-(^ZUdhF-oc%d0RM6m@-7wh~vz0V_(v zSd*NJFQj4OfG?dtI6Z+T&Q7ym3`5Fx;i&1nNkllIk*mIxWosFjI%6#|CkGmuPno1N zoO#s-?eu}4LGLru^dKJU1bY2Q1PzS5NCKCIDNP-+Wl5L_pi$3uff(> zif3_bxTASLTzy5pCMRynsEg4bJfph864&Vd%-Tezlxn?yi-?Jed2p(GvoE4ND1PV2o@{ZA7pAvNweWr{t7vsHz?a_N}KCKa? zG+A0)C6RfME@M26%SqKOKxqC(pB&rrAnug-9fi#dbsCgkH9QEb3n8gf{56TcI<2U( z@zrlJfIHy$&yoFygD_TGI(1$rLa`Hj_p56P8?**wHXn(~tdW_2=)ova{UpY){z`(< z$X^z*S_k*U3H-cD1`LMGBViEE8P=>RK*#TICaen7cU_RpR?mdgN`;(|wnjM&DeU9u zo^xZ`kVu>2uMyz!d9^|NpjSji0^D-(a_<=Px%D0&`%-ptM+Gk?kzl)=bDf_w$;{F2*9>@N&@Y$l#f@S41@kDvbFe<}OUze9wC^WkwC05w5ec zKPTJ{RtO1z31Pw=_R!NWalz4GiKBLh{!#_I8YX7TvXSN~ZN;EQpv*F9jeF46K$CH+ z1^ouU8L`&Lhs=yPK2=F;iurR-B##|EobS^{J}=Bzn|qT zPakN94l;R)rJZ)v#n0`B3Dk(e{GK&-qKp&RAFzlrXTm_JTZqsK;OPT@(v@(f6HCD; zcEq5z*1>28A>jvMN9jr&fEll1hb^xmxUvIyd)4FA-p3zyd{ZDAg(%RJY{bpqMlQa$ z^>TgeLGnZj?QYc2bQNkBYtpE#&SiEGMrcw6ouR6R?l5EyF){F5CDDnyQ_?eN;!Vl?0TGl^__Bxuz%TTs|<_4+k7eC z0ngiQ4&EXm)qQG>S#ec7TqEc>^%4+GXA`Etn;0CLyfpt7^b0m+W9!}yJn1B>Mjv@q zqli?C$Vr&9=>v7#1#7IfkV{fyMSJ+&y5ZRFurUOA@Xv-2{s%+x_596~Q$Ja80NZ_c z7Ql-EM&F+9Bh|G30hWqWEg7zK;eKK_uPHP&qEjHv66k+h?Bv;N5#-3=_)kBjjn3v)h4TxM0=D}P1tx7 z<@1U?MVp86yySMO&>^b>d4G9YkBXA@-w|QQ>HNN^IO5AQ(H>VHYYX zHT{wb4h(i+Qb(xaYhKJjw`hPnyLWR-3q@uNrxQmoo_$(2>|(Bwe>d)hcAHw?_*zg& zo4)bwn5|!)!qTmjA`Ry5&x1a)9!5bP1e#`(+Xt;>627pUcRy_6wre9ZyK?ufkHmBQPO=rD4Eq^No+?^oF8X*BNw7G1Y zDQVJg_c?4HB+>!3j<#8us#^5l--J9pC6o)WKuCN8F&8e@m{4*ZS`4dk`Tn>3?;Y&H z>NoJgV`KLa+%NlRg2xbCB#@BV@G*x}nCB+K*{PKUSF>{$*181Tv=q&uy`_+`SA7tT z@h`*!YH*;>^s1UVJuIDuCQC1G3=eghzqCB~C)2V^+9gTAm5z@bzI6%4!C#IPG^qQv4K@x#Nn$(NUWRfvztahwi9i34HQ&Q~Vjn3(EOo%>KBLeg?tFzsQEva`k4gyO+ zJ5G+EPpv36!eL-DS<# z5D`l1{LSvm|Be7sj*y${DzO?g4uDag3u4|@qjS`j+Te2E=ipV)M5=8(&k8Ok%FhQ1 zWO>IRf#|{tJdsp$zSQkM!~UX5C{dk?=gIq)S)ig0B2{|)`K`*ZumD31Ygv-==ziRH zmE5Pjz6|6KHFwV<3|?4b2<~$enp5~i^aV)`Eg(H*v3{NlN#WFVkymixYDRsVmQx^2 z;cCV@tRWLNb*6NqJMqi$F&M)zmp!?RBDL92x~}^?_>gz*n7JZT!Dt;ZWs-J-lhMc~oa11@J0OsPAwBs=QEWDZ7U_uZ%& zbaF#oX2-T3QpXu`^?={9M7c8VSUxS7()EmR%`KsRrWgbxa(BJ`j*;Q~SB_F=$5uK; z*4v62Z)RP85;>>q8kv(o6;UCs$LTW*5V6$rw);|Ip^n*Lx1~@wtx(7Cu;^#H^KGlB zZLRppn|dtMk$Im))O=p!W!l~=vNLAEJhF`|1*#gj`?S*sZBMUOE-4tcq<7g!*pLPU zXD%wD!48cW~CH#=+6``!&4bYCt8GHjS5< zH8FVPt2j*r{&D45*8W_90E?ejAVyik=QPS?U>>eMBPJOYnz)tbh^Fsqqdbv}LT8_q zEP>ow74)LCE^xp}Xy6>! z+bAJ&9sj8_>I^_y_&WJt7Z&cfgeRIm~ zK4Boo7(Q#@d%FlEIrZkm)fdY%MX>SOChsYtZ0wUc*tvnTk|^KEMdl*sWd~DbkDNLW zL(LO!N`)eXd;x(;Y|a0zo6To}Q%nxZCs&LSt(`Gx`9pc4%B9zLbY zUNH8)tXhjx;3vCg5#@id-fxtN6knvtYR)a?*WiX!Ni}x*Fl8hBYF^aKhx7khu?G= zf^OE-hD5y3_`6U(mWv=`$fNsUiy-%j0+ouo0_n);G4D63@8T9OnTm@qGd9qZEfFS$ zNa<>$gZ43@DDQH|!u2$n=7hl+8}g^+hK2b_oFQEOTtX~npKsSRrpqLh#seGoRb#oo zkQDSgUg|O71rV5-X$StEUBF6cJiyXph4wAYL|SMe*|F|nk*tVsy1lqigI!%?PnJKT z{4x?XrQWw(w4H82ybOC}k#?DtoF<<*TC}*?n(&yFDyn4cw2-w@LJ{J|r;?2?2Ba8{ zV>I|tVlf5fRi8aIVA`b%gF%!;h)5v>U)6!sA4Uq0CzOzf$(*9p+5ZOs&(FJGUQQfJ zPgLeB9cvQ8*^?ljjIeJzAul_)X?T%#q~Ah(oWADWX9)t;1X3`Ea9`2omo*X*2u#U6ITh;Emw}Ptq3yIigs3AQ`(##V1OB*@} zWAv_I!iPYb)=EIGIeL=bL;9Emi9+2m_WZt57#ZOiS zcpp0}I@=3N3X-g2xMVFrShLG$d+aEi*ftpdiRF-ggqYK?t;q`DjswFpDVdQ8NFJCe zQyzjla}p8mOK5T%f(i)JIG@y&?4WwoN*N=IsmA0GsH10MZX}1)pXG}|i?r3#nS3n` zuVNC6h@zp-xzfpUq{$OFvKtq@+`&`2YxoMWS*r%aLoqx_S+8DaPgR5dBT4GWg2Mk2zw0Afg&GLUPN8GJ9#b+HXJ3LFX zm$Tc-?#S5Yhoje#{}uQqTW&vo%yKIAh-CzTfu;}8Y<$hNE}AuSl>9#GhNaI^q678|*tdj_ z%>A`qjdvR4G4vhsyb)M9n4v<1h-*xIHcoP3bvd_gFe}9brc`5PuiE<%^#~~jckc5_ zjqvR+PN_N=Qe#VClJs1`?-OXnEguEmt$se*%~C%oWh&xVP-MdsgB$Z;JbSy+b8aMt zV8|bF^5I={s;9|)&Ljpy^fyJ_$QSbaoIH~tI%!cCXYS+G?TvQPMow_ zgr#K}CiWnhGPd1U8n{CRQ9>0HPN552vEQZcQ*>qfjDYeq_lY;aEP_jn7E#sl)__a4 z6$||}5|tOJ$h7*j_?o0MzOOWm2Y79QhShzxAbEV>kiJtiQ-N=%EQeOaJ@D;tNdUOn zJ0@d7G|sp%YTArr9Ju1V0qioMvKY$`ADJ~=tQ{E>K$+_{b}CMkJ~m3pJ8x@b3UTqo zX9*VpKv#to6W78lXMOv@{);F7S|z1pZAg$;+Ki4Ejz!(O$JC-h?eF z88U3})5wAgl=OITNp-I|s#7HxbASsChYjY(&2FD3d$ViF9~x-L?*cu{rHm>C`Y?Dy z!Yf}5=;zJ~G*;Anv|{YupViECa{zr5nuEu8j8y~w+gph8KY+5NdjhF~02WVQX8L54 z+*%YBYx1qyDhNgGHteV?Ey5l$smyHan2z=mw7-J$t2J^u!^5(Cz#g^{6S_KAdOt5@ zW0vpR5)Q{Ucn_~Ag(`5x+U}UxDiS(XHf|GnYKAD^-5`(<_VXHl`fw}Y1@(kii7T{d ztAOK+JK*YS-O-$HCzh{1QeiSk6&%R+gEDkMX<8733dpvwxwHx!ju1i#PoHhD&`d7Q zl%-Dx$?iv~39+n$pHqfY>9HMs>u0f~D2OGY9uqv@Ii$--Q_+rg5~kH0Mx;qx;{f&F zBjqV_h*`J*+=C8lOkku8)Dz7WJ)iuiTvIK>dS|T^i$`Ee0M+d!HFfRRm zE1KFdK|tVu3Yl7g{|z;34|6`v0AkIovJ&#KY%;Qz6L(;CJI;6?{(r9X!4m$4xG`Vq zZ{K3UDAtt;3ZT6lFct2mld|8RYuDiD6EK&n6=2ydth7qF0Z@)+{_9o(HDSuXVtaBIwT5FlP$Q=qJv7PDC^SuRT- zwgl%Ui8qNS5Ib@lq7jT}t=@;7wVV&PdrY~$yr_SP7PZKa;L=CNKNLHr%U-(+6VGZS z5rbvlx>(pT+|7jk1?rlQjio=*P0Es4An;F9uJjRRMHhicGDCgvar5Df4oQ;-p1vd< z%Wz9JDM{rFRL%(~ojPorZVuB)7+zE4PsKbtOKcl23CrrfX-F7?R|YmiTp$V-(km7!?VxDxFA&}a1bdT7MC1dM_C#Vo>sIIqLt_nGi_@GO*pSQcA zK8j_0e>9#R2Jd~^w&0y^DZCyq)?6uP1KW2;=jWL z=j;thenkwIbG3YPdD>ANbfrVDCah_o_tR#Xx`tj)1BOs$)pf9O90PIRRzY7F853!m zIan}>j3WA##qjHJc)-L2f*V7QV!wD9AMngCnNepXuhsJLW-FXj0A58qUD1n zs2GZ22-%x3p^s3pYcu|jTp(DW|IMa81odBW$nQ@r$53aZMD#zv5ZB!bm_b(}VEoXl zHW-&S^A8Ucgwi&%d=o+Ygn4VF7%7^i=Y{N?ZX^8oJ?>O26d-pq6Uw~Qa4U~igv5uT z;(R(4HtAM{5i&dc53uX~@_l!a_;_7T@&xLNXgjq56KY_0A^DXP7eAY(3{Qslgy<)` z^+>xs@?o-N9O|gn=?0VgAeXS%;p=Rp+reVcuiN+;9bZh9hsMTeB2BN61M?{X?FC8m ztgtRp-E**CwcXY~z(LW(jYr>x)dXK2TU5Y-luq#RRm$zjXCjnb_(Rz%(#aKe#ULj2 zWW-~m&VqY3c}EdPD~ccsdIxIhIyAYHSR6qvsw6stA==Z~>at=IZT8ENN~f*rh@CX{ z+0C`XUjp`*7inlz_)3>v#}_ks^i{w4wH@d zQFjvhg}|HkWsh5wV5*Ts9otaWx{@eRFrJA3l$Y8MJKLAJEKR`vH60Re~?F=&`Mi8YpnO>4elGI_R ztUzrbAnAB}&&qS<1g#j8$uq5aBg~_kKQQCM@4zzqNE4F zbO0%Typ_0zP_B+tfT5)G+PJL4c_fz=^**(8iG2xFzKP`x3p916XTMH4W-`Cf7vl2A zoLr=uv-%vbk5%;TWOrpo|C5wIN!ZRS$dEFM?&-dYU#<8pIS(#smt4IEGSgcOMytBe zFJ$M(U4j$iR+kLC-kAGBN-16^zCMS*hS3x@uLv=99>0gxg+<`QH6?1=j=tBI{Oe$u z(!tmpNEniAC!#PHtNgSQphe`^y!vXBvSc8%8pix~dr6{k` zfUyoaN-O}YTj*MnWWIlZF>m@}IvHg)SJe@I)A`oK(F^v18HaZ%nABkpke<_{--6e6H&+U^J>pKMf_`vgpt7LL zlsPFXsRcYwSizzejOzwHp|1+g?%vbsVNQ5)$6Z>9T7Ta(zSD_NPcubptCYy+SNsQ1 zU?*91?3U1Aj&L170e<_5BVksVsHS9=2rv_%RV%UP!Cp&FWCJ57)W}UaP~AB zbtN)*AxUIiQ%nJI`mkzjZyojTeQgrHrSLZ-f0ET-Q2yYletig>js0nrMH?p6GpA)`^irZI~wUOsbKgJ}J0Cp`v_0 zQdV>oCn{r6;f%h&6B5$j2JJ_c7_aY@%^diqw;qU)l6t+=_~^jZrHk6$@r4mFUBA)I z@?h=g9{^Nrp1~z{Z|0R{+BGJu^xMrYoH2Og9OgUA`G;VO=vODfj^@aH_t6=BYTCwc<=4dxUx1m$)$Jx|dAiyN zKV;O=N>B7*5k9kma+4OIt^(~x=R!XDbv6#Z(f;e=!;7Y8dm$M&cU}d2=yUO=_>Z4<-gi4R+ID zR1Q>3sA^=T@fp7nn`Pi|M8@xv?MMO@eN(-w|JiyTED)w!ctW)j=PgMywk&Xr(;=h*pIk0^tbZ+SJ$ zAO#275 zpiAZ!0cg9DeVf1&8SCvkrD{uFo^*JuOH^hWMDxbb&ht3RKdmxlWJw(|p9XlHkTMW8 z+%X)=)|07mWN#$xvFKZkC*nuS1OeEvVU)&&Fx8(niK3j%8&N2KW%qpt!ia4r% z(o!W7Zbc&H{NU<%zO8EAyZ(d`i7(^6Iy~{4Shz94W-Nc(0+Aq=xd4r54KHr?7Nvwi zso$-J)}mKp-3h)VZw6aR7G+B1B4YNh>xjx;FBIwpg3)U>Z}jyk1WWzLE`fw`sRN0w zgk!K;P+ADa;(9jT#Xj0EIw1FTI@od0={OsWL^cHzqD*i{wj>tsgzpuaf(xO-Q&>d=%Dc^8*hT`k;tcV& zl9_7s(flckAZQ+!mAa;$L{A!_gSA30)Kp~Zk^`^>OMS*|@7N!*tQ2>+piaNVszOM% zyUm@y*o*vR27WIspbz2vlTwbqkp!o>NSR>mm5mqEMIa%5jZ3C@v{#5=1V0h0ihBwH zQnac*e5f_W5B6Lf@guGG2FIfV@bhku_=)Ize{MfgYk|YA@#E*8^be7d2rsaMY`U!< z1qvUr6?`^Os&PK8QXi?VYS`!eD<|LkxeC)~f`cPSZB|sDHxruzl{}-aO5^5(y6|nU z!kqt*VTn3nSvZt!h)_rNiFIsEmljQe8=UYvBZG_p3Q_b?0+f4Zx0fYVfm!n7D$IdO zIpW#NSbN}Zz#bZN_>PO_Hr`+aDKI1=%Q35>%-|SH)CH55R%Kb_OT4AkV zNf6mtygl$E{{aRUL#h-h1jrXlYGu5$0UTr-q|6X&qlj; z#O<u zbU(f@-9DBPUsU}AoQ}ISyD*-`_8M7r=x5TF+56rQ=>GksW8{l9Q~u)^;-=C5kT;am z#vaVzvx=XHwf?5pQB4q5jv9F^CRq9q8Oz2@e+#Oa^*3mZ8VWY`X}I3h#DwUk6PG@J z^4jQ$za9xJL+>!x+zqKIzqrqx7p>hL3nFe3S)f-l7q_-TMRu`2!LR$skxVb7(g{GG zGMp)Y=!L`&5iZlE_aaHC|NPFG^=u$nm1pR5LTPmhtuWLq(yMduqJQ`YXnE__i+F=C`UlV;k17!tWu~K!I1;M# zrF~#Ox`voCjJ?mSwJCk2!$M&~o3otrnK#eV!FEHWNoJ;$xf=lrDQ|t|N?7x=@<^yQ zdha;Ul?QzKmJz&Llqtm7;=;|%WuqjI1Rs$F)7cEuM)$T1wTjV?{Z*j@!boFwH2KV4 zuHj1^0c@>t$0+YMy~%J#VZ_En0iN6|lspIUmy*xXXPT(WJT^LPJKER2F~+{jdWiM9 zXO*aGC=@^Y6{>tLHgPbdW8da*XhmfCq`q$vEo#*l&~)rmgJvW&`NPO`dER<}s(wP~ zw>;2?fVL+65TeP(t{vD}n|%ys4+L5^v}fZjP6P3EE7@nfDOgz9G*(0?5W8Zm1vfX+HR9T6 zE3a}awSlQ(G^mf8(GalkFB59PIzjPbV@6d85-60|g+)W6)J7TlsS&2E+y`zBDtO+} zA(uK`bT#~cmFJ6>4Zpem`Rv>sWOU$ zJh?0m|K#XB@{lNT+jbO;HwRI<;YWES6f^jQ2QO4HsQ!=q*xh~p(!T~xdp(;3#@Y}0 zR)&Q`33?-X4GyJ#EK%XXbDSi;evlCKWhA8hf7l4|F`vcqNN~fCp$r0+!E-xF*zA>^ zYdoNz^EY1id$Zs8dPC#Aiv$q$G>ras7s5oE(sDWZuUx79RiPi zY5Lx{KqLnjtjtu$*$M!}7WB=>9E9oxzWlWPuL^W*MVr*&$HPHP>-1k+{+7J{{6l7& z3|E7RU;BBM?I&OOgsu)%fZCHG{-WCNjd42U6(+JRC5IU)IiMm^PTGuwF+Ee7;UNCUB2g~6q&<#kCFI?m_OaMA`d|;JAY0xEihdIN|TGA>}^X#gP{k3L0iN-nF(&p-C1J@`V62ywhqTv}~FR(HDD@ z0U$+Iy~F7DYg~y2cvnGKh$r7d2oqxR;ly!g`-;|gtmwJ@&eFaniRJn4SWHGqPUNHTAd7Wr|DzVjEzpy=;PB68`0(q9HN>(ej4B`E2E(ya|B!{88_XVbwEyGGl$U z)T(uvS{=-hm%#Oa+{KH(u^||_^CyiCp*py7I;3)!C;wR?%ANPE_Xq_x5=kVmS|mc> z2V#%*(SH&82$~B?(P!7?!m|QJ2Hl;U5+Tawq$Y*>HJJaZ2*E zimCuEn#AEBQs?$2f36GIHl~Iq562tEd^=!fUqfcr#%TIX(f;5J-kCt_*9H5i69Kyf zPg^~==^Ufm^mAL;v+~gtbK3&jGMeI3M=(M)gglcX5nu)bAf-8PQa56qTp;7Oiv2vA z_c2RC6UdUE%SJL{z&`RkO*!4;ggrD|+ZURI;&^u!ofPYUgBrCSj+FL~l|thRZn|RfN=AKP7F4iju!ip4PE1WJW>wSq35v&x5oKKl)iN9#YdI zu#q#_UMtqLn2< z_~dg3U$wB-ms_?Woe$!y#X@ezZ4R-$lghhRy3!doy;hgou>iA=Ix?02^M^M< z&2zmS`prz=Q>?lCNAVOiwUas&lWV2=0Gs!4J@ng_x}}l!jNr_h6*}aFQnYIA1h1fmBxlsUnK3zNW%; zOKI{FkMNL)eW|K(-)hCNcUed14pUG>k^6PVc6aXnkD;KH>`m}yi_(Z;Bw|S8hS>`e zUoNc%siz_xF2P6~s(GhFzoT+-uww{zxxKP9@}Rs>h%)z&7_7D;!H%7XVxyy^ZGpY& zi-=VMT~pV7KOFSrPp)^*AdQ^{uisNDgttN#zksE_oPV@Wus3>QFB)7Z-2^JN{wr65 zkjGha^~e^ulxgrYeVs5wzT}MR@!ogfHm^J+#01&*nR)bmiwA$4P_c!i={7GQ^-?Zx z*q$p{(u6lP1}v-SCp56fgT6nWH$5o$|EAt0jQoRZhg0=gBljkHt(tuyH3j1%CmSee zwh=B$2ui_-oz@txIcs-N)BV^Tq{y~PAH2zIsbr-;G+^aRNt9Pi1o=}E>ZDVL{$xzF zsx*5I)`-3qi=c@$ZP6>1Aw*3Brv)b4HRkI(Jx9KtJ%Ag-+skya{4Vy4Ts3e5Zinwq zA^DhvQ1g!Gx$_T4kqEnZ5CO&CFM|FCOSfbMl6|)Hw(ciDcD58gax_^Wj~SwQSR zV`PiR!v-OgQFGI=0lBscSso;|XASwEUqHGM)D+(w$x>-|?E)8V!yE^#eru(WmeTf7 zC^b7tA~bd--G_I{O7OUi_~_z){$Zu;{FP4atZJOn5mH9!eE73#?Y7Wo8O>{nH<3TU z8fpE4`F(K|ep4*SGSLFlSykFm;$Q-<0zOAPJdx${pdvUghH4Qh>m%0!xdc=k+y=7g zOj7M9*9Wwt4C%-jE8D)V7Yh5E>~2Znv33JtwCx#jdw$amBBxv;4mO3bD{7p5l}cH& zTpYd+ZeP~(Jl0Pz*HSJoD_KI7od*ed=UJ=Cf;r#;fAX) zUT7ILM>(ln5QJcPpnP}zL6Ol{GxvfPv!G>~#$=oELiegGVN*)tyk82URXz+qwJ2H> zmsACIyf8D61`2lv=RrRLT3mEGn)x`;=uMb+S)_FXV&3}QYOz`y_)Y|LuGUEdoFXA~ z!A_%YE$%Tk!HMqW%ua8RbD0E*#sg8Bjc724S4zm!M3sH}>GW8~s8rpb``AUTiiXER zKZu;psRIA20cPK!SimLe4fSniro8Y+Hc_V<`xbUOg0=)=P}S^AcwPPnSONDD?)0HI zO_%=0lftT;5l~sfz@T%uzB!~cZ&JTLc_u3=)uxMRGihL^`w>qb`7WHrQ^Ec$ZNN8w<@E4!TvnJK zPE|MWjjk_rsTiEGDHF&)75O_odhgC5*zgNl#5*L5h98@qlSW$MKLtEur*4;Ur=q_0 zHPD3n6R(5|WT&?pz*)ofh6q3LAkPOn2sg!9sd|^XA>XH7lRj)D-=}T5n7soSQ4Un0@!R(TDxXnhnwhSR=l89NmAfx zUJ$ZT*9S8ztxuvCH`8wEm|$26qy!h|&ujqN2_C=k>1hpu=&JaFJ)D1{tmAcVDTS~S zxqQ?OEN`LNxa?xVJ?T_5*0%H9m zp?$WwWKjHnJWX;o^!n7=6jR8W3V2e{NwcDw*A7ECtGT4qTNN}Cj#ZbWN5iO5pWBrOCIHbl6g6_KW;or48cvcUQ+xm?PxBoKQhWnXu&ZlXB6Su z5?aPh_%?(najdyFxVO&T;3s|_JY6~zTJ#J;9OU)xJ%>0rLeoFCzxJcSg0~2I_WRX0 z*s8*|b+u5zDgOXk@a_J;SX9jFKrkDaN4-A5Go^~Ef5VT7h|%H#{{d`n71K`O8@szj zA5Cs_pL>uvvi<=e+aL-r9rr*W2Dr~bGt7v>%79D3=WNQ?rp+sERczGn2X~@?YVG*S zgW@twI{d|i*aPh})W->Oy!Sm}O49K4#l8!Bg^u}DoPo#L#)Lm{_?$L;<)9Y@8Y*TK znpveHvYmF9%VzNMr9ITg8r69Lcl^&@le`A4PSDCj&u_uT1fhTpsLIS3k>RGwAqRl-$9wbd=rAVmrkf>Yexix-FDPH~sw#ogWA9a@50 zi@R%!6e|RRYp?>vA-wc^|86F;R&LIkJ!hXYdj_PM9nfbc(K1Ak{p%z<-KwKgd7-Pp zad*CBa{gltPf|WrQHfOx&NijlMZ)OJe2No=bu@6`9@CtNJE`O2QJYxWFYj>livA3~ zZ(pVMV7}No5_>C8Qxd@u$shAaA8wgje@zVc+<5_f!b1(9x>Y*1{P*Y@43C>frT^^d z2H1Ca)nukGfS+RX&ja1A6mR7-g(XZoGkSM`BO6#KAJ)y|a32%ohyr+&5gb**akG~w zmoF&vK0Z29VgKzdw}X9;&1f9(d1}o6=8IZs8*=@{)c7?gzF`UNquK1ElcNp>WE>v( zuA8JxIbh*oKZQ)h(Y1o;!|bI3WiGNaTZ1sB9hj=8(y@gPifp8J&qofG|B~g;EdJh5B2WxOis!%Js(BTKOv4!D6k9Ola19lAF02WLb_X#+R-3bt`bRjtOwL=;9;k&+#mVI{kw40 z+=oeOIYTASVggmRyD7+fj5*8Bzq(AvKF7q@YZbp^J(*MK_lUT|n!&m!yu<{&Q{+=w9<2hQ>*nUDzLvCo)e%0`zO$IaQD4MY>9+i)B@e&CIM0ciaa ztRo#Hv-}zUD;vjRn9%0w+k9R6!S&4^!-4kT|Gqt97Dz|!9FMtPgGC_)p7XUi!9N6z`aYG`sVQt|VR7T+zDGphe zoIrHo4=2tyo8#?s+pokxO+NwMT0Bp_XXO7mtkPYpL&dXyDK7Wj)PoxMW6W*V0udH9 zt)$(1V$wC{@>W@uPH$IEAxkQ_lPDp>N7X66`*x2Dy>Wl6U**>PQ~nqY4Y9!xhGSCc z6G!g>fwI7wrW0Kwub20Q6nlDzO{#XzQb@C#i5WiwqiA>s?x_9Nbo)aLIT5x_d314@ z4~>VpeNB(S80MXR4s?(KpHnB*vhz%dGq*W~l0TjQ)i}0oUF$bPDI;yNSka?B*<0Px zsQ@lXUzuetmh8xu*GD&_#5oCxD}<(0%@nX~^e}3L9jasZrsKW@H{=WHp$>bO>Bs$4 zWI|R~_4-Kxnj7JfS{d2>Op!ln@T~hi4ykT`halo(GZ3xv7IHT}gQA5`br5u`Wxupg zGshz15s-LDs%L5U*3>N=fry;26J(s0YTX&i(^Vf5M#f~2SLc0kOX2VsvWM3CMdYE1 zE6gZSarUFb_gJ1b;OGR#t3xnaXbu%gu2M;5SB|u=MN5r_3ch8m(U1z_SJCT(Pu|Eg0z4me9QZhO_UtOA=&0TBmK z>WF-mY8{$lP`=XGN3`g{>P>78 zwc}pfsrV#1k5Hi%&<*H(Z00=^%CO^>aT4vKX@iOq%+{^Tab|t&xmqXvs&qCQU(40) zaD)!Kpp#!eM& z#Ts*%OYwtF#l{rNw`70F7}om3r8A+)`>y{K*B~?aa z@P{I+1E%+N&&1(IB&)tweVXyDEz+Afz}+k^!|QZRh97SYs}~j>zQ^I@B?oX}RVEPm zYV^)*NrtS5zOTd5=>eWGzFNF6eDfNN7E@X;D>b`VQ=O_h*JOmlSy5ZoJUE#x`knu!rEl2 zcrDDbu1-eUMX^&Pu4KaKri%n@EVkr@8O7c)o9~+|295LlTjNWOScy9BSf+Iy?hdxd zfa)j{@UTy~64iJsSlZVa05t9$OW*DyXopF-K#9K65;ux*fL-qY7LRVb^@jwUf1Ir| zvhgB8NhfLKZ&=X*$~PT_*v|7JWVO%&b-}2?e^29 zJ7XgjU8p9C9kFb`8-=9O*>wER_UQR!r)t3u!{YZ!XBpKg&ac(@ja;VXW|-Sd~O@WNv?LiA2Qd3qis&O8)~ zX@E}Gp~BhK;+b}1JxpHY-x?co5V#DwRdT!}-vZh044Hyzx!Ml8!#B^$2r}DDFKoF< z4|0oHJYHu9d(fei$!R8}WJ7XUFb2~{XeK=x*U)&zE^@W08G7Zy@B$ZrT7WXLCFKMO zW0vWFa20z1W$5Osi8>Q&i$6dzf|4<>@1l}zX(i}v@YMbd^7*f7Bxu_A+u2d5T-kx7 z5_zTb(^622;8sKGN--gxH-FFE;6&N)eP)p|c__J#UyIoa}+WMJBr|o5o%^L#@uQ#kz{>e!^GldqB-fok=MDI_P`-qs%wflE}+8E z;*&5~yEW`fZX`(s-+rCTm=I8iTMV3^R)jk}2gx*mX3gRxj=5f}H1815K-Fq4sjC7e z!_nv;>YS8pgQ$93=-sHBqZmpXV%2{dR;MFF0iqA91{*UUKY|evWD7TWRh=xaIf?E| zTC?ZuiP!wtUm5h#LL*xfJ}IKR766;0uTlRFGiDjP{Vf~B%2oSpHy1Cd14aareI9Tx z=3_5OSS^2M60dOm4oM`ZC~1hG*bY3qHldv#=cO?nGWYWC61A%Y3S4oHYzmE1YGMDG)Pc)E?3i0nO`DN zQfHqM&zP2AXXLz(IHKZt;>poXu&5dp#Fb;ycO91LKi`-OV5ocY2*3~Pi6NmmOLiG} zmKF+GwBPcTm!cb(mi&uRYWf70zVI?==(zjnQpSF;tD*nQYVD!Dp(EoKm;%qNGb z>t)8S&=_Ay&<(_?9oe8`BdiI{vmSVG^`>`)8BCF*zOUZ2m4fJW`FvBSR4g)e!?o zst;@?&FqCk*?_|{x!*hExiI>nQNN_l+o|O_l(1I=EXioeDq0O=-pBztYMh&H z;&4ROBU=ee3BoueuH+DgIDf#;xnL%VYqDL>cwI7`Z=;sql%iO>Zw%v-X2W}r5kJi0 z6)E0*;xAAoo-^rx|oZotypW6@q^D`*ht&irdz1rMJ&s zndaA5N(VTon2JtaUjW927BoXqtaO-XwO7Ys;jhN(ujDHEVwawRxx({ zdmfox$OQTvV}CiJ`-8Cd-AsV_lvJ&F)?>=zZ?fT$EW>mYIXtK#Snmjv4<0w^FIqBL z8=jRU*dHw7o3;(lmj)Mo55p_`V=rEVL6p#f<;U#Y*KKW|X{DGT)s54w6^y z^Oiyt3QeCIhI0z-FMT$Z={^MX6}rT1w_*={liiHt zo)oT$r)ee({CT)z-%GS|1ZKdivBP+?9KBD$mcu^QV_1)n=_EX>honsgn1snKQNZ^d zBKWQKpbJzK1V_&Kz3@!z5&C=3q8M@gg1^dB=on0TNNpmjPf;tF>iTIct4Oe6)=$cL zSLGmIJf#KcX#SL6fFjcK_TW+ao#>cOy+9e=Mys-f%Ga73^by}3#*SjF_pV05ly-ke zM|-nP11@PI@#OP|owfxUE&WI3y3_BD0`~Vh4X0yA{D6!FK2oH4pHvyC|5gsNNEa1J zd^ypD>w`j6dy5pu8>^5C-M;HoBSNjIXAL?D9ygYNr|>~$NZ-f9Uow zsX4;=TMo)5&T7dD)r&CTgY!fd@m-m~QQo))g3N@wP%8AprSZ3y(p;_>qy_1Q!$)oj zr)UGaNz4&i33}Y97EiGRN6|*}5jezWk1EYyww_BQ;^*~A+up^r$FNP!+~aV4`=ede z*IX8;r)F@_&5MFp^jH4_8ix~ONqJn&qb&NZD(_-l2^J?usjk1Be{x42Q5YXqDK)f1 zNV>^)9-n4C{xC!Z>B_pb(ur8)ObcCq7iyPtHL-sAB?;ftSHNF^TCswGHP zBA*;1>!f(qtsETh=iy%Pur#brn!V{DMaYHg>wYJ2`K_}5_=t>}lhf@Su+#}^ray~d zZ#-`tA~jp0sUce~8RWn=aCWaA z=Tx0_N%bkRn#-ujBL%zkH7B{aNbZ+(fS`2`I9c5Z4GB6eAmOg_?IA5gM)b1YpCl6n z@v@xJz;P3fIuidf&gK^Eg<+GuqRE-_x3nSae1Bv9q46t%*n1(AdfsTY=Jq#4=lGIS zC3u%1Ay>6}JM1cIvCwREFNdnVswrjuu<@ZNSDD3H*Zip7SxIa;;z-^uN%3(!U&)yX zyL1TG05_Ya6e~VXQ0F zIutH@h2uTlQOBHSNWo-^?%0j-!FNu;_!w4_N1!?tkFSqrmubXn@9A~6UPbxqaznID zfruACP;ldR0QW!Pj3CyIsY2jAY$u+Q;qCK(|Ec)=1+ZbHAMh3X9$WsuV$$sk;H2-7 zkxo=UAj#^3Ce#0o`(uy3)|ZrL|6hsx^9KL$T`xA`nOpQ-KBXPLKs9?h)(Mzv>$+8y znZMO*wTeS4?+Rmm<0Haq!6hiiR#l%Zc+6xL%-gyNMT>K zrwI9LTqzddtDj@(v^WG*o}$)t!#w_2be?90+DWrCY8*{gmLwa|9|K6GbPQU zKBcTLY{N_E4I`Ch{j!W36?-yXv3UOZ?;_~ZmiQzIpzXo7l}{j&rdKh1Ri9+Gn_COg4!we0-vzBIHs`}HxbhV(lH;wy7BYbQ6BHDWb z{KVc@cWPt_Otz3c-Sa(!+6iL^;n%i{6D8lyErzrkTL0jQT7NJq_9Wpd$y~ zRvI^FrL7cLo_sXo0!4qT69t_boc-9E6Wzg0uZ5pDdT5nc@~i`^_(=wx<}Jv`L8BaO z*LCY0OqxCA3Wp@1qY#hISJGoI68^c2_s?Pp5}MuMV#4Z2cl_dEC~xqmKzju{cOds! zeHt8f%>GxtUwi`mYpz5MQBtPz-;V^}q|y`hZ^VnI7IbXOynu9tz1F{ z9AZE%xx%4&DNdX>2S5C2AHE{wJ~3Xz|2Ugz5d=$(g@Gfpf4~Y=Z+W#{Fcda=I#VHK zrwwd$S{MpQz+QI7Yge-Hm9DwXkqTPFOUDfme&0lJ8-H<%NXK+=0Ui#9(qZFHE* zHgV^3#Sj)`4yN?n@$GVl@c!#RQQChWpT1q9&^Wr?B%ys?sDLS(2o|%okBo32_|DG- z??yIYt$$tPqQ!Fo_gT4MOc$lU0O;6!=P{Iqx@aH5lrg8Q**9guMR+>_&R4g5kH2vmz<6jl=@GNqVkme=x#I0eRNY3VI<55Q&y0%Dr-;|;-W4#Mh+7BpH&f*MLN<2_PDTXg`o80m0y{WH74WYI!eH`^duD_zp& z60HDI*7TKDKL^bQVNZ>Q4mcEn6Txw+CK)eD{%$1X=#(=7uJpJzw2$$RerhPz+v~pv zd`;X>P6*CA{)EcBoxIpu$$VBMPMiadx+#6=+x3mU34gE{^qrX#m25#kT9W}1$T$nf zjq_nIj#9XZ;pbp&b=uMaS=cM7M0L@@0)N(K_D0FCl>40tR;FQxnS_fta+9NWpY*MN zC8rasoCKW<_$ciNn(N!f2(=&vX-Ns})|t{3h6h*D?g&V4-%tA0aGWY2z5wu5`JF#1 z5#N*i5a4%Uy_1I(Zjv_cLaNdVA#h$uqgF6dpdawX!#zN?cvLp@#GxJ}?5^3>g8Zz= z`$6uSJK8q`d!F|3^Vi>8<)rHP2*>+DmNSHM*J!X3F7st%7um2(NG0k$&9s+AO@R=P zcnwQ0rl_|Z0{qTS@od5LQn~*|dU=*EKIbDxaMe~fN2`Q&0sg!Vs2F(t;EwGp4sO?Za4?zf(4vc1&MWCj4l%{t-38iA z`GZX&t&2R^?>=*ZOVcxKiml3^QhxY&uAzznLR6pc&b$5a;$wHxsgVsZ^!0+W_%Y5|<3*PcUl71PbC+ilzA3g>azTs*=pIBg+xug|Nm5s4FXH(N8YO79*^G}jt-=f~a% z`>)o?0Ca4;gjW{$<7h+)0Hrw@&emhxu7j33+7f(#{ni%t3e>YHp)XQf}#-DMpn@X6zvbd07imG%`PBFDUtWDXT(BD2Eu#_>;!u^5)@tk0ilP z*hSosAn(0ve8Ajh%nLxFRkriW*W~cp)b5LaeQJ-HWnZ-f((Wj2k&Q1ua|}(p;b-f; zdQUbDXzzT6PpaL0;SPhCk;VB&z>0Xn796K<7DD7ona{~JDue1S?CQEZ1Zx@+)_Bw1 z(pQ*`Wj@5)qxq+y@%HCzO(>yW7tQie%HK`R{yJ#^71zQcS>gU0CsKnag807HbuO@N zk&>j{dLl1d0r(Z1@-(}gY>JpYQ9R{~`dsFmU!W*P^EXQEOtPz`&K?di)O&eSwTuL_ za31}D<)moma`XVJjxF5o*e!?S3DvU&%H`4Rl+iW4`zmgS@n>6J$!8ipmsnAGfU`Gq zAvjh2?+2=x7|XpE0J~WF-49h9u5>&HeGWx$ud<#2_gBu%^-s%`H86uf`~WLKTgOsw zijjH{sSeOj7dS_j7o`7l*d^sHj;GwPNy^Pd$8@N|Q?BWFg0>gyL~=&owfnWb7|#L; zTy&_<@dC&cUv`!LS@>u3#~#Nyo|`bG?Ooz*!t%{c_GKtg#v9EPCMnnzF5p8EGrjhF)V)ybe}gMp=)s={r-mB933SW7QT8i&SV~iL zjEoEnxrk-NVItX$5#iBxt&c_ zF{?%T0aVTH%~+E(sdn@NNLB+4fXiM0+2ZJ3U3$+7D=e$)#z6}%n5s65Q1Ly4uf+zlXYxSG_oa$MyaTqQ(pieU% z8?vQEDXr;=M8UJ=8B9)$?aRsTzC1ABUqE}u+1sO?1ArCP^HuJa20H|Q$J~~a`p6+a z>Qu(9`blp?&R>)spKf|JU&L{Auv*he|8b`vs%^FCkE~UG_wrwxoPLs8OsWI(gChI< zRil~T!&L&qQqzH%(raoRu}oFd}mxB~bWXOg-AcU}pgx1-L+mS)BlgI0CJmpi0@70c#9kfiq`o=aQlBKJblC z{L&5GY5^)lI|~ZOScbBKxh>=-KD&^p+a3YPyon$s+uFKc#XqX2DWV6WqSQ6KvH?=1 z9Mq$25+5Cj{|3Q+wtwlqRtqCjwV9{CJ_>Fk(=hS-kBZK}wU>tJ4HT2Ffc5Y&yN&hL2c9HrG)*?EcP*sEV3^}jZEZAUC;3ie| zTfu70-{UtXL~NcUzD$AapYp`um6EG?4_NVKe1+wnyWCUK>)6_P=Gz=xj>E*?Xk;`} PtK6)4ECn;3FDw58w-52{ diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index b8b187697..ddd5b49f0 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -46,7 +46,9 @@ public final class FilesStorageKit: FilesStorageProtocol { } public func isCachedInMemory(_ id: String) -> Bool { - cachedImages.object(forKey: id as NSString) != nil + guard !id.isEmpty else { return false } + + return cachedImages.object(forKey: id as NSString) != nil } public func isCachedLocally(_ id: String) -> Bool { From 38ed9c2de099be8eab50213dbf238815cd3285a8 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 13 Jun 2024 11:40:22 +0300 Subject: [PATCH 101/123] [trello.com/c/uxBZaznD] fix: scroll to last item --- .../Subviews/FilesToolBarView/FilesToolbarView.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift index 8d5646148..c52574de4 100644 --- a/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift +++ b/Adamant/Modules/Chat/View/Subviews/FilesToolBarView/FilesToolbarView.swift @@ -102,11 +102,13 @@ extension FilesToolbarView { func update(_ data: [FileResult]) { self.data = data collectionView.reloadData() - collectionView.scrollToItem( - at: .init(row: data.count - 1, section: .zero), - at: .right, - animated: true - ) + Task { + collectionView.scrollToItem( + at: .init(row: data.count - 1, section: .zero), + at: .right, + animated: true + ) + } } } From 82ab4eef3f08de0706f4c2709dd594481eb138df Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 13 Jun 2024 11:40:36 +0300 Subject: [PATCH 102/123] [trello.com/c/uxBZaznD] fix: paste image in input field --- .../Sources/FilesPickerKit/Helpers/FilesPickerKit.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index f44c9395e..9bec460e2 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -96,11 +96,12 @@ public final class FilesPickerKit: FilesPickerProtocol { } public func getFileResult(for image: UIImage) throws -> FileResult { - let fileName = "image.\(previewExtension)" + let fileName = "image\(String.random(length: 4)).\(previewExtension)" let newUrl = try storageKit.getTempUrl(for: image, name: fileName) let preview = getPreview(for: newUrl) let fileSize = try storageKit.getFileSize(from: newUrl) return FileResult( + assetId: newUrl.absoluteString, url: newUrl, type: .other, preview: preview.image, From 207d94d661922868044c653e0f7d2674970f3637 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 16 Jul 2024 16:58:21 +0300 Subject: [PATCH 103/123] [trello.com/c/uxBZaznD] fix: indicators logic & design --- Adamant.xcodeproj/project.pbxproj | 4 + Adamant/Helpers/EdgeInsetLabel.swift | 34 +++++ .../Modules/Chat/View/Helpers/ChatFile.swift | 6 +- .../View/Managers/ChatDisplayManager.swift | 5 + .../Container/ChatMediaContainerView.swift | 8 +- .../ChatFileContainerView/ChatFileView.swift | 43 +++---- .../MediaContainerView.swift | 49 ++++++- .../MediaContainerView/MediaContentView.swift | 46 +++---- .../Chat/ViewModel/ChatFileService.swift | 10 +- .../Chat/ViewModel/ChatMessageFactory.swift | 25 +++- .../Chat/ViewModel/ChatViewModel.swift | 120 ++++++++++++++++-- .../ServiceProtocols/ChatFileProtocol.swift | 4 +- .../Localization/en.lproj/Localizable.strings | 2 +- .../Sources/CommonKit/Models/FileResult.swift | 4 + .../FilesStorageKit/FilesStorageKit.swift | 2 +- 15 files changed, 277 insertions(+), 85 deletions(-) create mode 100644 Adamant/Helpers/EdgeInsetLabel.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index b192c7b22..b0aa8d2fd 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 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 */; }; + 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 */; }; @@ -715,6 +716,7 @@ 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 = ""; }; + 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 = ""; }; @@ -2256,6 +2258,7 @@ 936658942B0AC15300BDB2D3 /* Node+UI.swift */, 3AF53F8C2B3DCFA300B30312 /* NodeGroup+Constants.swift */, 3AA3880B2B69201B00125684 /* ADM+JsonDecode.swift */, + 3A5DF1782C4698EC0005369D /* EdgeInsetLabel.swift */, ); path = Helpers; sourceTree = ""; @@ -3573,6 +3576,7 @@ E93B0D762028B28E00126346 /* AdamantChatsProvider.swift in Sources */, 3A33F9FA2A7A53DA002B8003 /* EmojiUpdateType.swift in Sources */, 936658932B0AC03700BDB2D3 /* CoinsNodesListStrings.swift in Sources */, + 3A5DF1792C4698EC0005369D /* EdgeInsetLabel.swift in Sources */, E993302021354B1800CD5200 /* AdmWalletFactory.swift in Sources */, E9332B8921F1FA4400D56E72 /* OnboardFactory.swift in Sources */, 938F7D722955CE72001915CA /* ChatFactory.swift in Sources */, diff --git a/Adamant/Helpers/EdgeInsetLabel.swift b/Adamant/Helpers/EdgeInsetLabel.swift new file mode 100644 index 000000000..1be00b6a4 --- /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 + +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/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 47306ba83..922dcf280 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -22,7 +22,8 @@ struct ChatFile: Equatable, Hashable { var fileType: FileType var progress: Int var isPreviewDownloadAllowed: Bool - + var isFullMediaDownloadAllowed: Bool + var isBusy: Bool { return isDownloading || isUploading } @@ -38,6 +39,7 @@ struct ChatFile: Equatable, Hashable { isFromCurrentSender: false, fileType: .other, progress: .zero, - isPreviewDownloadAllowed: false + isPreviewDownloadAllowed: false, + isFullMediaDownloadAllowed: false ) } 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/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 6cda45b63..5e93b007e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -136,6 +136,12 @@ final class ChatMediaContainerView: UIView, ChatModelView { } @objc func onStatusButtonTap() { + if model.status == .failed, + let file = model.content.fileModel.files.first { + actionHandler(.openFile(messageId: model.id, file: file)) + return + } + guard model.status == .needToDownload else { return } let fileModel = model.content.fileModel @@ -188,7 +194,7 @@ extension ChatMediaContainerView { func updateStatus(_ status: FileMessageStatus) { statusButton.setImage(status.image, for: .normal) statusButton.tintColor = status.imageTintColor - statusButton.isHidden = status == .success || status == .failed + statusButton.isHidden = status == .success } func updateLayout() { diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 21c8ffaa1..655bc86d4 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -38,11 +38,6 @@ class ChatFileView: UIView { private let sizeLabel = UILabel(font: sizeFont, textColor: .lightGray) private let additionalLabel = UILabel(font: additionalFont, textColor: .adamant.cellColor) - private lazy var previewDownloadNotAllowedLabel = UILabel( - font: previewDownloadNotAllowedFont, - textColor: .adamant.textColor.withAlphaComponent(0.4) - ) - private lazy var vStack: UIStackView = { let stack = UIStackView() stack.alignment = .leading @@ -159,20 +154,11 @@ private extension ChatFileView { make.size.equalTo(imageSize / 2) } - addSubview(previewDownloadNotAllowedLabel) - previewDownloadNotAllowedLabel.snp.makeConstraints { make in - make.centerY.equalTo(iconImageView.snp.centerY) - make.horizontalEdges.equalTo(iconImageView).inset(5) - } - addSubview(tapBtn) tapBtn.snp.makeConstraints { make in make.directionalEdges.equalToSuperview() } - previewDownloadNotAllowedLabel.text = previewDownloadNotAllowedText - previewDownloadNotAllowedLabel.numberOfLines = .zero - previewDownloadNotAllowedLabel.textAlignment = .center nameLabel.lineBreakMode = .byTruncatingMiddle nameLabel.textAlignment = .left sizeLabel.textAlignment = .left @@ -185,7 +171,6 @@ private extension ChatFileView { videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) - previewDownloadNotAllowedLabel.addShadow() } func update() { @@ -193,17 +178,12 @@ private extension ChatFileView { if let previewImage = model.previewImage { image = previewImage additionalLabel.isHidden = true - previewDownloadNotAllowedLabel.isHidden = true } else { - image = model.fileType == .image || model.fileType == .video + image = model.fileType.isMedia ? defaultMediaImage : defaultImage - previewDownloadNotAllowedLabel.isHidden = model.isPreviewDownloadAllowed - || model.isBusy - || !(model.fileType == .image || model.fileType == .video) - - additionalLabel.isHidden = !previewDownloadNotAllowedLabel.isHidden + additionalLabel.isHidden = model.fileType.isMedia } if iconImageView.image != image { @@ -212,12 +192,25 @@ private extension ChatFileView { downloadImageView.isHidden = model.isCached || model.isBusy + if model.isDownloading { + if model.previewImage == nil || !model.isFullMediaDownloadAllowed { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } else { + spinner.stopAnimating() + } + if model.isBusy { - spinner.startAnimating() - progressState.hidden = false + if model.isUploading { + progressState.hidden = false + } else { + progressState.hidden = !model.isFullMediaDownloadAllowed + } + progressState.progress = Double(model.progress) / 100 } else { - spinner.stopAnimating() progressState.hidden = true progressState.progress = .zero } 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 index 0845b6ba9..8ad1d9d29 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -42,6 +42,11 @@ final class MediaContainerView: UIView { return stack }() + private lazy var previewDownloadNotAllowedLabel = EdgeInsetLabel( + font: previewDownloadNotAllowedFont, + textColor: .adamant.textColor.withAlphaComponent(0.4) + ) + // MARK: Proprieties var model: ChatMediaContentView.FileModel = .default { @@ -77,6 +82,21 @@ private extension MediaContainerView { $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() { @@ -87,6 +107,8 @@ private extension MediaContainerView { files: Array(fileList) )) + updatePreviewDownloadLabel(files: Array(fileList)) + for (index, stackView) in filesStack.arrangedSubviews.enumerated() { guard let horizontalStackView = stackView as? UIStackView else { continue } @@ -176,11 +198,31 @@ private extension MediaContainerView { } func calculateMinimumWidth(availableWidth: CGFloat) -> CGFloat { - return (availableWidth - stackSpacing) * 0.3 + (availableWidth - stackSpacing) * 0.3 } func calculateMaximumWidth(availableWidth: CGFloat) -> CGFloat { - return (availableWidth - stackSpacing) * 0.7 + (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 + } } } @@ -222,3 +264,6 @@ 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 index 5e52f28f7..582dfa0fe 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -30,11 +30,6 @@ final class MediaContentView: UIView { return btn }() - private lazy var previewDownloadNotAllowedLabel = UILabel( - font: previewDownloadNotAllowedFont, - textColor: .adamant.textColor.withAlphaComponent(0.4) - ) - private lazy var progressBar = CircularProgressView() private lazy var progressState: CircularProgressState = { .init( @@ -109,12 +104,6 @@ private extension MediaContentView { make.size.equalTo(imageSize / 1.6) } - addSubview(previewDownloadNotAllowedLabel) - previewDownloadNotAllowedLabel.snp.makeConstraints { make in - make.centerY.equalTo(imageView.snp.centerY) - make.horizontalEdges.equalTo(imageView).inset(5) - } - let controller = UIHostingController(rootView: progressBar.environmentObject(progressState)) controller.view.backgroundColor = .clear @@ -127,23 +116,15 @@ private extension MediaContentView { imageView.layer.masksToBounds = true imageView.contentMode = .scaleAspectFill videoIconIV.tintColor = .adamant.active - previewDownloadNotAllowedLabel.text = previewDownloadNotAllowedText - previewDownloadNotAllowedLabel.numberOfLines = .zero - previewDownloadNotAllowedLabel.textAlignment = .center videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) controller.view.addShadow() - previewDownloadNotAllowedLabel.addShadow() } func update() { - let image = model.previewImage - ?? (model.fileType == .image || model.fileType == .video - ? defaultMediaImage - : defaultImage - ) + let image = model.previewImage ?? defaultMediaImage if imageView.image != image { imageView.image = image @@ -157,19 +138,28 @@ private extension MediaContentView { && model.fileType == .video ) + if model.isDownloading { + if model.previewImage == nil || !model.isFullMediaDownloadAllowed { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } else { + spinner.stopAnimating() + } + if model.isBusy { - spinner.startAnimating() - progressState.hidden = false + if model.isUploading { + progressState.hidden = false + } else { + progressState.hidden = !model.isFullMediaDownloadAllowed + } + progressState.progress = Double(model.progress) / 100 } else { - spinner.stopAnimating() progressState.hidden = true progressState.progress = .zero } - - previewDownloadNotAllowedLabel.isHidden = model.isPreviewDownloadAllowed - || model.isBusy - || model.previewImage != nil } } @@ -178,5 +168,3 @@ 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: 8) -private var previewDownloadNotAllowedText: String { .localized("Chats.AutoDownloadPreview.Disabled") } diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index b05756649..f62e3a3c1 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -137,16 +137,18 @@ final class ChatFileService: ChatFileProtocol { func downloadFile( file: ChatFile, chatroom: Chatroom?, - saveEncrypted: Bool + saveEncrypted: Bool, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool ) async throws { let isCachedOriginal = filesStorage.isCachedLocally(file.file.id) - let isCachedPreview = filesStorage.isCachedInMemory(file.file.preview?.id ?? .empty) + let isCachedPreview = filesStorage.isCachedInMemory(file.file.preview?.id ?? .empty) try await downloadFile( file: file, chatroom: chatroom, - shouldDownloadOriginalFile: !isCachedOriginal, - shouldDownloadPreviewFile: !isCachedPreview, + shouldDownloadOriginalFile: !isCachedOriginal && fullMediaDownloadAllowed, + shouldDownloadPreviewFile: !isCachedPreview && previewDownloadAllowed, saveEncrypted: saveEncrypted ) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 94ce1cc2d..7d8320eca 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -336,7 +336,15 @@ private extension ChatMessageFactory { ? transaction.recipientAddress : transaction.senderAddress - let isPreviewDownloadAllowed = isPreviewDownloadAllowed(havePartnerName) + let isPreviewDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadPreviewPolicy(), + havePartnerName: havePartnerName + ) + + let isFullMediaDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadFullMediaPolicy(), + havePartnerName: havePartnerName + ) let chatFiles = makeChatFiles( from: files, @@ -344,7 +352,8 @@ private extension ChatMessageFactory { downloadingFilesIDs: downloadingFilesIDs, isFromCurrentSender: isFromCurrentSender, storage: storage, - isPreviewDownloadAllowed: isPreviewDownloadAllowed + isPreviewDownloadAllowed: isPreviewDownloadAllowed, + isFullMediaDownloadAllowed: isFullMediaDownloadAllowed ) let isMediaFilesOnly = chatFiles.allSatisfy { @@ -379,8 +388,10 @@ private extension ChatMessageFactory { ))) } - func isPreviewDownloadAllowed(_ havePartnerName: Bool) -> Bool { - let policy = filesStorageProprieties.autoDownloadPreviewPolicy() + func isDownloadAllowed( + policy: DownloadPolicy, + havePartnerName: Bool + ) -> Bool { switch policy { case .everybody: return true @@ -419,7 +430,8 @@ private extension ChatMessageFactory { downloadingFilesIDs: [String], isFromCurrentSender: Bool, storage: String, - isPreviewDownloadAllowed: Bool + isPreviewDownloadAllowed: Bool, + isFullMediaDownloadAllowed: Bool ) -> [ChatFile] { return files.map { let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] @@ -438,7 +450,8 @@ private extension ChatMessageFactory { isFromCurrentSender: isFromCurrentSender, fileType: FileType(raw: fileType) ?? .other, progress: .zero, - isPreviewDownloadAllowed: isPreviewDownloadAllowed + isPreviewDownloadAllowed: isPreviewDownloadAllowed, + isFullMediaDownloadAllowed: isFullMediaDownloadAllowed ) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index ba819039a..4f8564d01 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -754,7 +754,11 @@ final class ChatViewModel: NSObject { guard tx?.statusEnum == .delivered else { return } - downloadFile(file: file) + downloadFile( + file: file, + previewDownloadAllowed: true, + fullMediaDownloadAllowed: true + ) } func downloadPreviewIfNeeded( @@ -763,10 +767,7 @@ final class ChatViewModel: NSObject { ) { let tx = chatTransactions.first(where: { $0.txId == messageId }) - guard tx?.statusEnum == .delivered || tx?.statusEnum == nil, - (filesStorageProprieties.autoDownloadPreviewPolicy() != .nobody || - filesStorageProprieties.autoDownloadFullMediaPolicy() != .nobody) - else { return } + guard tx?.statusEnum == .delivered || tx?.statusEnum == nil else { return } let chatFiles = files.filter { $0.fileType == .image || $0.fileType == .video @@ -787,25 +788,104 @@ final class ChatViewModel: NSObject { } func forceDownloadAllFiles(messageId: String, files: [ChatFile]) { - let needToDownload = files.filter { - !$0.isCached || - ($0.isCached - && ($0.fileType == .image || $0.fileType == .video) - && $0.previewImage == nil) + 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 + if !file.isCached { + return true + } + + if file.fileType.isMedia && file.previewImage == nil { + return isPreviewDownloadAllowed + } + + return false + } + + 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) + downloadFile( + file: file, + previewDownloadAllowed: downloadPreview, + fullMediaDownloadAllowed: downloadFullMedia + ) } } - func downloadFile(file: ChatFile) { + func downloadFile( + file: ChatFile, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool + ) { Task { [weak self] in do { try await self?.chatFileService.downloadFile( file: file, chatroom: self?.chatroom, - saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true + saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true, + previewDownloadAllowed: previewDownloadAllowed, + fullMediaDownloadAllowed: fullMediaDownloadAllowed ) } catch { self?.dialog.send(.alert(error.localizedDescription)) @@ -1386,6 +1466,20 @@ private extension ChatViewModel { 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 { diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift index c58a857bc..a36d9ed09 100644 --- a/Adamant/ServiceProtocols/ChatFileProtocol.swift +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -41,7 +41,9 @@ protocol ChatFileProtocol { func downloadFile( file: ChatFile, chatroom: Chatroom?, - saveEncrypted: Bool + saveEncrypted: Bool, + previewDownloadAllowed: Bool, + fullMediaDownloadAllowed: Bool ) async throws func autoDownload( diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index bec9ced5c..4e7f885c8 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -767,7 +767,7 @@ "StorageUsage.Description" = "Total file and image size in the app's secure storage"; /* Chats: Auto preview download is disabled */ -"Chats.AutoDownloadPreview.Disabled" = "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"; diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index 41dd16902..571616763 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -11,6 +11,10 @@ public enum FileType { case image case video case other + + public var isMedia: Bool { + self == FileType.image || self == FileType.video + } } public extension FileType { diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index ddd5b49f0..e0a9482d8 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -301,7 +301,7 @@ private extension FilesStorageKit { } } - return fileURLs + return Array(Set(fileURLs)) } func cacheTemporaryFile( From 2396d8063edbc57379dc9d2104f97d71c9a472fd Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 16 Jul 2024 17:35:17 +0300 Subject: [PATCH 104/123] [trello.com/c/uxBZaznD] fix: present keyboard on start --- Adamant/Modules/Chat/View/ChatViewController.swift | 4 +++- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index f520ffe34..5deb9424d 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -857,7 +857,9 @@ private extension ChatViewController { at: self.messageInputBar.topStackView.arrangedSubviews.count ) }) - messageInputBar.inputTextView.becomeFirstResponder() + if viewAppeared { + messageInputBar.inputTextView.becomeFirstResponder() + } } filesToolbarView.update(data) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 9134e1416..730563d60 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -230,7 +230,11 @@ final class ChatViewModel: NSObject { } func presentKeyboardOnStartIfNeeded() { - guard !inputText.isEmpty || replyMessage != nil else { return } + guard !inputText.isEmpty + || replyMessage != nil + || (filesPicked?.count ?? .zero) > .zero + else { return } + presentKeyboard.send() } From 7acc1bb1e39dc2a45602092156e498cbb5e13500 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 17 Jul 2024 12:49:49 +0300 Subject: [PATCH 105/123] [trello.com/c/uxBZaznD] fix: open not uploaded file --- Adamant.xcodeproj/project.pbxproj | 40 +++++------ .../Chat/ViewModel/ChatFileService.swift | 72 ++++++++++--------- .../DataProviders/ChatsProvider.swift | 13 ++-- .../DataProviders/AdamantChatsProvider.swift | 51 +++++++++++-- 4 files changed, 109 insertions(+), 67 deletions(-) diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 0887c9c6a..fc04218a3 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -13,6 +13,10 @@ 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 */; }; @@ -28,10 +32,6 @@ 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 */; }; - 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 */; }; 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 */; }; @@ -696,6 +696,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 = ""; }; @@ -711,10 +715,6 @@ 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 = ""; }; - 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 = ""; }; 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 = ""; }; @@ -1389,6 +1389,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 = ( @@ -1419,16 +1429,6 @@ path = WalletService; sourceTree = ""; }; - 3A2478AF2BB45DE2009D89E9 /* StorageUsage */ = { - isa = PBXGroup; - children = ( - 3A2478B42BB46617009D89E9 /* StorageUsageFactory.swift */, - 3A2478B02BB45DF8009D89E9 /* StorageUsageView.swift */, - 3A2478B22BB461A7009D89E9 /* StorageUsageViewModel.swift */, - ); - path = StorageUsage; - sourceTree = ""; - }; 3A299C672B838A7800B54C61 /* ChatMedia */ = { isa = PBXGroup; children = ( @@ -3984,7 +3984,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 = ""; @@ -4015,7 +4015,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 = ""; diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index f62e3a3c1..803ccc4f7 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -24,8 +24,7 @@ private struct FileUpload { private struct FileMessage { var files: [FileUpload] var message: String? - var tx: RichMessageTransaction? - var context: NSManagedObjectContext? + var txId: String? } final class ChatFileService: ChatFileProtocol { @@ -687,18 +686,15 @@ private extension ChatFileService { cachePreviewFiles(files) - let txLocaly = try await sendMessageLocallyIfNeeded( + let txId = try await sendMessageLocallyIfNeeded( fileMessage: fileMessage, partnerAddress: partnerAddress, chatroom: chatroom, messageLocally: messageLocally ) - fileMessage.tx = txLocaly.tx - fileMessage.context = txLocaly.context + fileMessage.txId = txId - let txId = txLocaly.tx.txId - let needToLoadFiles = richFiles.filter { $0.nonce.isEmpty } updateUploadingFilesIDs(with: needToLoadFiles.map { $0.id }, uploading: true) @@ -716,7 +712,8 @@ private extension ChatFileService { partnerAddress: partnerAddress, saveEncrypted: saveEncrypted, txId: txId, - richFiles: &richFiles + richFiles: &richFiles, + messageLocally: messageLocally ) let message = createAdamantMessage( @@ -729,8 +726,7 @@ private extension ChatFileService { _ = try await chatsProvider.sendFileMessage( message, recipientId: partnerAddress, - transactionLocaly: txLocaly.tx, - context: txLocaly.context, + transactionLocalyId: txId, from: chatroom ) @@ -740,8 +736,7 @@ private extension ChatFileService { } catch { await handleUploadError( for: needToLoadFiles, - tx: txLocaly.tx, - context: txLocaly.context + txId: txId ) throw error @@ -813,31 +808,26 @@ private extension ChatFileService { partnerAddress: String, chatroom: Chatroom?, messageLocally: AdamantMessage - ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) { - let tx: RichMessageTransaction - let context: NSManagedObjectContext - - if let transaction = fileMessage.tx, - let txContext = fileMessage.context { - tx = transaction - context = txContext + ) async throws -> String { + let txId: String + + if let transactionId = fileMessage.txId { + txId = transactionId try? await chatsProvider.setTxMessageStatus( - transactionLocaly: tx, - context: context, + txId: txId, status: .pending ) } else { - let txLocally = try await chatsProvider.sendFileMessageLocally( + let txLocallyId = try await chatsProvider.sendFileMessageLocally( messageLocally, recipientId: partnerAddress, from: chatroom ) - tx = txLocally.tx - context = txLocally.context + txId = txLocallyId } - return (tx, context) + return txId } func updateUploadingFilesIDs(with ids: [String], uploading: Bool) { @@ -862,7 +852,8 @@ private extension ChatFileService { partnerAddress: String, saveEncrypted: Bool, txId: String, - richFiles: inout [RichMessageFile.File] + richFiles: inout [RichMessageFile.File], + messageLocally: AdamantMessage ) async throws { let files = fileMessage.files @@ -893,14 +884,15 @@ private extension ChatFileService { saveEncrypted: saveEncrypted ) - updateRichFile( + await updateRichFile( oldId: file.url.absoluteString, fileResult: result.file, previewResult: result.preview, fileMessage: &fileMessage, richFiles: &richFiles, file: file, - txId: txId + txId: txId, + messageLocally: messageLocally ) } } @@ -950,8 +942,9 @@ private extension ChatFileService { fileMessage: inout FileMessage, richFiles: inout [RichMessageFile.File], file: FileResult, - txId: String - ) { + txId: String, + messageLocally: AdamantMessage + ) async { let cached = filesStorage.isCachedLocally(fileResult.cid) $uploadingFilesIDsArray.mutate { $0.removeAll { $0 == oldId } } @@ -996,18 +989,27 @@ private extension ChatFileService { $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], - tx: RichMessageTransaction, - context: NSManagedObjectContext + txId: String ) async { updateUploadingFilesIDs(with: richFiles.map { $0.id }, uploading: false) try? await chatsProvider.setTxMessageStatus( - transactionLocaly: tx, - context: context, + txId: txId, status: .failed ) } diff --git a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift index 1b237d9e5..b45672bba 100644 --- a/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift +++ b/Adamant/ServiceProtocols/DataProviders/ChatsProvider.swift @@ -221,19 +221,22 @@ protocol ChatsProvider: DataProvider, Actor { _ message: AdamantMessage, recipientId: String, from chatroom: Chatroom? - ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) + ) async throws -> String func sendFileMessage( _ message: AdamantMessage, recipientId: String, - transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext, + transactionLocalyId: String, from chatroom: Chatroom? ) async throws -> ChatTransaction + func updateTxMessageContent( + txId: String, + richMessage: RichMessage + ) throws + func setTxMessageStatus( - transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext, + txId: String, status: MessageStatus ) throws diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 160af572b..c88d848be 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -869,7 +869,7 @@ extension AdamantChatsProvider { _ message: AdamantMessage, recipientId: String, from chatroom: Chatroom? - ) async throws -> (tx: RichMessageTransaction, context: NSManagedObjectContext) { + ) async throws -> String { guard let loggedAccount = accountService.account, let keypair = accountService.keypair else { throw ChatsProviderError.notLogged } @@ -906,14 +906,13 @@ extension AdamantChatsProvider { from: chatroom ) - return (transactionLocaly, context) + return transactionLocaly.transactionId } func sendFileMessage( _ message: AdamantMessage, recipientId: String, - transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext, + transactionLocalyId: String, from chatroom: Chatroom? ) async throws -> ChatTransaction { guard let loggedAccount = accountService.account, let keypair = accountService.keypair else { @@ -933,6 +932,17 @@ extension AdamantChatsProvider { 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) } @@ -954,11 +964,38 @@ extension AdamantChatsProvider { } func setTxMessageStatus( - transactionLocaly: RichMessageTransaction, - context: NSManagedObjectContext, + txId: String, status: MessageStatus ) throws { - transactionLocaly.statusEnum = status + 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() } From 9da435809b381547d28eba2471c49c59555af9f4 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 22 Jul 2024 15:04:17 +0300 Subject: [PATCH 106/123] [trello.com/c/uxBZaznD] fix: UI logic (Indicators, design) --- .../Modules/Chat/View/Helpers/ChatFile.swift | 24 ++- .../Chat/View/Helpers/FileMessageStatus.swift | 10 +- .../ChatMediaContainerView+Model.swift | 7 +- .../Container/ChatMediaContainerView.swift | 4 +- .../Content/ChatMediaContnentView+Model.swift | 16 +- .../Content/ChatMediaContnentView.swift | 13 ++ .../ChatFileContainerView/ChatFileView.swift | 54 +++--- .../FileContainerView.swift | 5 +- .../MediaContainerView.swift | 9 +- .../MediaContainerView/MediaContentView.swift | 48 +++--- .../Chat/ViewModel/ChatFileService.swift | 154 +++++++++++++----- .../Chat/ViewModel/ChatMessageFactory.swift | 37 +++-- .../ViewModel/ChatMessagesListFactory.swift | 16 +- .../Chat/ViewModel/ChatViewModel.swift | 55 +++---- .../ServiceProtocols/ChatFileProtocol.swift | 5 +- .../Localization/en.lproj/Localizable.strings | 4 +- .../Contents.json | 23 +++ .../download-circular-error.png | Bin 0 -> 978 bytes .../download-circular-error@2x.png | Bin 0 -> 2118 bytes .../download-circular-error@3x.png | Bin 0 -> 3411 bytes 20 files changed, 325 insertions(+), 159 deletions(-) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/Contents.json create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error@3x.png diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 922dcf280..d89aae48d 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -10,28 +10,44 @@ import Foundation import CommonKit import UIKit +struct DownloadStatus: Equatable, 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 isDownloading: Bool + 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 progress: Int? var isPreviewDownloadAllowed: Bool var isFullMediaDownloadAllowed: Bool var isBusy: Bool { - return isDownloading || isUploading + isDownloading + || isUploading + } + + var isDownloading: Bool { + downloadStatus.isOriginalDownloading + || downloadStatus.isPreviewDownloading } static let `default` = Self( file: .init([:]), previewImage: nil, - isDownloading: false, + downloadStatus: .default, isUploading: false, isCached: false, storage: .empty, diff --git a/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift index e85941af9..3ef357631 100644 --- a/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift +++ b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift @@ -10,9 +10,9 @@ import Foundation import UIKit import CommonKit -enum FileMessageStatus { +enum FileMessageStatus: Equatable { case busy - case needToDownload + case needToDownload(failed: Bool) case failed case success @@ -21,7 +21,11 @@ enum FileMessageStatus { 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 .needToDownload: return .asset(named: "download-circular") ?? .init() + case let .needToDownload(failed): + guard !failed else { + return .asset(named: "download-circular-error") ?? .init() + } + return .asset(named: "download-circular") ?? .init() } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index 6c07ebedc..2ccd9ae48 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -35,7 +35,12 @@ extension ChatMediaContainerView { && $0.previewImage == nil && ($0.fileType == .image || $0.fileType == .video)) }) { - return .needToDownload + let failed = content.fileModel.files.contains(where: { + guard let progress = $0.progress else { return false } + return progress < 100 + }) + + return .needToDownload(failed: failed) } return .success diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 5e93b007e..31aa3ec09 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -142,7 +142,9 @@ final class ChatMediaContainerView: UIView, ChatModelView { return } - guard model.status == .needToDownload else { return } + guard case .needToDownload = model.status else { + return + } let fileModel = model.content.fileModel let fileList = Array(fileModel.files.prefix(FilesConstants.maxFilesCount)) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift index aef97170d..e6e2ae264 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView+Model.swift @@ -39,12 +39,24 @@ extension ChatMediaContentView { var files: [ChatFile] var isMediaFilesOnly: Bool let isFromCurrentSender: Bool - + let txStatus: MessageStatus + static let `default` = Self( messageId: .empty, files: [], isMediaFilesOnly: false, - isFromCurrentSender: 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 index 9cf8f20b8..9d68d8032 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -115,6 +115,8 @@ final class ChatMediaContentView: UIView { return stack }() + private lazy var uploadImageView = UIImageView(image: .asset(named: "downloadIcon")) + private lazy var mediaContainerView = MediaContainerView() private lazy var fileContainerView = FileContainerView() @@ -174,6 +176,14 @@ private extension ChatMediaContentView { 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) } @@ -183,6 +193,8 @@ private extension ChatMediaContentView { 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 @@ -285,3 +297,4 @@ 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/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 655bc86d4..685c0c414 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -74,23 +74,22 @@ class ChatFileView: UIView { private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, - backgroundColor: .white, - progressColor: .lightGray, + backgroundColor: .lightGray, + progressColor: .white, progress: .zero, hidden: true ) }() - var model: ChatFile = .default { + var model: ChatMediaContentView.FileContentModel = .default { didSet { - guard oldValue != model else { return } update() } } var buttonActionHandler: (() -> Void)? - init(model: ChatFile) { + init(model: ChatMediaContentView.FileContentModel) { super.init(frame: .zero) backgroundColor = .clear configure() @@ -174,26 +173,32 @@ private extension ChatFileView { } func update() { + let chatFile = model.chatFile + let image: UIImage? - if let previewImage = model.previewImage { + if let previewImage = chatFile.previewImage { image = previewImage additionalLabel.isHidden = true } else { - image = model.fileType.isMedia + image = chatFile.fileType.isMedia ? defaultMediaImage : defaultImage - additionalLabel.isHidden = model.fileType.isMedia + additionalLabel.isHidden = chatFile.fileType.isMedia } if iconImageView.image != image { iconImageView.image = image } - downloadImageView.isHidden = model.isCached || model.isBusy + downloadImageView.isHidden = chatFile.isCached + || chatFile.isBusy + || model.txStatus == .failed - if model.isDownloading { - if model.previewImage == nil || !model.isFullMediaDownloadAllowed { + if chatFile.isDownloading { + if chatFile.previewImage == nil, + chatFile.file.preview != nil, + chatFile.downloadStatus.isPreviewDownloading { spinner.startAnimating() } else { spinner.stopAnimating() @@ -202,33 +207,34 @@ private extension ChatFileView { spinner.stopAnimating() } - if model.isBusy { - if model.isUploading { + if chatFile.isBusy { + if chatFile.isUploading { progressState.hidden = false } else { - progressState.hidden = !model.isFullMediaDownloadAllowed + progressState.hidden = !chatFile.downloadStatus.isOriginalDownloading } - - progressState.progress = Double(model.progress) / 100 } else { - progressState.hidden = true - progressState.progress = .zero + progressState.hidden = chatFile.progress == 100 + || chatFile.progress == nil } - let fileType = model.file.type.map { ".\($0)" } ?? .empty - let fileName = model.file.name ?? "UNKNWON" + let progress = chatFile.progress ?? .zero + progressState.progress = Double(progress) / 100 + + let fileType = chatFile.file.type.map { ".\($0)" } ?? .empty + let fileName = chatFile.file.name ?? "UNKNWON" nameLabel.text = fileName.contains(fileType) ? fileName : "\(fileName.uppercased())\(fileType.uppercased())" - sizeLabel.text = formatSize(model.file.size) + sizeLabel.text = formatSize(chatFile.file.size) additionalLabel.text = fileType.uppercased() videoIconIV.isHidden = !( - model.isCached - && !model.isBusy - && model.fileType == .video + chatFile.isCached + && !chatFile.isBusy + && chatFile.fileType == .video ) } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift index 31d7083b0..56c4cced6 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift @@ -69,7 +69,10 @@ private extension FileContainerView { for (index, file) in fileList.enumerated() { let view = filesStack.arrangedSubviews[index] as? ChatFileView view?.isHidden = false - view?.model = file + view?.model = .init( + chatFile: file, + txStatus: model.txStatus + ) view?.buttonActionHandler = { [weak self, file, model] in self?.actionHandler( .openFile( 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 index 8ad1d9d29..53b12e628 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -122,7 +122,10 @@ private extension MediaContainerView { if fileOverallIndex < fileList.count { let file = fileList[fileOverallIndex] mediaView.isHidden = false - mediaView.model = file + mediaView.model = .init( + chatFile: file, + txStatus: model.txStatus + ) mediaView.buttonActionHandler = { [weak self, file, model] in self?.actionHandler( .openFile( @@ -166,7 +169,7 @@ private extension MediaContainerView { var totalWidthForEqualAspectRatio: CGFloat = 0.0 for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { - if let resolution = mediaView.model.file.resolution { + if let resolution = mediaView.model.chatFile.file.resolution { let aspectRatio = resolution.width / resolution.height let widthForEqualAspectRatio = height * aspectRatio totalWidthForEqualAspectRatio += widthForEqualAspectRatio @@ -178,7 +181,7 @@ private extension MediaContainerView { let scaleFactor = filesStackWidth / totalWidthForEqualAspectRatio for case let mediaView as MediaContentView in horizontalStackView.arrangedSubviews { - if let resolution = mediaView.model.file.resolution { + if let resolution = mediaView.model.chatFile.file.resolution { let aspectRatio = resolution.width / resolution.height let widthForEqualAspectRatio = height * aspectRatio var width = max(widthForEqualAspectRatio * scaleFactor, minimumWidth) 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 index 582dfa0fe..72940ea10 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -34,14 +34,14 @@ final class MediaContentView: UIView { private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, - backgroundColor: .white, - progressColor: .lightGray, + backgroundColor: .lightGray, + progressColor: .white, progress: .zero, hidden: true ) }() - var model: ChatFile = .default { + var model: ChatMediaContentView.FileContentModel = .default { didSet { update() } @@ -49,7 +49,7 @@ final class MediaContentView: UIView { var buttonActionHandler: (() -> Void)? - init(model: ChatFile) { + init(model: ChatMediaContentView.FileContentModel) { super.init(frame: .zero) backgroundColor = .clear configure() @@ -110,7 +110,7 @@ private extension MediaContentView { addSubview(controller.view) controller.view.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(15) - make.size.equalTo(15) + make.size.equalTo(progressSize) } imageView.layer.masksToBounds = true @@ -124,22 +124,28 @@ private extension MediaContentView { } func update() { - let image = model.previewImage ?? defaultMediaImage - + let chatFile = model.chatFile + + let image = chatFile.previewImage ?? defaultMediaImage + if imageView.image != image { imageView.image = image } - downloadImageView.isHidden = model.isCached || model.isBusy + downloadImageView.isHidden = chatFile.isCached + || chatFile.isBusy + || model.txStatus == .failed videoIconIV.isHidden = !( - model.isCached - && !model.isBusy - && model.fileType == .video + chatFile.isCached + && !chatFile.isBusy + && chatFile.fileType == .video ) - if model.isDownloading { - if model.previewImage == nil || !model.isFullMediaDownloadAllowed { + if chatFile.isDownloading { + if chatFile.previewImage == nil, + chatFile.file.preview != nil, + chatFile.downloadStatus.isPreviewDownloading { spinner.startAnimating() } else { spinner.stopAnimating() @@ -148,22 +154,24 @@ private extension MediaContentView { spinner.stopAnimating() } - if model.isBusy { - if model.isUploading { + if chatFile.isBusy { + if chatFile.isUploading { progressState.hidden = false } else { - progressState.hidden = !model.isFullMediaDownloadAllowed + progressState.hidden = !chatFile.downloadStatus.isOriginalDownloading } - - progressState.progress = Double(model.progress) / 100 } else { - progressState.hidden = true - progressState.progress = .zero + progressState.hidden = chatFile.progress == 100 + || chatFile.progress == nil } + + let progress = chatFile.progress ?? .zero + progressState.progress = Double(progress) / 100 } } 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") diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 803ccc4f7..4629b6936 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -38,13 +38,14 @@ final class ChatFileService: ChatFileProtocol { private let filesNetworkManager: FilesNetworkManagerProtocol private let adamantCore: AdamantCore - @Atomic private var downloadingFilesIDsArray: [String] = [] + @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] = [:] + private var subscriptions = Set() private let maxDownloadAttemptsCount = 3 @@ -52,10 +53,14 @@ final class ChatFileService: ChatFileProtocol { $uploadingFilesIDsArray.wrappedValue } - var downloadingFiles: [String] { + var downloadingFiles: [String: DownloadStatus] { $downloadingFilesIDsArray.wrappedValue } + var filesLoadingProgress: [String: Int] { + $fileProgressValue.wrappedValue + } + let updateFileFields = ObservableSender<( id: String, newId: String?, @@ -63,7 +68,7 @@ final class ChatFileService: ChatFileProtocol { preview: UIImage?, needUpdatePreview: Bool, cached: Bool?, - downloading: Bool?, + downloadStatus: DownloadStatus?, uploading: Bool?, progress: Int? )>() @@ -160,7 +165,7 @@ final class ChatFileService: ChatFileProtocol { fullMediaDownloadPolicy: DownloadPolicy, saveEncrypted: Bool ) async { - guard !downloadingFiles.contains(file.file.id), + guard !downloadingFiles.keys.contains(file.file.id), !$ignoreFilesIDsArray.wrappedValue.contains(file.file.id), !$busyFilesIDs.wrappedValue.contains(file.file.id) else { @@ -223,6 +228,16 @@ private extension ChatFileService { 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) } } @@ -326,7 +341,7 @@ private extension ChatFileService { preview: image, needUpdatePreview: true, cached: nil, - downloading: nil, + downloadStatus: nil, uploading: nil, progress: nil )) @@ -365,7 +380,7 @@ private extension ChatFileService { let recipientId = chatroom?.partner?.address, NetworkFileProtocolType(rawValue: file.storage) != nil, (shouldDownloadOriginalFile || shouldDownloadPreviewFile), - !downloadingFiles.contains(file.file.id) + !downloadingFiles.keys.contains(file.file.id) else { return } guard !file.file.id.isEmpty, @@ -375,20 +390,19 @@ private extension ChatFileService { } defer { - $downloadingFilesIDsArray.mutate { - $0.removeAll(where: { $0 == file.file.id }) + $downloadingFilesIDsArray.mutate { + $0[file.file.id] = nil } - sendUpdate(for: [file.file.id], downloading: false, uploading: nil) + sendUpdate( + for: [file.file.id], + downloadStatus: .init( + isPreviewDownloading: false, + isOriginalDownloading: false + ), + uploading: nil + ) } - - $downloadingFilesIDsArray.mutate { $0.append(file.file.id) } - sendUpdate( - for: [file.file.id], - downloading: true, - uploading: nil, - progress: .zero - ) - + let downloadFile = shouldDownloadOriginalFile && !filesStorage.isCachedLocally(file.file.id) @@ -396,20 +410,33 @@ private extension ChatFileService { && 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) - 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 - } + let (previewWeight, fileWeight) = getProgressWeights( + downloadPreview: false, + downloadFile: downloadFile + ) let previewProgress = Progress(totalUnitCount: previewWeight) totalProgress.addChild(previewProgress, withPendingUnitCount: previewWeight) @@ -431,13 +458,8 @@ private extension ChatFileService { fileType: .image, fileExtension: previewDTO.extension ?? .empty, isPreview: true, - downloadProgress: { [weak self] value in + downloadProgress: { value in previewProgress.completedUnitCount = Int64(value.fractionCompleted * Double(previewWeight)) - - self?.sendProgress( - for: file.file.id, - progress: Int(totalProgress.fractionCompleted * 100) - ) } ) @@ -450,7 +472,7 @@ private extension ChatFileService { preview: preview, needUpdatePreview: true, cached: nil, - downloading: nil, + downloadStatus: nil, uploading: nil, progress: nil )) @@ -491,13 +513,34 @@ private extension ChatFileService { preview: nil, needUpdatePreview: false, cached: cached, - downloading: nil, + downloadStatus: nil, uploading: nil, progress: 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, @@ -623,7 +666,7 @@ private extension ChatFileService { private extension ChatFileService { func sendUpdate( for files: [String], - downloading: Bool?, + downloadStatus: DownloadStatus?, uploading: Bool?, progress: Int? = nil ) { @@ -635,14 +678,22 @@ private extension ChatFileService { preview: nil, needUpdatePreview: false, cached: nil, - downloading: downloading, + downloadStatus: downloadStatus, uploading: uploading, progress: progress )) + + if progress != nil { + $fileProgressValue.mutate { + $0[id] = progress + } + } } } func sendProgress(for fileId: String, progress: Int) { + guard $fileProgressValue.wrappedValue[fileId] != progress else { return } + updateFileFields.send(( id: fileId, newId: nil, @@ -650,10 +701,14 @@ private extension ChatFileService { preview: nil, needUpdatePreview: false, cached: nil, - downloading: nil, + downloadStatus: nil, uploading: nil, progress: progress )) + + $fileProgressValue.mutate { + $0[fileId] = progress + } } } @@ -840,7 +895,13 @@ private extension ChatFileService { } } } - sendUpdate(for: ids, downloading: nil, uploading: uploading) + + sendUpdate( + for: ids, + downloadStatus: nil, + uploading: uploading, + progress: uploading ? .zero : nil + ) } func processFilesUpload( @@ -875,6 +936,11 @@ private extension ChatFileService { progress: uploadProgress ) + sendProgress( + for: result.file.cid, + progress: 100 + ) + try cacheUploadedFile( fileResult: result.file, previewResult: result.preview, @@ -956,7 +1022,7 @@ private extension ChatFileService { preview: filesStorage.getPreview(for: previewResult?.cid ?? .empty), needUpdatePreview: true, cached: cached, - downloading: nil, + downloadStatus: nil, uploading: false, progress: nil )) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index ba5481074..02135fe7b 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -85,8 +85,9 @@ struct ChatMessageFactory { dateHeaderOn: Bool, topSpinnerOn: Bool, uploadingFilesIDs: [String], - downloadingFilesIDs: [String], - havePartnerName: Bool + downloadingFilesIDs: [String: DownloadStatus], + havePartnerName: Bool, + filesLoadingProgress: [String: Int] ) -> ChatMessage { let sentDate = transaction.sentDate ?? .now let senderModel = ChatSender(transaction: transaction) @@ -113,7 +114,8 @@ struct ChatMessageFactory { backgroundColor: backgroundColor, uploadingFilesIDs: uploadingFilesIDs, downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName + havePartnerName: havePartnerName, + filesLoadingProgress: filesLoadingProgress ), backgroundColor: backgroundColor, bottomString: makeBottomString( @@ -135,8 +137,9 @@ private extension ChatMessageFactory { isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, uploadingFilesIDs: [String], - downloadingFilesIDs: [String], - havePartnerName: Bool + downloadingFilesIDs: [String: DownloadStatus], + havePartnerName: Bool, + filesLoadingProgress: [String: Int] ) -> ChatMessage.Content { switch transaction { case let transaction as MessageTransaction: @@ -165,7 +168,8 @@ private extension ChatMessageFactory { backgroundColor: backgroundColor, uploadingFilesIDs: uploadingFilesIDs, downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName + havePartnerName: havePartnerName, + filesLoadingProgress: filesLoadingProgress ) } @@ -313,8 +317,9 @@ private extension ChatMessageFactory { isFromCurrentSender: Bool, backgroundColor: ChatMessageBackgroundColor, uploadingFilesIDs: [String], - downloadingFilesIDs: [String], - havePartnerName: Bool + downloadingFilesIDs: [String: DownloadStatus], + havePartnerName: Bool, + filesLoadingProgress: [String: Int] ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" @@ -354,7 +359,8 @@ private extension ChatMessageFactory { isFromCurrentSender: isFromCurrentSender, storage: storage, isPreviewDownloadAllowed: isPreviewDownloadAllowed, - isFullMediaDownloadAllowed: isFullMediaDownloadAllowed + isFullMediaDownloadAllowed: isFullMediaDownloadAllowed, + filesLoadingProgress: filesLoadingProgress ) let isMediaFilesOnly = chatFiles.allSatisfy { @@ -365,7 +371,8 @@ private extension ChatMessageFactory { messageId: id, files: chatFiles, isMediaFilesOnly: isMediaFilesOnly, - isFromCurrentSender: isFromCurrentSender + isFromCurrentSender: isFromCurrentSender, + txStatus: transaction.statusEnum ) return .file(.init(value: .init( @@ -428,29 +435,31 @@ private extension ChatMessageFactory { func makeChatFiles( from files: [[String: Any]], uploadingFilesIDs: [String], - downloadingFilesIDs: [String], + downloadingFilesIDs: [String: DownloadStatus], isFromCurrentSender: Bool, storage: String, isPreviewDownloadAllowed: Bool, - isFullMediaDownloadAllowed: Bool + isFullMediaDownloadAllowed: Bool, + filesLoadingProgress: [String: Int] ) -> [ChatFile] { return files.map { let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] let preview = RichMessageFile.Preview(previewData) let fileType = $0[RichContentKeys.file.type] as? String ?? .empty let fileId = $0[RichContentKeys.file.id] as? String ?? .empty + let progress = filesLoadingProgress[fileId] return ChatFile( file: RichMessageFile.File($0), previewImage: filesStorage.getPreview(for: preview.id), - isDownloading: downloadingFilesIDs.contains(fileId), + downloadStatus: downloadingFilesIDs[fileId] ?? .default, isUploading: uploadingFilesIDs.contains(fileId), isCached: filesStorage.isCachedLocally(fileId), storage: storage, nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, isFromCurrentSender: isFromCurrentSender, fileType: FileType(raw: fileType) ?? .other, - progress: .zero, + progress: progress, isPreviewDownloadAllowed: isPreviewDownloadAllowed, isFullMediaDownloadAllowed: isFullMediaDownloadAllowed ) diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift index 14cfe9084..8b4f012f2 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift @@ -24,8 +24,9 @@ actor ChatMessagesListFactory { isNeedToLoadMoreMessages: Bool, expirationTimestamp minExpTimestamp: inout TimeInterval?, uploadingFilesIDs: [String], - downloadingFilesIDs: [String], - havePartnerName: Bool + downloadingFilesIDs: [String: DownloadStatus], + havePartnerName: Bool, + filesLoadingProgress: [String: Int] ) -> [ChatMessage] { assert(!Thread.isMainThread, "Do not process messages on main thread") @@ -50,7 +51,8 @@ actor ChatMessagesListFactory { willExpireAfter: &expTimestamp, uploadingFilesIDs: uploadingFilesIDs, downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName + havePartnerName: havePartnerName, + filesLoadingProgress: filesLoadingProgress ) if let timestamp = expTimestamp, timestamp < minExpTimestamp ?? .greatestFiniteMagnitude { @@ -70,8 +72,9 @@ private extension ChatMessagesListFactory { topSpinnerOn: Bool, willExpireAfter: inout TimeInterval?, uploadingFilesIDs: [String], - downloadingFilesIDs: [String], - havePartnerName: Bool + downloadingFilesIDs: [String: DownloadStatus], + havePartnerName: Bool, + filesLoadingProgress: [String: Int] ) -> ChatMessage { var expireDate: Date? let message = chatMessageFactory.makeMessage( @@ -82,7 +85,8 @@ private extension ChatMessagesListFactory { topSpinnerOn: topSpinnerOn, uploadingFilesIDs: uploadingFilesIDs, downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName + havePartnerName: havePartnerName, + filesLoadingProgress: filesLoadingProgress ) willExpireAfter = expireDate?.timeIntervalSince1970 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 730563d60..8f084b7d9 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -499,17 +499,13 @@ final class ChatViewModel: NSObject { let message = messages.first(where: { $0.messageId == id }) if case let .file(model) = message?.content { - do { - try await chatFileService.resendMessage( - with: id, - text: model.value.content.comment.string, - chatroom: chatroom, - replyMessage: nil, - saveEncrypted: filesStorageProprieties.saveFileEncrypted() - ) - } catch { - dialog.send(.error(error.localizedDescription, supportEmail: false)) - } + try? await chatFileService.resendMessage( + with: id, + text: model.value.content.comment.string, + chatroom: chatroom, + replyMessage: nil, + saveEncrypted: filesStorageProprieties.saveFileEncrypted() + ) return } @@ -741,7 +737,7 @@ final class ChatViewModel: NSObject { return } - guard !chatFileService.downloadingFiles.contains(file.file.id), + guard !chatFileService.downloadingFiles.keys.contains(file.file.id), !chatFileService.uploadingFiles.contains(file.file.id), case let(.file(fileModel)) = message?.content else { return } @@ -883,17 +879,13 @@ final class ChatViewModel: NSObject { fullMediaDownloadAllowed: Bool ) { Task { [weak self] in - do { - try await self?.chatFileService.downloadFile( - file: file, - chatroom: self?.chatroom, - saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true, - previewDownloadAllowed: previewDownloadAllowed, - fullMediaDownloadAllowed: fullMediaDownloadAllowed - ) - } catch { - self?.dialog.send(.alert(error.localizedDescription)) - } + try? await self?.chatFileService.downloadFile( + file: file, + chatroom: self?.chatroom, + saveEncrypted: self?.filesStorageProprieties.saveFileEncrypted() ?? true, + previewDownloadAllowed: previewDownloadAllowed, + fullMediaDownloadAllowed: fullMediaDownloadAllowed + ) } } @@ -1051,7 +1043,7 @@ private extension ChatViewModel { needToUpdatePreview: data.needUpdatePreview, cached: data.cached, isUploading: data.uploading, - isDownloading: data.downloading, + downloadStatus: data.downloadStatus, progress: data.progress ) } @@ -1181,7 +1173,8 @@ private extension ChatViewModel { expirationTimestamp: &expirationTimestamp, uploadingFilesIDs: chatFileService.uploadingFiles, downloadingFilesIDs: chatFileService.downloadingFiles, - havePartnerName: havePartnerName + havePartnerName: havePartnerName, + filesLoadingProgress: chatFileService.filesLoadingProgress ) await setupNewMessages( @@ -1258,8 +1251,6 @@ private extension ChatViewModel { case .accountNotFound, .accountNotInitiated, .dependencyError, .internalError, .networkError, .notLogged, .requestCancelled, .serverError, .transactionNotFound, .invalidTransactionStatus, .none: break } - - dialog.send(.richError(error)) } func inputTextUpdated() { @@ -1400,7 +1391,7 @@ private extension ChatViewModel { needToUpdatePreview: Bool, cached: Bool? = nil, isUploading: Bool? = nil, - isDownloading: Bool? = nil, + downloadStatus: DownloadStatus? = nil, progress: Int? = nil ) { let indexes = messages.indices.filter { @@ -1420,7 +1411,7 @@ private extension ChatViewModel { needToUpdatePeview: needToUpdatePreview, cached: cached, isUploading: isUploading, - isDownloading: isDownloading, + downloadStatus: downloadStatus, progress: progress ) } @@ -1536,7 +1527,7 @@ private extension ChatMessage { needToUpdatePeview: Bool, cached: Bool? = nil, isUploading: Bool? = nil, - isDownloading: Bool? = nil, + downloadStatus: DownloadStatus? = nil, progress: Int? = nil ) { guard case let .file(fileModel) = content else { return } @@ -1558,8 +1549,8 @@ private extension ChatMessage { if let value = isUploading { model.content.fileModel.files[index].isUploading = value } - if let value = isDownloading { - model.content.fileModel.files[index].isDownloading = value + if let value = downloadStatus { + model.content.fileModel.files[index].downloadStatus = value } if needToUpdatePeview { model.content.fileModel.files[index].previewImage = preview diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift index a36d9ed09..368f32213 100644 --- a/Adamant/ServiceProtocols/ChatFileProtocol.swift +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -13,8 +13,9 @@ import UIKit import FilesStorageKit protocol ChatFileProtocol { - var downloadingFiles: [String] { get } + var downloadingFiles: [String: DownloadStatus] { get } var uploadingFiles: [String] { get } + var filesLoadingProgress: [String: Int] { get } var updateFileFields: PassthroughSubject<( id: String, @@ -23,7 +24,7 @@ protocol ChatFileProtocol { preview: UIImage?, needUpdatePreview: Bool, cached: Bool?, - downloading: Bool?, + downloadStatus: DownloadStatus?, uploading: Bool?, progress: Int? ), Never> { diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 12673f882..76b14824c 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -725,10 +725,10 @@ "NodesEditor.FailedToBuildURL" = "Invalid host"; /* Storage: Save Encrypted */ -"Storage.SaveEncrypted.Title" = "Keep the files encrypted"; +"Storage.SaveEncrypted.Title" = "Store files encrypted"; /* Storage: Save Encrypted */ -"Storage.SaveEncrypted.Description" = "Store the files in encrypted form in local storage (May impact performance)"; +"Storage.SaveEncrypted.Description" = "Store files in the local storage encrypted (May impact performance)"; /* Storage: Auto download preview */ "Storage.AutoDownloadPreview.Title" = "Preview"; 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..602883aeb --- /dev/null +++ b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "download-circular-error.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "download-circular-error@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "download-circular-error@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.png b/CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error.png new file mode 100644 index 0000000000000000000000000000000000000000..9a8e982e2f36b027b5aacaf74985af159a996701 GIT binary patch literal 978 zcmV;@11F zA5gh@;^W{4@OJ&p+kNmT?R&3){*r0m?oQ`7v)!GUrzio|^M&-T*4GT_2dq?%i7w*1 zQ`WY%_j6n8RnO>Z=c%)cUFG6uO#hCvsp1Hk^oSuUV3?)^(8jDmzr-pv3nyySD)DB1 zSO_Qf301|ASs~&I2DQMbIESw+k(X7(14Jh4Ow^!1*;RgBN=n?enICzH9~Jfq6$}(B zlmKufr^t-Mft)2!#q;Iq=Xlgn#Qfv_leeci^$5=!&f_8-0qgmp0!ZNirT=K}-GzlW zgLvL;NQ_9QT%_10?njhWItCCuw%^Pn`izTw^bj$W1{LX9@+0M2$0a>Tu9xwcMo^={ z4l1_@kIb@o7G8{j{2dfgJHB${EN4`S~~& zHy}%pD~%R|{4SM^DIQlL_aI*(*CX_9LcTz5L%Jyjj54+*50XTh=zJnur8*#zfqEL~ z$lQ|Ck!$mzks(*f{2I=h%$l3eP;*R%$SZZ^J#MZeq}L2tORQJfGxmt%kmo2G_2hzd zh;)LYNl9w9|A}senNlBRfLz*3@tAbd5h4yqW3wJO{0u2A61Opb!}Ep|ZYkmvkBK^x z`f)=z+gT)$?>OCMmTQC-XWDusokJo_K>8tflaL;?txEFgt`kc|a#0TzC>hF-#UzT- zzqwYZ2CmW`v5nfe>Y>J^c+&|%Y`Lb9Y%ArGNT47!(`|Vg9mD)dknguX(tXf$xZ^yK zxw^_TV^EMPm63L(g>63s*CB&-l)ArjUIj_1>30{tG%_6&Wus0!)!(z z7#MU0GETK>YqnoLt3gq5sWyW*j$SyJ3kWHuJ&t>Y$w)sYt�g62V@H<`lNA?8XlBWDKdCPSVJ0Ikt`?)DT*+_U+z|eu*1RBYXATM07*qoM6N<$g18I9 A%m4rY literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5bb1a0b99d5b39c5c388e270b896f558a6f2bca2 GIT binary patch literal 2118 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 pi#AR01wJU Date: Fri, 26 Jul 2024 17:20:24 +0300 Subject: [PATCH 107/123] [trello.com/c/uxBZaznD] fix: error download status in dark theme --- .../Contents.json | 33 ++++++++++++++++++ .../download-circular-error-dark.png | Bin 0 -> 1020 bytes .../download-circular-error-dark@2x.png | Bin 0 -> 2161 bytes .../download-circular-error-dark@3x.png | Bin 0 -> 3530 bytes 4 files changed, 33 insertions(+) create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@2x.png create mode 100644 CommonKit/Sources/CommonKit/Assets/Shared.xcassets/files/download-circular-error.imageset/download-circular-error-dark@3x.png 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 index 602883aeb..aaf58dd66 100644 --- 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 @@ -5,15 +5,48 @@ "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" : { 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 0000000000000000000000000000000000000000..da7e2978010dc9f473e18dcd9501b4ad64271bf4 GIT binary patch literal 1020 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..95d55d42f3f51a67a0985d484c5547c96b3bdecf GIT binary patch literal 3530 zcmV;*4K?zKP)21c?NthvE;3JUONaCiSUB)saS7A%O z0m0x3ks9PsD1<|=3J`Au0S-o<-Ss*Wp8}uY3(={~E!SrIlGkg=lQ-hqD0l!73Vy;k zKf)GPK3)Fk);}WY8h8lLi0-Rvi1Gky6xnP&ctoxeoPEHm7Pe}i_5Hr`@SYfW0N08B z^uZ^q!*3mJfT8q{A(JVE83xq;10opQ9{pYG``{v6BRaXMr3wa8cZp>G1|fX5U>o3N zrH^O(2V2~-@PZSKH#nBOVRO9zXojgtL>!ydfj6<^c=?_9K4vDENCL?b9%{_*!J~E& z1GXGnaS=@AyE*?*Fy2&o@S!K5z$hruXvIrKSNDFA$z18EqE4*#96;c3p)3l21R@)W z7i*CCS|@e!#`!Xfgufqs@`3lDKqwf|iIoivXwFS&(Kan$3}V<-MBhSS9s#ispN^8cnxi;GU!PxqJQ&e z$OSlnZHu<^YhwdZD)u(REI?i+w&e*wKK$Kmg2&1XtK6!^J*^pnwImf+#Lm_nGILPyUh;4zGDuJ&(``@7>f zO#c!}NYBI!sYM6MO17#=CSCT0Oe=5Sp~ljB|1AKKn1Y&2yf>aQm{u z(#+wZP3C;&D8@B3xTyR(P(V}uf^!!%ks-~dgRao{=y( zkz4h|sl8F6lee~916gsltP)zY(rq}`A=KPNlea!Fst})BbZ_5kd3o01)X6=UU}|-T zEKloCT%UWb6HTi+Syq$wCWP53ga^us&X-qX^VaMba-HTC6<^_*?u3+RbOpW%^Kb-XP4{GI@qsUM zF*v+TQElF$9fM0bW5waqc_6Ytu}CoxgLybbOn#bO%Eiec)dCP#7l+SM#H44_=@O2v zxrL4@O#|3-sK#_PrVGI*;CC;>bPc96z#}lutGGyyNEa*?dbIQC?QLLU?XJPPCcnD=1%1$YE(!y|a0DW;%# ziX^iY&wH}>(W|MTvp_yQ--s9bOK=hR`9VxHbAGENn+)J=@Bm&B{Sl@fMcyW|HaBF1 zNXEncm{e=$x-q%BV>SoQ!W4>OGR!Sw1py9|2f(;k)iyG+wg;i`AWhz)$fHg7_hQ?I zpg@>7A)5nn%!Q`)fCs<}dI}F4K~45;Xn{MS05Ii(^k20lkJ`|PZTb}y7BwJmP%l~Z zprcCD0AARBJka_yRS1VWk)XEe$cQNyEugk!BKa0<%U&ovQOQtnk5Uko_2@I}~|2lJ|i-sF9r%{izt&hSHzP>o6OeG^Yld+7OL;UO(@CH(AX5sKF#IZ3l)Wlz< zg@9iV9>FA={2xwYW2p9wxZS&uG(<>O;cB4$i;g>=KQ?e7%&!4jGRQB7kz3O@;IX0bP(l*zUK$TT3H^es%#w_|8yMDnh?81 zARr9|j8?rHe1a(>G{1LZ%<0cCt?{yVF%f9Z${lPJ_jK(0N&V2ojkoQ<0gN;>f*HQ2 z1AGFG0%^zWA}|c32T2|TpJ48wmCnvkywi$9d)AIUH)$#>aPfJMApI(>whr#(GLYC2 zOdn(V&`*-}w&;OknoUD3=>V5AAJZ!JsOTUJduL7vdL;UWQyUSSa|CRXAiN(G+2pRi3tV3R320a4^Mw`dW&3xm%NJwX){-z*y=VuzqLf=L zs3Eh_vdNOINJhkv%9;Gbx8JcRT=E|?Ve4VZD=xB1x<|6NZD=xZ%5H~ zlbf2Cip1b+R^g11@I9IlFZz_+F`z#$276R0XAyxHg}TCvqhBrFyX}8q6OO|g>#?JY zg0IZku*OB

R$0H%oRpiy`fJ*)D~Y$ew-r)x4;v%c7X>a5M=M^E3+v$N7@#t33@ppM!(=_~ZlrUmm6~+6W;66!zFEBJcPw)Cq6%E2@oY!JF+6OhDgq8(VVv zeX{6`PEe2s-smx=*WGisNT%+DtUPF}RV zK#_X!gSVq~pE-r<6wRd*>vy8W&}cMfoP*fJzWErxI33BOxLoM3bz-OVKs0g>>z>O3 zJho{pTp^l3Bv3F}tv|I9W;hs5-3gpkq6v@bYO(S=z-3TSS(ZsR;Sk=|&C*kZf+>;J z9tvH!{Lx7B%_qP`xK4BqWZ}WZmDgpv`?jE@c(RGo0&!ZyLdz?>aus++bPg1nlcGk7 z-k#N5uv#rWvIM+A9KuD<@I{p`pgcSe9>a5@$3lNxHO1TuX)8JKOGG+-ITQ%`E(Tg) zFW@gC82VeqZ|{HA+*s-hd?k7!XmfsY!}bd(&J%?oVkd`}v9bl{WKRyAhLZ~mCq`nR z9jUY-m4(BD=oNnhhBIAIQFd&&d*J0o&+R%olODkSe{ScZG0S%*>Hq)$07*qoM6N<$ Eg7}Q31poj5 literal 0 HcmV?d00001 From 054c09834819cf39e72744475b02b1e9592be7b5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Jul 2024 17:21:31 +0300 Subject: [PATCH 108/123] [trello.com/c/uxBZaznD] fix: short file presentation --- Adamant/Helpers/Markdown+Adamant.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Adamant/Helpers/Markdown+Adamant.swift b/Adamant/Helpers/Markdown+Adamant.swift index e8038aabf..d827a2244 100644 --- a/Adamant/Helpers/Markdown+Adamant.swift +++ b/Adamant/Helpers/Markdown+Adamant.swift @@ -242,7 +242,7 @@ final class MarkdownFileRaw: MarkdownElement { } var regex: String { - return "\(emoji)\\d?" + return "\(emoji)\\d{0,2}" } func regularExpression() throws -> NSRegularExpression { From a6b1580a06973efd09d5406c45308b8bf7c4c69c Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Jul 2024 17:22:39 +0300 Subject: [PATCH 109/123] [trello.com/c/uxBZaznD] fix: inc video preview quality --- .../Sources/CommonKit/Helpers/FilesConstants.swift | 1 + .../FilesPickerKit/Helpers/FilesPickerKit.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift index db6d8dc98..42c74b911 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilesConstants.swift @@ -11,6 +11,7 @@ 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/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index 9bec460e2..469795681 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -37,7 +37,10 @@ public final class FilesPickerKit: FilesPickerProtocol { } public func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage { - let newSize = getPreviewSize(from: image.size) + let newSize = getPreviewSize( + from: image.size, + previewSize: FilesConstants.previewSize + ) return image.imageResized(to: newSize) } @@ -60,12 +63,15 @@ public final class FilesPickerKit: FilesPickerProtocol { var thumbnailSize: CGSize? if let size = originalSize { - thumbnailSize = getPreviewSize(from: size) + thumbnailSize = getPreviewSize( + from: size, + previewSize: FilesConstants.previewVideoSize + ) } let request = QLThumbnailGenerator.Request( fileAt: url, - size: thumbnailSize ?? FilesConstants.previewSize, + size: thumbnailSize ?? FilesConstants.previewVideoSize, scale: 1.0, representationTypes: .thumbnail ) From 5777f8170ab15f2d18a0daa244bcd3c7f7fb247a Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 26 Jul 2024 17:25:02 +0300 Subject: [PATCH 110/123] [trello.com/c/uxBZaznD] feat: video duration, new fields (mime, extension), inc timeout interval for resource --- .../ChatFileContainerView/ChatFileView.swift | 2 +- .../MediaContainerView/MediaContentView.swift | 28 +++++ .../Chat/ViewModel/ChatFileService.swift | 8 +- .../Chat/ViewModel/ChatMessageFactory.swift | 21 ++-- .../Chat/ViewModel/ChatViewModel.swift | 2 +- Adamant/Services/APICore.swift | 34 +++--- .../Sources/CommonKit/Models/FileResult.swift | 8 +- .../CommonKit/Models/RichMessage.swift | 35 ++++-- .../Helpers/FilesPickerKit.swift | 106 +++++++++++------- .../Pickers/MediaPickerService.swift | 12 +- .../Protocols/FilesPickerProtocol.swift | 4 +- 11 files changed, 179 insertions(+), 81 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index 685c0c414..e296d67a9 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -221,7 +221,7 @@ private extension ChatFileView { let progress = chatFile.progress ?? .zero progressState.progress = Double(progress) / 100 - let fileType = chatFile.file.type.map { ".\($0)" } ?? .empty + let fileType = chatFile.file.extension.map { ".\($0)" } ?? .empty let fileName = chatFile.file.name ?? "UNKNWON" nameLabel.text = fileName.contains(fileType) 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 index 72940ea10..031f0c9f0 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -41,6 +41,8 @@ final class MediaContentView: UIView { ) }() + private let durationLabel = UILabel(font: durationFont, textColor: .white) + var model: ChatMediaContentView.FileContentModel = .default { didSet { update() @@ -93,6 +95,11 @@ private extension MediaContentView { 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() @@ -120,6 +127,7 @@ private extension MediaContentView { videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) + durationLabel.addShadow() controller.view.addShadow() } @@ -167,6 +175,25 @@ private extension MediaContentView { 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) + } } } @@ -176,3 +203,4 @@ 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: 13) diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 4629b6936..98e515138 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -492,7 +492,7 @@ private extension ChatFileService { recipientId: recipientId, saveEncrypted: saveEncrypted, fileType: file.fileType, - fileExtension: file.file.type ?? .empty, + fileExtension: file.file.extension ?? .empty, isPreview: false, downloadProgress: { [weak self] value in fileProgress.completedUnitCount = Int64(value.fractionCompleted * Double(fileWeight)) @@ -805,7 +805,8 @@ private extension ChatFileService { size: $0.file.size, nonce: $0.fileNonce ?? .empty, name: $0.file.name, - type: $0.file.extenstion, + extension: $0.file.extenstion, + mimeType: $0.file.mimeType, preview: $0.preview ?? $0.file.previewUrl.map { RichMessageFile.Preview( id: $0.absoluteString, @@ -813,7 +814,8 @@ private extension ChatFileService { extension: .empty ) }, - resolution: $0.file.resolution + resolution: $0.file.resolution, + duration: $0.file.duration ) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 02135fe7b..85c1e1536 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -444,21 +444,28 @@ private extension ChatMessageFactory { ) -> [ChatFile] { return files.map { let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] - let preview = RichMessageFile.Preview(previewData) - let fileType = $0[RichContentKeys.file.type] as? String ?? .empty - let fileId = $0[RichContentKeys.file.id] as? String ?? .empty + let file = RichMessageFile.File($0) + let fileId = file.id + let fileType = FileType(raw: file.extension ?? .empty) ?? .other + + let previewImage = (file.preview?.id).flatMap { + !$0.isEmpty + ? filesStorage.getPreview(for: $0) + : nil + } + let progress = filesLoadingProgress[fileId] return ChatFile( - file: RichMessageFile.File($0), - previewImage: filesStorage.getPreview(for: preview.id), + file: file, + previewImage: previewImage, downloadStatus: downloadingFilesIDs[fileId] ?? .default, isUploading: uploadingFilesIDs.contains(fileId), isCached: filesStorage.isCachedLocally(fileId), storage: storage, - nonce: $0[RichContentKeys.file.nonce] as? String ?? .empty, + nonce: file.nonce, isFromCurrentSender: isFromCurrentSender, - fileType: FileType(raw: fileType) ?? .other, + fileType: fileType, progress: progress, isPreviewDownloadAllowed: isPreviewDownloadAllowed, isFullMediaDownloadAllowed: isFullMediaDownloadAllowed diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8f084b7d9..8a3b9a10e 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -1451,7 +1451,7 @@ private extension ChatViewModel { previewExtension: nil, size: file.file.size, name: file.file.name, - extenstion: file.file.type, + extenstion: file.file.extension, resolution: nil, data: data ) diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index 2f76b87f4..22300a8e9 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -19,8 +19,8 @@ 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 return Alamofire.Session.init(configuration: configuration) }() @@ -32,7 +32,7 @@ actor APICore: APICoreProtocol { uploadProgress: @escaping ((Progress) -> Void) ) async -> APIResponseModel { do { - let request = AF.upload(multipartFormData: { multipartFormData in + let request = session.upload(multipartFormData: { multipartFormData in models.forEach { file in multipartFormData.append( file.data, @@ -113,22 +113,19 @@ private extension APICore { func sendRequest(request: DataRequest) async -> APIResponseModel { await withCheckedContinuation { continuation in request.responseData(queue: responseQueue) { response in + let code = response.response?.statusCode + let result: ApiServiceResult + + if let code = code, code == 502 || code == 504 { + result = .failure(.serverError(error: "unknown")) + } else { + result = response.result.mapError { .init(error: $0) } + } + continuation.resume(returning: .init( - result: response.result.mapError { .init(error: $0) }, - data: response.data, - code: response.response?.statusCode - )) - } - } - } - - func sendRequest(request: UploadRequest) async -> APIResponseModel { - await withCheckedContinuation { continuation in - request.responseData(queue: responseQueue) { response in - continuation.resume(returning: .init( - result: response.result.mapError { .init(error: $0) }, + result: result, data: response.data, - code: response.response?.statusCode + code: code )) } } @@ -141,4 +138,5 @@ private extension APICore { } } -private let timeoutInterval: TimeInterval = 15 +private let timeoutIntervalForRequest: TimeInterval = 15 +private let timeoutIntervalForResource: TimeInterval = 24 * 3600 diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index 571616763..d2f0f96af 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -41,6 +41,8 @@ public struct FileResult { 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, @@ -53,7 +55,9 @@ public struct FileResult { name: String?, extenstion: String?, resolution: CGSize?, - data: Data? = nil + data: Data? = nil, + duration: Float64? = nil, + mimeType: String? = nil ) { self.assetId = assetId self.url = url @@ -66,5 +70,7 @@ public struct FileResult { self.preview = preview self.resolution = resolution self.data = data + self.duration = duration + self.mimeType = mimeType } } diff --git a/CommonKit/Sources/CommonKit/Models/RichMessage.swift b/CommonKit/Sources/CommonKit/Models/RichMessage.swift index e177b00af..b69e5e62b 100644 --- a/CommonKit/Sources/CommonKit/Models/RichMessage.swift +++ b/CommonKit/Sources/CommonKit/Models/RichMessage.swift @@ -60,6 +60,8 @@ public enum RichContentKeys { 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" } } @@ -136,37 +138,46 @@ public struct RichMessageFile: RichMessage { public struct File: Codable, Equatable, Hashable { public var preview: Preview? public var id: String - public var type: 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?, - type: String? = nil, + `extension`: String? = nil, + mimeType: String? = nil, preview: Preview? = nil, - resolution: CGSize? = nil + resolution: CGSize? = nil, + duration: Float64? = nil ) { self.id = id - self.type = type + 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.type = data[RichContentKeys.file.type] as? String + 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) } @@ -190,8 +201,8 @@ public struct RichMessageFile: RichMessage { RichContentKeys.file.nonce: nonce ] - if let type = type, !type.isEmpty { - contentDict[RichContentKeys.file.type] = type + if let value = `extension`, !value.isEmpty { + contentDict[RichContentKeys.file.extension] = value } if let preview = preview { @@ -206,6 +217,14 @@ public struct RichMessageFile: RichMessage { 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 } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index 469795681..6b66f7661 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -84,39 +84,22 @@ public final class FilesPickerKit: FilesPickerProtocol { } public func getFileResult(for url: URL) throws -> FileResult { - let newUrl = try storageKit.copyFileToTempCache(from: url) - let preview = getPreview(for: newUrl) - let fileSize = try storageKit.getFileSize(from: newUrl) - return FileResult( - assetId: url.absoluteString, - url: newUrl, - type: .other, - preview: preview.image, - previewUrl: preview.url, - previewExtension: previewExtension, - size: fileSize, + try createFileResult( + from: url, name: url.lastPathComponent, - extenstion: url.pathExtension, - resolution: preview.resolution + extension: url.pathExtension ) } - + public func getFileResult(for image: UIImage) throws -> FileResult { - let fileName = "image\(String.random(length: 4)).\(previewExtension)" + let fileName = "\(imagePrefix)\(String.random(length: 4)).\(previewExtension)" + let newUrl = try storageKit.getTempUrl(for: image, name: fileName) - let preview = getPreview(for: newUrl) - let fileSize = try storageKit.getFileSize(from: newUrl) - return FileResult( - assetId: newUrl.absoluteString, - url: newUrl, - type: .other, - preview: preview.image, - previewUrl: preview.url, - previewExtension: previewExtension, - size: fileSize, + + return try createFileResult( + from: newUrl, name: fileName, - extenstion: previewExtension, - resolution: preview.resolution + extension: previewExtension ) } @@ -174,17 +157,69 @@ public final class FilesPickerKit: FilesPickerProtocol { } } } + + 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 getPreviewSize(from originalSize: CGSize?) -> CGSize { + 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) + 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 = FilesConstants.previewSize.width / width - let heightRatio = FilesConstants.previewSize.height / height + let widthRatio = previewSize.width / width + let heightRatio = previewSize.height / height var newSize: CGSize if(widthRatio > heightRatio) { @@ -203,14 +238,7 @@ private extension FilesPickerKit { } func isFileType(format: UTType, atURL fileURL: URL) -> Bool { - var mimeType: String? - - let pathExtension = fileURL.pathExtension - if let type = UTType(filenameExtension: pathExtension) { - mimeType = type.preferredMIMEType - } - - guard let mimeType = mimeType else { return false } + guard let mimeType = getMimeType(for: fileURL) else { return false } return UTType(mimeType: mimeType)?.conforms(to: format) ?? false } @@ -262,3 +290,5 @@ private extension FilesPickerKit { } } } + +private let imagePrefix = "image" diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift index a8954943e..73f30ab1d 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/MediaPickerService.swift @@ -68,7 +68,8 @@ private extension MediaPickerService { for: resizedPreview, name: FilesConstants.previewTag + url.lastPathComponent ) - + let mimeType = helper.getMimeType(for: url) + dataArray.append( .init( assetId: result.assetIdentifier, @@ -80,7 +81,8 @@ private extension MediaPickerService { size: fileSize, name: itemProvider.suggestedName, extenstion: url.pathExtension, - resolution: preview.size + resolution: preview.size, + mimeType: mimeType ) ) } else if isConforms(to: .movie, itemProvider.registeredTypeIdentifiers) { @@ -91,6 +93,8 @@ private extension MediaPickerService { 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, @@ -113,7 +117,9 @@ private extension MediaPickerService { size: fileSize, name: itemProvider.suggestedName, extenstion: url.pathExtension, - resolution: originalSize + resolution: originalSize, + duration: duration, + mimeType: mimeType ) ) } else { diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift index 63e96f08a..06ca65156 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilesPickerProtocol.swift @@ -40,5 +40,7 @@ public protocol FilesPickerProtocol { itemProvider: NSItemProvider ) async throws -> URL - func getFileResult(for image: UIImage) throws -> FileResult + func getFileResult(for image: UIImage) throws -> FileResult + func getVideoDuration(from url: URL) -> Float64? + func getMimeType(for url: URL) -> String? } From 7151dd546ed98f4324400315e4dfe424909f8537 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 30 Jul 2024 12:05:53 +0300 Subject: [PATCH 111/123] [trello.com/c/uxBZaznD] fix: short description for files in chat list --- .../CommonKit/Helpers/FilePresentationHelper.swift | 4 ++-- CommonKit/Sources/CommonKit/Models/FileResult.swift | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift index 2460529b0..1d7f56bd7 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -37,8 +37,8 @@ public class FilePresentationHelper { let files = content[RichContentKeys.file.files] as? [[String: Any]] ?? [] let mediaFilesCount = files.filter { file in - let fileTypeRaw = file[RichContentKeys.file.type] as? String ?? .empty - let fileType = FileType(raw: fileTypeRaw) ?? .other + let mimeType = file[RichContentKeys.file.mimeType] as? String ?? .empty + let fileType = FileType(mimeType: mimeType) ?? .other return fileType == .image || fileType == .video }.count diff --git a/CommonKit/Sources/CommonKit/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index d2f0f96af..c039bd07f 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -18,6 +18,16 @@ public enum FileType { } 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": From 0eadcdf90a6f56ee1b2f390ec3bf16369c168d0f Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 31 Jul 2024 17:48:07 +0300 Subject: [PATCH 112/123] [trello.com/c/uxBZaznD] fix: send files with "stress" situation & hide download icon --- .../ChatFileContainerView/ChatFileView.swift | 1 + .../MediaContainerView/MediaContentView.swift | 1 + .../Chat/ViewModel/ChatFileService.swift | 12 +++++++++- .../Chat/ViewModel/ChatViewModel.swift | 24 ++++++++++++++++--- .../StorageUsage/StorageUsageViewModel.swift | 20 ++++++++++++++-- .../ServiceProtocols/ChatFileProtocol.swift | 2 ++ .../FilesNetworkManager/IPFS+Constants.swift | 15 +++++++++--- .../Localization/AdamantLocalized.swift | 11 +++++++++ 8 files changed, 77 insertions(+), 9 deletions(-) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift index e296d67a9..ed8515b8f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/ChatFileView.swift @@ -194,6 +194,7 @@ private extension ChatFileView { downloadImageView.isHidden = chatFile.isCached || chatFile.isBusy || model.txStatus == .failed + || chatFile.previewImage == nil if chatFile.isDownloading { if chatFile.previewImage == nil, 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 index 031f0c9f0..1bb9f42a1 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -143,6 +143,7 @@ private extension MediaContentView { downloadImageView.isHidden = chatFile.isCached || chatFile.isBusy || model.txStatus == .failed + || chatFile.previewImage == nil videoIconIV.isHidden = !( chatFile.isCached diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 98e515138..99065c379 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -45,9 +45,11 @@ final class ChatFileService: ChatFileProtocol { @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 @@ -214,6 +216,14 @@ final class ChatFileService: ChatFileProtocol { 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 { @@ -1118,7 +1128,7 @@ private extension ChatFileService { var preview: UploadResult? if let url = file.previewUrl { - preview = try? await uploadFile( + preview = try await uploadFile( url: url, recipientPublicKey: recipientPublicKey, senderPrivateKey: senderPrivateKey, diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 8a3b9a10e..38ea2dafe 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -319,7 +319,11 @@ final class ChatViewModel: NSObject { saveEncrypted: filesStorageProprieties.saveFileEncrypted() ) } catch { - await handleMessageSendingError(error: error, sentText: text) + await handleMessageSendingError( + error: error, + sentText: text, + filesPicked: filesPicked + ) } } return @@ -742,11 +746,20 @@ final class ChatViewModel: NSObject { case let(.file(fileModel)) = message?.content else { return } + let chatFiles = fileModel.value.content.fileModel.files + + if filesStorageProprieties.autoDownloadPreviewPolicy() == .nobody, + 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 { Task { - let chatFiles = fileModel.value.content.fileModel.files self.presentFileInFullScreen(id: file.file.id, chatFiles: chatFiles) } return @@ -1238,12 +1251,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 diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 10cdb3e37..96e2ed796 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -151,10 +151,26 @@ private extension StorageUsageViewModel { } 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 = [.useGB, .useMB, .useKB] + formatter.allowedUnits = .useAll formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) } } diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift index 368f32213..eef3cf819 100644 --- a/Adamant/ServiceProtocols/ChatFileProtocol.swift +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -69,4 +69,6 @@ protocol ChatFileProtocol { replyMessage: MessageModel?, saveEncrypted: Bool ) async throws + + func isDownloadPreviewLimitReached(for fileId: String) -> Bool } diff --git a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift index f04297300..bda6da969 100644 --- a/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift +++ b/Adamant/Services/FilesNetworkManager/IPFS+Constants.swift @@ -24,9 +24,18 @@ extension IPFSApiService { static var nodes: [Node] { [ - Node(url: URL(string: "https://ipfs1test.adamant.im")!), - Node(url: URL(string: "https://ipfs2test.adamant.im")!), - Node(url: URL(string: "https://ipfs3test.adamant.im")!) + 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")! + ) ] } 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, From ed414e91a60a0ea699dbff366ba938fde7c7523f Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 7 Aug 2024 16:55:33 +0300 Subject: [PATCH 113/123] [trello.com/c/uxBZaznD] feat: duration design --- .../MediaContainerView/MediaContentView.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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 index 1bb9f42a1..3a56b40a5 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -41,7 +41,10 @@ final class MediaContentView: UIView { ) }() - private let durationLabel = UILabel(font: durationFont, textColor: .white) + private lazy var durationLabel = EdgeInsetLabel( + font: durationFont, + textColor: .white.withAlphaComponent(0.8) + ) var model: ChatMediaContentView.FileContentModel = .default { didSet { @@ -127,8 +130,15 @@ private extension MediaContentView { videoIconIV.addShadow() downloadImageView.addShadow() spinner.addShadow(shadowColor: .white) - durationLabel.addShadow() 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() { @@ -178,6 +188,7 @@ private extension MediaContentView { progressState.progress = Double(progress) / 100 durationLabel.isHidden = chatFile.fileType != .video + if let duration = chatFile.file.duration { durationLabel.text = formatTime(seconds: Int(duration)) } else { @@ -204,4 +215,5 @@ 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: 13) +private let durationFont = UIFont.systemFont(ofSize: 10) +private let durationTextInsets: UIEdgeInsets = .init(top: 3, left: 3, bottom: 3, right: 3) From acb02d94f1d23073a33eff8fd31093498ec53d13 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 7 Aug 2024 16:56:50 +0300 Subject: [PATCH 114/123] [trello.com/c/uxBZaznD] feat: improved file message setup --- Adamant/Modules/Chat/ChatFactory.swift | 4 +- .../Chat/ViewModel/ChatMessageFactory.swift | 101 +++--------------- .../ViewModel/ChatMessagesListFactory.swift | 24 +---- .../Chat/ViewModel/ChatViewModel.swift | 85 +++++++++++++-- 4 files changed, 96 insertions(+), 118 deletions(-) diff --git a/Adamant/Modules/Chat/ChatFactory.swift b/Adamant/Modules/Chat/ChatFactory.swift index 95edd0596..0ab315a81 100644 --- a/Adamant/Modules/Chat/ChatFactory.swift +++ b/Adamant/Modules/Chat/ChatFactory.swift @@ -112,9 +112,7 @@ private extension ChatFactory { markdownParser: .init(font: UIFont.systemFont(ofSize: UIFont.systemFontSize)), transfersProvider: transferProvider, chatMessagesListFactory: .init(chatMessageFactory: .init( - walletServiceCompose: walletServiceCompose, - filesStorage: filesStorage, - filesStorageProprieties: filesStorageProprieties + walletServiceCompose: walletServiceCompose )), addressBookService: addressBookService, visibleWalletService: visibleWalletService, diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 85c1e1536..61d75d311 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -14,8 +14,6 @@ import FilesStorageKit struct ChatMessageFactory { private let walletServiceCompose: WalletServiceCompose - private let filesStorage: FilesStorageProtocol - private let filesStorageProprieties: FilesStorageProprietiesProtocol static let markdownParser = MarkdownParser( font: .adamantChatDefault, @@ -69,13 +67,8 @@ struct ChatMessageFactory { ] ) - init(walletServiceCompose: WalletServiceCompose, - filesStorage: FilesStorageProtocol, - filesStorageProprieties: FilesStorageProprietiesProtocol - ) { + init(walletServiceCompose: WalletServiceCompose) { self.walletServiceCompose = walletServiceCompose - self.filesStorage = filesStorage - self.filesStorageProprieties = filesStorageProprieties } func makeMessage( @@ -83,11 +76,7 @@ struct ChatMessageFactory { expireDate: inout Date?, currentSender: SenderType, dateHeaderOn: Bool, - topSpinnerOn: Bool, - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], - havePartnerName: Bool, - filesLoadingProgress: [String: Int] + topSpinnerOn: Bool ) -> ChatMessage { let sentDate = transaction.sentDate ?? .now let senderModel = ChatSender(transaction: transaction) @@ -111,11 +100,7 @@ struct ChatMessageFactory { content: makeContent( transaction, isFromCurrentSender: currentSender.senderId == senderModel.senderId, - backgroundColor: backgroundColor, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName, - filesLoadingProgress: filesLoadingProgress + backgroundColor: backgroundColor ), backgroundColor: backgroundColor, bottomString: makeBottomString( @@ -135,11 +120,7 @@ private extension ChatMessageFactory { func makeContent( _ transaction: ChatTransaction, isFromCurrentSender: Bool, - backgroundColor: ChatMessageBackgroundColor, - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], - havePartnerName: Bool, - filesLoadingProgress: [String: Int] + backgroundColor: ChatMessageBackgroundColor ) -> ChatMessage.Content { switch transaction { case let transaction as MessageTransaction: @@ -165,11 +146,7 @@ private extension ChatMessageFactory { return makeFileContent( transaction, isFromCurrentSender: isFromCurrentSender, - backgroundColor: backgroundColor, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName, - filesLoadingProgress: filesLoadingProgress + backgroundColor: backgroundColor ) } @@ -315,11 +292,7 @@ private extension ChatMessageFactory { func makeFileContent( _ transaction: RichMessageTransaction, isFromCurrentSender: Bool, - backgroundColor: ChatMessageBackgroundColor, - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], - havePartnerName: Bool, - filesLoadingProgress: [String: Int] + backgroundColor: ChatMessageBackgroundColor ) -> ChatMessage.Content { let id = transaction.chatMessageId ?? "" @@ -342,25 +315,10 @@ private extension ChatMessageFactory { ? transaction.recipientAddress : transaction.senderAddress - let isPreviewDownloadAllowed = isDownloadAllowed( - policy: filesStorageProprieties.autoDownloadPreviewPolicy(), - havePartnerName: havePartnerName - ) - - let isFullMediaDownloadAllowed = isDownloadAllowed( - policy: filesStorageProprieties.autoDownloadFullMediaPolicy(), - havePartnerName: havePartnerName - ) - let chatFiles = makeChatFiles( from: files, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs, isFromCurrentSender: isFromCurrentSender, - storage: storage, - isPreviewDownloadAllowed: isPreviewDownloadAllowed, - isFullMediaDownloadAllowed: isFullMediaDownloadAllowed, - filesLoadingProgress: filesLoadingProgress + storage: storage ) let isMediaFilesOnly = chatFiles.allSatisfy { @@ -396,20 +354,6 @@ private extension ChatMessageFactory { ))) } - func isDownloadAllowed( - policy: DownloadPolicy, - havePartnerName: Bool - ) -> Bool { - switch policy { - case .everybody: - return true - case .nobody: - return false - case .contacts: - return havePartnerName - } - } - func makeAttributed(_ text: String) -> NSMutableAttributedString { let attributedString = Self.markdownParser.parse(text) @@ -434,41 +378,26 @@ private extension ChatMessageFactory { func makeChatFiles( from files: [[String: Any]], - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], isFromCurrentSender: Bool, - storage: String, - isPreviewDownloadAllowed: Bool, - isFullMediaDownloadAllowed: Bool, - filesLoadingProgress: [String: Int] + storage: String ) -> [ChatFile] { return files.map { - let previewData = $0[RichContentKeys.file.preview] as? [String: Any] ?? [:] let file = RichMessageFile.File($0) - let fileId = file.id let fileType = FileType(raw: file.extension ?? .empty) ?? .other - let previewImage = (file.preview?.id).flatMap { - !$0.isEmpty - ? filesStorage.getPreview(for: $0) - : nil - } - - let progress = filesLoadingProgress[fileId] - return ChatFile( file: file, - previewImage: previewImage, - downloadStatus: downloadingFilesIDs[fileId] ?? .default, - isUploading: uploadingFilesIDs.contains(fileId), - isCached: filesStorage.isCachedLocally(fileId), + previewImage: nil, + downloadStatus: .default, + isUploading: false, + isCached: false, storage: storage, nonce: file.nonce, isFromCurrentSender: isFromCurrentSender, fileType: fileType, - progress: progress, - isPreviewDownloadAllowed: isPreviewDownloadAllowed, - isFullMediaDownloadAllowed: isFullMediaDownloadAllowed + progress: nil, + isPreviewDownloadAllowed: false, + isFullMediaDownloadAllowed: false ) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift index 8b4f012f2..06ebf904d 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessagesListFactory.swift @@ -22,11 +22,7 @@ actor ChatMessagesListFactory { transactions: [ChatTransaction], sender: ChatSender, isNeedToLoadMoreMessages: Bool, - expirationTimestamp minExpTimestamp: inout TimeInterval?, - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], - havePartnerName: Bool, - filesLoadingProgress: [String: Int] + expirationTimestamp minExpTimestamp: inout TimeInterval? ) -> [ChatMessage] { assert(!Thread.isMainThread, "Do not process messages on main thread") @@ -48,11 +44,7 @@ actor ChatMessagesListFactory { transactions: transactionsWithoutReact ), topSpinnerOn: isNeedToLoadMoreMessages && index == .zero, - willExpireAfter: &expTimestamp, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName, - filesLoadingProgress: filesLoadingProgress + willExpireAfter: &expTimestamp ) if let timestamp = expTimestamp, timestamp < minExpTimestamp ?? .greatestFiniteMagnitude { @@ -70,11 +62,7 @@ private extension ChatMessagesListFactory { sender: SenderType, dateHeaderOn: Bool, topSpinnerOn: Bool, - willExpireAfter: inout TimeInterval?, - uploadingFilesIDs: [String], - downloadingFilesIDs: [String: DownloadStatus], - havePartnerName: Bool, - filesLoadingProgress: [String: Int] + willExpireAfter: inout TimeInterval? ) -> ChatMessage { var expireDate: Date? let message = chatMessageFactory.makeMessage( @@ -82,11 +70,7 @@ private extension ChatMessagesListFactory { expireDate: &expireDate, currentSender: sender, dateHeaderOn: dateHeaderOn, - topSpinnerOn: topSpinnerOn, - uploadingFilesIDs: uploadingFilesIDs, - downloadingFilesIDs: downloadingFilesIDs, - havePartnerName: havePartnerName, - filesLoadingProgress: filesLoadingProgress + topSpinnerOn: topSpinnerOn ) willExpireAfter = expireDate?.timeIntervalSince1970 diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 38ea2dafe..cdfdf9fe9 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -748,7 +748,12 @@ final class ChatViewModel: NSObject { let chatFiles = fileModel.value.content.fileModel.files - if filesStorageProprieties.autoDownloadPreviewPolicy() == .nobody, + let isPreviewAutoDownloadAllowed = isDownloadAllowed( + policy: filesStorageProprieties.autoDownloadPreviewPolicy(), + havePartnerName: havePartnerName + ) + + if !isPreviewAutoDownloadAllowed, file.previewImage == nil, file.file.preview != nil, !chatFileService.isDownloadPreviewLimitReached(for: file.file.id) { @@ -1179,17 +1184,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, - uploadingFilesIDs: chatFileService.uploadingFiles, - downloadingFilesIDs: chatFileService.downloadingFiles, - havePartnerName: havePartnerName, - filesLoadingProgress: chatFileService.filesLoadingProgress + expirationTimestamp: &expirationTimestamp ) + postProcess(messages: &messages) + await setupNewMessages( newMessages: messages, resetLoadingProperty: resetLoadingProperty, @@ -1204,6 +1207,62 @@ 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 + ) + + messages[index].updateFields( + id: file.file.id, + preview: previewImage, + needToUpdatePeview: true, + cached: cached, + isUploading: isUploading, + downloadStatus: downloadStatus, + progress: progress, + isPreviewDownloadAllowed: isPreviewDownloadAllowed, + isFullMediaDownloadAllowed: isFullMediaDownloadAllowed + ) + } + func setupNewMessages( newMessages: [ChatMessage], resetLoadingProperty: Bool, @@ -1546,7 +1605,9 @@ private extension ChatMessage { cached: Bool? = nil, isUploading: Bool? = nil, downloadStatus: DownloadStatus? = nil, - progress: Int? = nil + progress: Int? = nil, + isPreviewDownloadAllowed: Bool? = nil, + isFullMediaDownloadAllowed: Bool? = nil ) { guard case let .file(fileModel) = content else { return } var model = fileModel.value @@ -1576,7 +1637,13 @@ private extension ChatMessage { if let progress = progress { model.content.fileModel.files[index].progress = progress } - + if let value = isPreviewDownloadAllowed { + model.content.fileModel.files[index].isPreviewDownloadAllowed = value + } + if let value = isFullMediaDownloadAllowed { + model.content.fileModel.files[index].isFullMediaDownloadAllowed = value + } + guard model != fileModel.value else { return } From 1cb49e9fbb90f4125ad3e8d15abdf91d277f7c93 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 7 Aug 2024 16:58:31 +0300 Subject: [PATCH 115/123] [trello.com/c/uxBZaznD] feat: inc max parallel request --- .../ServiceProtocols/APICoreProtocol.swift | 33 +++++++++++++++++++ Adamant/Services/APICore.swift | 14 ++------ .../FilesNetworkManager/IPFSApiService.swift | 7 +++- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Adamant/ServiceProtocols/APICoreProtocol.swift b/Adamant/ServiceProtocols/APICoreProtocol.swift index bf77ca444..1a5e8c28a 100644 --- a/Adamant/ServiceProtocols/APICoreProtocol.swift +++ b/Adamant/ServiceProtocols/APICoreProtocol.swift @@ -77,6 +77,24 @@ extension APICoreProtocol { ).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, @@ -134,6 +152,21 @@ extension APICoreProtocol { ) } + 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, diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index 22300a8e9..6c5a2debc 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -22,6 +22,7 @@ actor APICore: APICoreProtocol { configuration.timeoutIntervalForRequest = timeoutIntervalForRequest configuration.timeoutIntervalForResource = timeoutIntervalForResource configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpMaximumConnectionsPerHost = 100 return Alamofire.Session.init(configuration: configuration) }() @@ -113,19 +114,10 @@ private extension APICore { func sendRequest(request: DataRequest) async -> APIResponseModel { await withCheckedContinuation { continuation in request.responseData(queue: responseQueue) { response in - let code = response.response?.statusCode - let result: ApiServiceResult - - if let code = code, code == 502 || code == 504 { - result = .failure(.serverError(error: "unknown")) - } else { - result = response.result.mapError { .init(error: $0) } - } - continuation.resume(returning: .init( - result: result, + result: response.result.mapError { .init(error: $0) }, data: response.data, - code: code + code: response.response?.statusCode )) } } diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 794395090..2d3fba6aa 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -65,11 +65,16 @@ final class IPFSApiService: FileApiServiceProtocol { downloadProgress: @escaping ((Progress) -> Void) ) async throws -> Data { let result: Data = try await request { core, node in - await core.sendRequest( + let result: APIResponseModel = await core.sendRequest( node: node, path: "\(IPFSApiCommands.file.download)\(id)", downloadProgress: downloadProgress ) + + if let code = result.code, code == 502 || code == 504 || code == 404 { + return .failure(.serverError(error: "unknown")) + } + return result.result }.get() return result From 5ed3ff04bf9d0c6eb4dc193c9230354b770679f4 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Wed, 7 Aug 2024 16:58:53 +0300 Subject: [PATCH 116/123] [trello.com/c/uxBZaznD] fix: pre-chats download on start --- Adamant/Services/DataProviders/AdamantChatsProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index c88d848be..734302e21 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) } From ef21a92609f05ec1ba7ce4a6d981d194b5b2b0cb Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Tue, 13 Aug 2024 18:25:30 +0300 Subject: [PATCH 117/123] [trello.com/c/uxBZaznD] code improvements --- Adamant.xcodeproj/project.pbxproj | 20 ++++++----- Adamant/Helpers/EdgeInsetLabel.swift | 2 +- Adamant/Models/DownloadPolicy.swift | 34 +++++++++++++++++++ .../Chat/View/ChatViewController.swift | 3 +- .../Chat/View/Helpers/ChatDropView.swift | 3 +- .../View/Helpers/CircularProgressView.swift | 2 +- .../FixedTextMessageSizeCalculator.swift | 3 +- .../Container/ChatMediaContainerView.swift | 10 +++--- .../Content/ChatMediaContnentView.swift | 10 +++--- ...View.swift => FileListContainerView.swift} | 10 +++--- ...leView.swift => FileListContentView.swift} | 6 ++-- .../MediaContainerView.swift | 4 +-- .../FilesToolBarView/FilesToolbarView.swift | 11 ++++-- .../Chat/ViewModel/ChatViewModel.swift | 1 + .../StorageUsage/StorageUsageViewModel.swift | 24 ------------- Adamant/Services/APICore.swift | 3 +- .../FilesNetworkManager/IPFSApiService.swift | 20 +++++++++-- .../Localization/de.lproj/Localizable.strings | 3 ++ .../Localization/en.lproj/Localizable.strings | 3 ++ .../Localization/ru.lproj/Localizable.strings | 3 ++ .../Localization/zh.lproj/Localizable.strings | 3 ++ 21 files changed, 114 insertions(+), 64 deletions(-) create mode 100644 Adamant/Models/DownloadPolicy.swift rename Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/{FileContainerView.swift => FileListContainerView.swift} (89%) rename Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/{ChatFileView.swift => FileListContentView.swift} (98%) diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index fc04218a3..34cf00f5e 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -39,7 +39,7 @@ 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 /* ChatFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A299C7A2B85EABB00B54C61 /* ChatFileView.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 */; }; @@ -50,6 +50,7 @@ 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 */; }; @@ -76,7 +77,7 @@ 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 /* FileContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA6DF432BA997C000EA2E16 /* FileContainerView.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 */; }; @@ -722,7 +723,7 @@ 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 /* ChatFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatFileView.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 = ""; }; @@ -733,6 +734,7 @@ 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 = ""; }; @@ -757,7 +759,7 @@ 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 /* FileContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContainerView.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 = ""; }; @@ -1513,8 +1515,8 @@ 3AA6DF412BA9942300EA2E16 /* ChatFileContainerView */ = { isa = PBXGroup; children = ( - 3AA6DF432BA997C000EA2E16 /* FileContainerView.swift */, - 3A299C7A2B85EABB00B54C61 /* ChatFileView.swift */, + 3AA6DF432BA997C000EA2E16 /* FileListContainerView.swift */, + 3A299C7A2B85EABB00B54C61 /* FileListContentView.swift */, ); path = ChatFileContainerView; sourceTree = ""; @@ -2255,6 +2257,7 @@ 3AA3880D2B6A356900125684 /* RpcRequestModel.swift */, 936658902B0AB9DC00BDB2D3 /* NodeWithGroup.swift */, 3AB87CD52BF6237100AE8743 /* MultipartFormDataModel.swift */, + 3A53BD452C6B7AF100BB1EE6 /* DownloadPolicy.swift */, ); path = Models; sourceTree = ""; @@ -3267,6 +3270,7 @@ 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 */, @@ -3403,7 +3407,7 @@ E9E7CDB32002B9FB00DFC4DB /* LoginFactory.swift in Sources */, E941CCDE20E7B70200C96220 /* WalletCollectionViewCell.swift in Sources */, 4186B33A294200F4006594A3 /* DashWalletService+DynamicConstants.swift in Sources */, - 3A299C7B2B85EABB00B54C61 /* ChatFileView.swift in Sources */, + 3A299C7B2B85EABB00B54C61 /* FileListContentView.swift in Sources */, 3AF08D5F2B4EB3A200EB82B1 /* LanguageService.swift in Sources */, E9AA8BFA212C166600F9249F /* EthWalletService+Send.swift in Sources */, 411743042A39B257008CD98A /* ContributeViewModel.swift in Sources */, @@ -3514,7 +3518,7 @@ E9B1AA5B21283E0F00080A2A /* AdmTransferViewController.swift in Sources */, 3A26D94D2C3D387B003AD832 /* KlyTransactionDetailsViewController.swift in Sources */, 648C697322916192006645F5 /* DashTransactionsViewController.swift in Sources */, - 3AA6DF442BA997C000EA2E16 /* FileContainerView.swift in Sources */, + 3AA6DF442BA997C000EA2E16 /* FileListContainerView.swift in Sources */, 93E8EDCF2AF1CD9F003E163C /* NodeStatusInfo.swift in Sources */, 55E69E172868D7920025D82E /* CheckmarkView.swift in Sources */, 93B28EC02B076667007F268B /* APIResponseModel.swift in Sources */, diff --git a/Adamant/Helpers/EdgeInsetLabel.swift b/Adamant/Helpers/EdgeInsetLabel.swift index 1be00b6a4..1192a30ca 100644 --- a/Adamant/Helpers/EdgeInsetLabel.swift +++ b/Adamant/Helpers/EdgeInsetLabel.swift @@ -9,7 +9,7 @@ import Foundation import UIKit -class EdgeInsetLabel: UILabel { +final class EdgeInsetLabel: UILabel { var textInsets = UIEdgeInsets.zero { didSet { invalidateIntrinsicContentSize() } } 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/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 5deb9424d..c58082b51 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -512,7 +512,7 @@ private extension ChatViewController { func configureFilesToolbarView() { filesToolbarView.snp.makeConstraints { make in - make.height.equalTo(140) + make.height.equalTo(filesToolbarViewHeight) } filesToolbarView.closeAction = { [weak self] in @@ -1028,3 +1028,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/ChatDropView.swift b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift index 76e7111b6..953296ed3 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift @@ -32,7 +32,7 @@ private extension ChatDropView { layer.borderColor = UIColor.adamant.active.cgColor backgroundColor = .systemBackground - titleLabel.text = "Drop files here" + titleLabel.text = dropTitle imageView.tintColor = .lightGray addSubview(imageView) @@ -52,3 +52,4 @@ private extension ChatDropView { } private let titleFont = UIFont.systemFont(ofSize: 20) +private var dropTitle: String { .localized("Chat.Drop.Title") } diff --git a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift index 0b340238a..dce77cb31 100644 --- a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift +++ b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift @@ -9,7 +9,7 @@ import SwiftUI import UIKit -class CircularProgressState: ObservableObject { +final class CircularProgressState: ObservableObject { @Published var lineWidth: CGFloat = 6 @Published var backgroundColor: UIColor = .lightGray @Published var progressColor: UIColor = .blue diff --git a/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift b/Adamant/Modules/Chat/View/Managers/FixedTextMessageSizeCalculator.swift index a1a2ce876..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 } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 31aa3ec09..28c09df9b 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -80,7 +80,7 @@ final class ChatMediaContainerView: UIView, ChatModelView { let stack = UIStackView() stack.alignment = .center stack.axis = .vertical - stack.spacing = 12 + stack.spacing = reactionsStackSpace stack.addArrangedSubview(statusButton) stack.addArrangedSubview(ownReactionLabel) @@ -119,10 +119,6 @@ final class ChatMediaContainerView: UIView, ChatModelView { } } - private let ownReactionSize = CGSize(width: 40, height: 27) - private let opponentReactionSize = CGSize(width: 55, height: 27) - private let opponentReactionImageSize = CGSize(width: 12, height: 12) - // MARK: - Init override init(frame: CGRect) { @@ -352,3 +348,7 @@ extension ChatMediaContainerView.Model { 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.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift index 9d68d8032..8cb0ffcaf 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/ChatMediaContnentView.swift @@ -118,7 +118,7 @@ final class ChatMediaContentView: UIView { private lazy var uploadImageView = UIImageView(image: .asset(named: "downloadIcon")) private lazy var mediaContainerView = MediaContainerView() - private lazy var fileContainerView = FileContainerView() + private lazy var fileContainerView = FileListContainerView() var replyViewDynamicHeight: CGFloat { model.isReply ? replyViewHeight : .zero @@ -201,11 +201,9 @@ private extension ChatMediaContentView { replyContainerView.isHidden = !model.isReply spacingView.isHidden = !model.fileModel.isMediaFilesOnly - if model.isReply { - replyMessageLabel.attributedText = model.replyMessage - } else { - replyMessageLabel.attributedText = nil - } + replyMessageLabel.attributedText = model.isReply + ? model.replyMessage + : nil replyContainerView.snp.updateConstraints { make in make.height.equalTo(replyContainerViewDynamicHeight) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift similarity index 89% rename from Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift rename to Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift index 56c4cced6..6b35e5be7 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift @@ -1,5 +1,5 @@ // -// FileContainerView.swift +// FileListContainerView.swift // Adamant // // Created by Stanislav Jelezoglo on 19.03.2024. @@ -12,14 +12,14 @@ import CommonKit import FilesPickerKit import Combine -final class FileContainerView: UIView { +final class FileListContainerView: UIView { private lazy var filesStack: UIStackView = { let stack = UIStackView() stack.axis = .vertical stack.spacing = Self.stackSpacing for _ in 0.. UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: "cell", + withReuseIdentifier: String(describing: FilesToolbarCollectionViewCell.self), for: indexPath ) as? FilesToolbarCollectionViewCell else { return UICollectionViewCell() @@ -143,7 +144,10 @@ extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath ) -> CGSize { - .init(width: self.frame.height - 10, height: self.frame.height - 10) + .init( + width: self.frame.height - itemOffset, + height: self.frame.height - itemOffset + ) } func collectionView( @@ -157,3 +161,4 @@ extension FilesToolbarView: UICollectionViewDelegate, UICollectionViewDataSource 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/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index cdfdf9fe9..a90e73403 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -1453,6 +1453,7 @@ private extension ChatViewModel { } } + // TODO: Post process func updateHiddenMessage(_ messages: inout [ChatMessage]) { messages.indices.forEach { messages[$0].isHidden = messages[$0].id == hiddenMessageID diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 96e2ed796..9ec6b98c5 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -11,30 +11,6 @@ import CommonKit import SwiftUI import FilesStorageKit -public 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") - } - } -} - @MainActor final class StorageUsageViewModel: ObservableObject { private let filesStorage: FilesStorageProtocol diff --git a/Adamant/Services/APICore.swift b/Adamant/Services/APICore.swift index 6c5a2debc..bfcd37c0f 100644 --- a/Adamant/Services/APICore.swift +++ b/Adamant/Services/APICore.swift @@ -22,7 +22,7 @@ actor APICore: APICoreProtocol { configuration.timeoutIntervalForRequest = timeoutIntervalForRequest configuration.timeoutIntervalForResource = timeoutIntervalForResource configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.httpMaximumConnectionsPerHost = 100 + configuration.httpMaximumConnectionsPerHost = maximumConnectionsPerHost return Alamofire.Session.init(configuration: configuration) }() @@ -132,3 +132,4 @@ private extension APICore { private let timeoutIntervalForRequest: TimeInterval = 15 private let timeoutIntervalForResource: TimeInterval = 24 * 3600 +private let maximumConnectionsPerHost = 100 diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 2d3fba6aa..84bb9b83c 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -71,9 +71,10 @@ final class IPFSApiService: FileApiServiceProtocol { downloadProgress: downloadProgress ) - if let code = result.code, code == 502 || code == 504 || code == 404 { - return .failure(.serverError(error: "unknown")) + if let error = handleError(result) { + return .failure(error) } + return result.result }.get() @@ -81,4 +82,19 @@ final class IPFSApiService: FileApiServiceProtocol { } } +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/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 5ecfdeaa3..f3a8e7458 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -1322,3 +1322,6 @@ /* File picker error 'Cant select file' */ "FileValidationError.CantSelectFile" = "Datei kann nicht ausgewählt werden: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Dateien hier ablegen"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 76b14824c..b14d0c3e1 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -1295,3 +1295,6 @@ /* File picker error 'Cant select file' */ "FileValidationError.CantSelectFile" = "Can't select file: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Drop files here"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 161dc4e3b..81b4b1e97 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -1292,3 +1292,6 @@ /* File picker error 'Cant select file' */ "FileValidationError.CantSelectFile" = "Невозможно выбрать файл: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "Перетащите файлы сюда"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index a610b20a5..a8a7c013c 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -1293,3 +1293,6 @@ /* File picker error 'Cant select file' */ "FileValidationError.CantSelectFile" = "无法选择文件: %@"; + +/* Chat drop view title */ +"Chat.Drop.Title" = "将文件拖到这里"; From 7619b4bf42af348d5b44b22c1b327dc654196cc5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 15 Aug 2024 12:35:04 +0300 Subject: [PATCH 118/123] [trello.com/c/uxBZaznD] code improvements --- .../CoreData/Chatroom+CoreDataClass.swift | 13 ++------ .../Chat/View/ChatViewController.swift | 3 ++ .../Modules/Chat/View/Helpers/ChatFile.swift | 2 +- .../View/Helpers/CircularProgressView.swift | 18 ++++++----- .../Chat/View/Managers/ChatAction.swift | 2 +- .../View/Managers/ChatDataSourceManager.swift | 4 +-- .../Chat/View/Subviews/ChatInputBar.swift | 10 ++++-- .../FileListContainerView.swift | 22 ++++++++++--- .../FileListContentView.swift | 6 ++-- .../MediaContainerView.swift | 22 ++++++++++--- .../MediaContainerView/MediaContentView.swift | 4 +-- .../Chat/ViewModel/ChatViewModel.swift | 31 +++++++------------ .../NotificationsService.swift | 4 --- 13 files changed, 80 insertions(+), 61 deletions(-) diff --git a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift index 8a0c07d45..1b40bbf37 100644 --- a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift +++ b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift @@ -50,16 +50,9 @@ public class Chatroom: NSManagedObject { @MainActor func havePartnerName(addressBookService: AddressBookService) -> Bool { guard let partner = partner else { return false } - if let address = partner.address, - let name = addressBookService.getName(for: address) { - return true - } else if let title = title { - return true - } else if let name = partner.name { - return true - } - - return false + return partner.address.flatMap { addressBookService.getName(for: $0) } != nil + || title != nil + || partner.name != nil } private let semaphore = DispatchSemaphore(value: 1) diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index c58082b51..dc38bad0a 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -97,6 +97,9 @@ final class ChatViewController: MessagesViewController { inputBar.onAttachmentButtonTap = { [weak self] in self?.viewModel.presentActionMenu() } + inputBar.onImagePasted = { [weak self] image in + self?.viewModel.handlePastedImage(image) + } } required init?(coder: NSCoder) { diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index d89aae48d..0718f7151 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -10,7 +10,7 @@ import Foundation import CommonKit import UIKit -struct DownloadStatus: Equatable, Hashable { +struct DownloadStatus: Hashable { var isPreviewDownloading: Bool var isOriginalDownloading: Bool diff --git a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift index dce77cb31..b834666ee 100644 --- a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift +++ b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift @@ -10,16 +10,16 @@ import SwiftUI import UIKit final class CircularProgressState: ObservableObject { - @Published var lineWidth: CGFloat = 6 - @Published var backgroundColor: UIColor = .lightGray - @Published var progressColor: UIColor = .blue + let lineWidth: CGFloat + let backgroundColor: UIColor + let progressColor: UIColor @Published var progress: Double = 0 @Published var hidden: Bool = false init( - lineWidth: CGFloat, - backgroundColor: UIColor, - progressColor: UIColor, + lineWidth: CGFloat = 6, + backgroundColor: UIColor = .lightGray, + progressColor: UIColor = .blue, progress: Double, hidden: Bool ) { @@ -32,7 +32,11 @@ final class CircularProgressState: ObservableObject { } struct CircularProgressView: View { - @EnvironmentObject private var state: CircularProgressState + @StateObject private var state: CircularProgressState + + init(state: CircularProgressState) { + _state = .init(wrappedValue: state) + } var body: some View { ZStack { diff --git a/Adamant/Modules/Chat/View/Managers/ChatAction.swift b/Adamant/Modules/Chat/View/Managers/ChatAction.swift index 80e2d2921..0990fe43f 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatAction.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatAction.swift @@ -22,6 +22,6 @@ enum ChatAction { case react(id: String, emoji: String) case presentMenu(arg: ChatContextMenuArguments) case openFile(messageId: String, file: ChatFile) - case downloadPreviewIfNeeded(messageId: String, files: [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 2455ff45d..22829e0c6 100644 --- a/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift +++ b/Adamant/Modules/Chat/View/Managers/ChatDataSourceManager.swift @@ -196,8 +196,8 @@ private extension ChatDataSourceManager { viewModel.copyTextInPartAction(text) case let .openFile(messageId, file): viewModel.openFile(messageId: messageId, file: file) - case let .downloadPreviewIfNeeded(messageId, files): - viewModel.downloadPreviewIfNeeded( + case let .downloadContentIfNeeded(messageId, files): + viewModel.downloadContentIfNeeded( messageId: messageId, files: files ) diff --git a/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift b/Adamant/Modules/Chat/View/Subviews/ChatInputBar.swift index 52eedae7b..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() } } @@ -239,8 +240,11 @@ extension InputTextView { open override func paste(_ sender: Any?) { super.paste(sender) - guard let image = UIPasteboard.general.image else { return } - NotificationCenter.default.post(name: .AdamantInputText.pastedImage, object: image) + guard let view = inputBarAccessoryView as? ChatInputBar, + let image = UIPasteboard.general.image + else { return } + + view.onImagePasted?(image) } } 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 index 6b35e5be7..78d475e10 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContainerView.swift @@ -59,10 +59,24 @@ private extension FileListContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) - actionHandler(.downloadPreviewIfNeeded( - messageId: model.messageId, - files: Array(fileList) - )) + 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 } 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 index 4068cd2eb..c90ee4a0f 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift @@ -56,7 +56,7 @@ final class FileListContentView: UIView { stack.axis = .horizontal stack.spacing = stackSpacing - let controller = UIHostingController(rootView: progressBar.environmentObject(progressState)) + let controller = UIHostingController(rootView: progressBar) controller.view.backgroundColor = .clear stack.addArrangedSubview(sizeLabel) @@ -70,7 +70,7 @@ final class FileListContentView: UIView { return btn }() - private lazy var progressBar = CircularProgressView() + private lazy var progressBar = CircularProgressView(state: progressState) private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, @@ -194,7 +194,7 @@ private extension FileListContentView { downloadImageView.isHidden = chatFile.isCached || chatFile.isBusy || model.txStatus == .failed - || chatFile.previewImage == nil + || (chatFile.fileType.isMedia && chatFile.previewImage == nil) if chatFile.isDownloading { if chatFile.previewImage == nil, 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 index 75e02a532..acdeb9cbf 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContainerView.swift @@ -102,10 +102,24 @@ private extension MediaContainerView { func update() { let fileList = model.files.prefix(FilesConstants.maxFilesCount) - actionHandler(.downloadPreviewIfNeeded( - messageId: model.messageId, - files: Array(fileList) - )) + 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)) 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 index 3a56b40a5..80b995dc2 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -30,7 +30,7 @@ final class MediaContentView: UIView { return btn }() - private lazy var progressBar = CircularProgressView() + private lazy var progressBar = CircularProgressView(state: progressState) private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, @@ -114,7 +114,7 @@ private extension MediaContentView { make.size.equalTo(imageSize / 1.6) } - let controller = UIHostingController(rootView: progressBar.environmentObject(progressState)) + let controller = UIHostingController(rootView: progressBar) controller.view.backgroundColor = .clear addSubview(controller.view) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index a90e73403..0a5ee3cd1 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -779,7 +779,7 @@ final class ChatViewModel: NSObject { ) } - func downloadPreviewIfNeeded( + func downloadContentIfNeeded( messageId: String, files: [ChatFile] ) { @@ -968,7 +968,7 @@ final class ChatViewModel: NSObject { case let .file(model) = message.content else { return } - downloadPreviewIfNeeded( + downloadContentIfNeeded( messageId: message.messageId, files: model.value.content.fileModel.files ) @@ -1032,6 +1032,15 @@ extension ChatViewModel { filesPicked = data } + + func handlePastedImage(_ image: UIImage) { + do { + let file = try filesPicker.getFileResult(for: image) + processFileResult(.success([file])) + } catch { + processFileResult(.failure(error)) + } + } } extension ChatViewModel: NSFetchedResultsControllerDelegate { @@ -1073,24 +1082,6 @@ private extension ChatViewModel { .sink { [weak self] _ in self?.updateAttachmentButtonAvailability() } .store(in: &subscriptions) - NotificationCenter.default - .publisher(for: .AdamantInputText.pastedImage) - .sink { [weak self] data in - guard let image = data.object as? UIImage else { - return - } - - do { - guard let file = try self?.filesPicker.getFileResult(for: image) - else { return } - - self?.processFileResult(.success([file])) - } catch { - self?.processFileResult(.failure(error)) - } - } - .store(in: &subscriptions) - Task { await chatsProvider.stateObserver .receive(on: DispatchQueue.main) diff --git a/Adamant/ServiceProtocols/NotificationsService.swift b/Adamant/ServiceProtocols/NotificationsService.swift index af3b6b131..ca35842e3 100644 --- a/Adamant/ServiceProtocols/NotificationsService.swift +++ b/Adamant/ServiceProtocols/NotificationsService.swift @@ -126,10 +126,6 @@ extension Notification.Name { static let notificationsModeChanged = Notification.Name("adamant.notificationService.notificationsMode") static let notificationsSoundChanged = Notification.Name("adamant.notificationService.notificationsSound") } - - enum AdamantInputText { - static let pastedImage = Notification.Name("pastedImage") - } } extension AdamantUserInfoKey { From 423987037223cccad7c658914e821161b62224e6 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 16 Aug 2024 15:03:34 +0300 Subject: [PATCH 119/123] [trello.com/c/uxBZaznD] fix: calculate file message status once --- .../CoreData/Chatroom+CoreDataClass.swift | 2 +- .../ChatMediaContainerView+Model.swift | 31 ++----------------- .../Chat/ViewModel/ChatMessageFactory.swift | 3 +- .../Chat/ViewModel/ChatViewModel.swift | 31 ++++++++++++++++++- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift index 1b40bbf37..f1a5a78af 100644 --- a/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift +++ b/Adamant/Models/CoreData/Chatroom+CoreDataClass.swift @@ -47,7 +47,7 @@ public class Chatroom: NSManagedObject { return result?.checkAndReplaceSystemWallets() } - @MainActor func havePartnerName(addressBookService: AddressBookService) -> Bool { + @MainActor func hasPartnerName(addressBookService: AddressBookService) -> Bool { guard let partner = partner else { return false } return partner.address.flatMap { addressBookService.getName(for: $0) } != nil diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift index 2ccd9ae48..5abb23a62 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -18,33 +18,7 @@ extension ChatMediaContainerView { let address: String let opponentAddress: String let txStatus: MessageStatus - - var status: FileMessageStatus { - if txStatus == .failed { - return .failed - } - - if content.fileModel.files.first(where: { $0.isBusy }) != nil { - return .busy - } - - if content.fileModel.files.contains(where: { - !$0.isCached || - ($0.isCached - && $0.file.preview != nil - && $0.previewImage == nil - && ($0.fileType == .image || $0.fileType == .video)) - }) { - let failed = content.fileModel.files.contains(where: { - guard let progress = $0.progress else { return false } - return progress < 100 - }) - - return .needToDownload(failed: failed) - } - - return .success - } + var status: FileMessageStatus static let `default` = Self( id: "", @@ -53,7 +27,8 @@ extension ChatMediaContainerView { content: .default, address: "", opponentAddress: "", - txStatus: .failed + txStatus: .failed, + status: .failed ) func makeReplyContent() -> NSAttributedString { diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 61d75d311..13fff642a 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -350,7 +350,8 @@ private extension ChatMessageFactory { ), address: address, opponentAddress: opponentAddress, - txStatus: transaction.statusEnum + txStatus: transaction.statusEnum, + status: .failed ))) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 0a5ee3cd1..38b5690a3 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -1341,7 +1341,7 @@ private extension ChatViewModel { } partnerName = chatroom?.getName(addressBookService: addressBookService) - havePartnerName = chatroom?.havePartnerName(addressBookService: addressBookService) ?? false + havePartnerName = chatroom?.hasPartnerName(addressBookService: addressBookService) ?? false guard let avatarName = chatroom?.partner?.avatar, let avatar = UIImage.asset(named: avatarName) @@ -1636,12 +1636,41 @@ private extension ChatMessage { model.content.fileModel.files[index].isFullMediaDownloadAllowed = value } + model.status = getStatus(from: model) + guard model != fileModel.value else { return } content = .file(.init(value: 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 + } } private extension Sequence where Element == ChatTransaction { From 5ef7ebc46ef8ae853dc41fd7a7617a086ba342f6 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 16 Aug 2024 15:08:07 +0300 Subject: [PATCH 120/123] [trello.com/c/uxBZaznD] fix: "unknown" title --- Adamant/Modules/Chat/ChatLocalization.swift | 3 +++ .../Views/ChatFileContainerView/FileListContentView.swift | 2 +- .../CommonKit/Assets/Localization/de.lproj/Localizable.strings | 3 +++ .../CommonKit/Assets/Localization/en.lproj/Localizable.strings | 3 +++ .../CommonKit/Assets/Localization/ru.lproj/Localizable.strings | 3 +++ .../CommonKit/Assets/Localization/zh.lproj/Localizable.strings | 3 +++ 6 files changed, 16 insertions(+), 1 deletion(-) 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/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift index c90ee4a0f..8cb147c9c 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift @@ -223,7 +223,7 @@ private extension FileListContentView { progressState.progress = Double(progress) / 100 let fileType = chatFile.file.extension.map { ".\($0)" } ?? .empty - let fileName = chatFile.file.name ?? "UNKNWON" + let fileName = chatFile.file.name ?? .adamant.chat.unknownTitle.uppercased() nameLabel.text = fileName.contains(fileType) ? fileName diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index f3a8e7458..034264e2f 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -1325,3 +1325,6 @@ /* 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 b14d0c3e1..24d1f315d 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -1298,3 +1298,6 @@ /* 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 81b4b1e97..154ed882b 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -1295,3 +1295,6 @@ /* 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 a8a7c013c..005f1d6fc 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -1296,3 +1296,6 @@ /* Chat drop view title */ "Chat.Drop.Title" = "将文件拖到这里"; + +/* Chat unknown */ +"Chat.unknown.title" = "未知"; From a70c830f64477cf8bd451a90623bf1c26faa186d Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Mon, 19 Aug 2024 20:15:47 +0300 Subject: [PATCH 121/123] [trello.com/c/uxBZaznD] code improvements --- Adamant.xcodeproj/project.pbxproj | 4 + Adamant/Models/ApiServiceResult.swift | 1 + Adamant/Models/IPFSNodeStatus.swift | 31 +++ .../Chat/View/Helpers/ChatDropView.swift | 1 - .../Modules/Chat/View/Helpers/ChatFile.swift | 1 - .../View/Helpers/CircularProgressView.swift | 4 +- .../Chat/View/Helpers/FileMessageStatus.swift | 1 - .../FileListContentView.swift | 5 +- .../MediaContainerView/MediaContentView.swift | 6 +- .../Chat/ViewModel/ChatFileService.swift | 72 +++-- .../Chat/ViewModel/ChatViewModel.swift | 261 ++++++++---------- .../StorageUsage/StorageUsageFactory.swift | 6 +- .../StorageUsage/StorageUsageView.swift | 4 +- .../StorageUsage/StorageUsageViewModel.swift | 2 +- .../ServiceProtocols/ChatFileProtocol.swift | 25 +- .../FileApiServiceProtocol.swift | 4 +- .../FilesNetworkManagerProtocol.swift | 4 +- .../DataProviders/AdamantChatsProvider.swift | 10 + .../FilesNetworkManager.swift | 10 +- .../FilesNetworkManager/IPFSApiCore.swift | 18 +- .../FilesNetworkManager/IPFSApiService.swift | 27 +- .../Models/FileManagerError.swift | 3 + .../Helpers/MessageProcessHelper.swift | 1 - .../Helpers/UIHelpers/UIColor+hex.swift | 1 - .../Sources/CommonKit/Models/FileResult.swift | 7 +- .../Models/FileValidationError.swift | 3 + .../Helpers/FilesPickerKit.swift | 4 +- .../Pickers/DocumentInteractionService.swift | 1 - .../Pickers/DocumentPickerService.swift | 1 - .../Protocols/FilePickerServiceProtocol.swift | 1 - .../FilesStorageKit/FilesStorageKit.swift | 33 ++- .../Protocols/FilesStorageProtocol.swift | 8 +- 32 files changed, 299 insertions(+), 261 deletions(-) create mode 100644 Adamant/Models/IPFSNodeStatus.swift diff --git a/Adamant.xcodeproj/project.pbxproj b/Adamant.xcodeproj/project.pbxproj index 34cf00f5e..ddb3f1e79 100644 --- a/Adamant.xcodeproj/project.pbxproj +++ b/Adamant.xcodeproj/project.pbxproj @@ -96,6 +96,7 @@ 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 */; }; @@ -780,6 +781,7 @@ 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 = ""; }; @@ -2238,6 +2240,7 @@ E95F85682006AB9D0070534A /* NormalizedTransaction.swift */, 93E5D4DA293000BE00439298 /* UnregisteredTransaction.swift */, 5558A437282AB9390024DDD6 /* NodeStatus.swift */, + 3AF8D9E82C73ADFA007A7CBC /* IPFSNodeStatus.swift */, E9FCA1E5218334C00005E83D /* SimpleTransactionDetails.swift */, E971591921681D6900A5F904 /* TransactionStatus.swift */, 648C696E22915A12006645F5 /* DashTransaction.swift */, @@ -3597,6 +3600,7 @@ 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 */, 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/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/Modules/Chat/View/Helpers/ChatDropView.swift b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift index 953296ed3..eb67a11df 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatDropView.swift @@ -6,7 +6,6 @@ // Copyright © 2024 Adamant. All rights reserved. // -import Foundation import UIKit import SnapKit diff --git a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift index 0718f7151..7f55caaa1 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatFile.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatFile.swift @@ -6,7 +6,6 @@ // Copyright © 2024 Adamant. All rights reserved. // -import Foundation import CommonKit import UIKit diff --git a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift index b834666ee..f9859965f 100644 --- a/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift +++ b/Adamant/Modules/Chat/View/Helpers/CircularProgressView.swift @@ -34,8 +34,8 @@ final class CircularProgressState: ObservableObject { struct CircularProgressView: View { @StateObject private var state: CircularProgressState - init(state: CircularProgressState) { - _state = .init(wrappedValue: state) + init(state: @escaping () -> CircularProgressState) { + _state = .init(wrappedValue: state()) } var body: some View { diff --git a/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift index 3ef357631..f2055198e 100644 --- a/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift +++ b/Adamant/Modules/Chat/View/Helpers/FileMessageStatus.swift @@ -6,7 +6,6 @@ // Copyright © 2024 Adamant. All rights reserved. // -import Foundation import UIKit import CommonKit 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 index 8cb147c9c..9a27186e2 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/ChatFileContainerView/FileListContentView.swift @@ -56,6 +56,10 @@ final class FileListContentView: UIView { 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 @@ -70,7 +74,6 @@ final class FileListContentView: UIView { return btn }() - private lazy var progressBar = CircularProgressView(state: progressState) private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, 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 index 80b995dc2..baaba4c8c 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Content/Views/MediaContainerView/MediaContentView.swift @@ -6,7 +6,6 @@ // Copyright © 2024 Adamant. All rights reserved. // -import Foundation import UIKit import SnapKit import SwiftUI @@ -30,7 +29,6 @@ final class MediaContentView: UIView { return btn }() - private lazy var progressBar = CircularProgressView(state: progressState) private lazy var progressState: CircularProgressState = { .init( lineWidth: 2.0, @@ -114,6 +112,10 @@ private extension MediaContentView { 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 diff --git a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift index 99065c379..da6cef3ae 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatFileService.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatFileService.swift @@ -6,7 +6,6 @@ // Copyright © 2024 Adamant. All rights reserved. // -import Foundation import CommonKit import UIKit import Combine @@ -63,17 +62,7 @@ final class ChatFileService: ChatFileProtocol { $fileProgressValue.wrappedValue } - let updateFileFields = ObservableSender<( - id: String, - newId: String?, - fileNonce: String?, - preview: UIImage?, - needUpdatePreview: Bool, - cached: Bool?, - downloadStatus: DownloadStatus?, - uploading: Bool?, - progress: Int? - )>() + let updateFileFields = ObservableSender() init( accountService: AccountService, @@ -330,7 +319,7 @@ private extension ChatFileService { ) { guard let id = file.file.preview?.id, let nonce = file.file.preview?.nonce, - let fileDTO = try? filesStorage.getFile(with: id), + let fileDTO = try? filesStorage.getFile(with: id).get(), fileDTO.isPreview, filesStorage.isCachedLocally(id), !filesStorage.isCachedInMemory(id), @@ -344,17 +333,18 @@ private extension ChatFileService { return } - updateFileFields.send(( + updateFileFields.send(.init( id: file.file.id, newId: nil, fileNonce: nil, - preview: image, - needUpdatePreview: true, + preview: .some(image), cached: nil, downloadStatus: nil, uploading: nil, - progress: nil - )) + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil) + ) } func cacheFileToMemory( @@ -475,16 +465,17 @@ private extension ChatFileService { let preview = filesStorage.getPreview(for: previewDTO.id) - updateFileFields.send(( + updateFileFields.send(.init( id: file.file.id, newId: nil, fileNonce: nil, - preview: preview, - needUpdatePreview: true, + preview: .some(preview), cached: nil, downloadStatus: nil, uploading: nil, - progress: nil + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil )) } else if !filesStorage.isCachedInMemory(previewDTO.id) { cacheFileToMemoryIfNeeded(file: file, chatroom: chatroom) @@ -516,17 +507,18 @@ private extension ChatFileService { let cached = filesStorage.isCachedLocally(file.file.id) - updateFileFields.send(( + updateFileFields.send(.init( id: file.file.id, newId: nil, fileNonce: nil, preview: nil, - needUpdatePreview: false, cached: cached, downloadStatus: nil, uploading: nil, - progress: nil - )) + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil) + ) } } @@ -658,7 +650,7 @@ private extension ChatFileService { id, type: storage, downloadProgress: downloadProgress - ) + ).get() guard let decodedData = adamantCore.decodeData( encodedData, @@ -681,16 +673,17 @@ private extension ChatFileService { progress: Int? = nil ) { files.forEach { id in - updateFileFields.send(( + updateFileFields.send(.init( id: id, newId: nil, fileNonce: nil, preview: nil, - needUpdatePreview: false, cached: nil, downloadStatus: downloadStatus, uploading: uploading, - progress: progress + progress: progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil )) if progress != nil { @@ -704,16 +697,17 @@ private extension ChatFileService { func sendProgress(for fileId: String, progress: Int) { guard $fileProgressValue.wrappedValue[fileId] != progress else { return } - updateFileFields.send(( + updateFileFields.send(.init( id: fileId, newId: nil, fileNonce: nil, preview: nil, - needUpdatePreview: false, cached: nil, downloadStatus: nil, uploading: nil, - progress: progress + progress: progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil )) $fileProgressValue.mutate { @@ -1027,16 +1021,17 @@ private extension ChatFileService { $uploadingFilesIDsArray.mutate { $0.removeAll { $0 == oldId } } - updateFileFields.send(( + updateFileFields.send(.init( id: oldId, newId: fileResult.cid, fileNonce: fileResult.nonce, - preview: filesStorage.getPreview(for: previewResult?.cid ?? .empty), - needUpdatePreview: true, + preview: .some(filesStorage.getPreview(for: previewResult?.cid ?? .empty)), cached: cached, downloadStatus: nil, uploading: false, - progress: nil + progress: nil, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil )) var previewDTO: RichMessageFile.Preview? @@ -1173,7 +1168,8 @@ private extension ChatFileService { encodedData, type: storageProtocol, uploadProgress: uploadProgress - ) + ).get() + return (data, encodedData, nonce, cid) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 38b5690a3..d912964ad 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -138,10 +138,10 @@ final class ChatViewModel: NSObject { didSet { updateHiddenMessage(&messages) } } - lazy var mediaPickerDelegate = MediaPickerService(helper: filesPicker) - lazy var documentPickerDelegate = DocumentPickerService(helper: filesPicker) - lazy var documentViewerService = DocumentInteractionService() - lazy var dropInteractionService = DropInteractionService(helper: filesPicker) + 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, @@ -295,29 +295,10 @@ final class ChatViewModel: NSObject { return } - if filesPicked?.count ?? .zero > .zero { - guard nodesStorage.haveActiveNode(in: .ipfs) else { - dialog.send(.alert(ApiServiceError.noEndpointsAvailable( - coin: NodeGroup.ipfs.name - ).localizedDescription)) - return - } - + if !(filesPicked?.isEmpty ?? true) { Task { - let replyMessage = replyMessage - let filesPicked = filesPicked - - self.replyMessage = nil - self.filesPicked = nil - do { - try await chatFileService.sendFile( - text: text, - chatroom: chatroom, - filesPicked: filesPicked, - replyMessage: replyMessage, - saveEncrypted: filesStorageProprieties.saveFileEncrypted() - ) + try await sendFiles(with: text) } catch { await handleMessageSendingError( error: error, @@ -736,7 +717,9 @@ final class ChatViewModel: NSObject { let tx = chatTransactions.first(where: { $0.txId == messageId }) let message = messages.first(where: { $0.messageId == messageId }) - if tx?.statusEnum == .failed { + guard let tx = tx, + tx.statusEnum != .failed + else { dialog.send(.failedMessageAlert(id: messageId, sender: nil)) return } @@ -770,7 +753,7 @@ final class ChatViewModel: NSObject { return } - guard tx?.statusEnum == .delivered else { return } + guard tx.statusEnum == .delivered else { return } downloadFile( file: file, @@ -819,15 +802,7 @@ final class ChatViewModel: NSObject { let needToDownload: [ChatFile] let shouldDownloadFile: (ChatFile) -> Bool = { file in - if !file.isCached { - return true - } - - if file.fileType.isMedia && file.previewImage == nil { - return isPreviewDownloadAllowed - } - - return false + !file.isCached || (file.fileType.isMedia && file.previewImage == nil && isPreviewDownloadAllowed) } let previewFiles = files.filter { file in @@ -1050,6 +1025,29 @@ 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() @@ -1061,17 +1059,22 @@ private extension ChatViewModel { .sink { [weak self] data in guard let self = self else { return } - self.updateFileFields( - &self.messages, + let fileProprieties = FileUpdateProperties( id: data.id, newId: data.newId, fileNonce: data.fileNonce, preview: data.preview, - needToUpdatePreview: data.needUpdatePreview, cached: data.cached, - isUploading: data.uploading, downloadStatus: data.downloadStatus, - progress: data.progress + uploading: data.uploading, + progress: data.progress, + isPreviewDownloadAllowed: nil, + isFullMediaDownloadAllowed: nil + ) + + self.updateFileFields( + &self.messages, + fileProprieties: fileProprieties ) } .store(in: &subscriptions) @@ -1092,7 +1095,7 @@ private extension ChatViewModel { }.stored(in: tasksStorage) dropInteractionService.onPreparedDataCallback = { [weak self] result in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.dropSessionUpdated(false) self?.presentDialog(progress: false) self?.processFileResult(result) @@ -1100,7 +1103,7 @@ private extension ChatViewModel { } dropInteractionService.onPreparingDataCallback = { [weak self] in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.presentDialog(progress: true) } } @@ -1110,27 +1113,27 @@ private extension ChatViewModel { } mediaPickerDelegate.onPreparedDataCallback = { [weak self] result in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.presentDialog(progress: false) self?.processFileResult(result) } } mediaPickerDelegate.onPreparingDataCallback = { [weak self] in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.presentDialog(progress: true) } } documentPickerDelegate.onPreparedDataCallback = { [weak self] result in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.presentDialog(progress: false) self?.processFileResult(result) } } documentPickerDelegate.onPreparingDataCallback = { [weak self] in - DispatchQueue.onMainAsync { + Task { @MainActor in self?.presentDialog(progress: true) } } @@ -1241,17 +1244,20 @@ private extension ChatViewModel { havePartnerName: havePartnerName ) - messages[index].updateFields( + let fileProprieties = FileUpdateProperties( id: file.file.id, - preview: previewImage, - needToUpdatePeview: true, + newId: nil, + fileNonce: nil, + preview: .some(previewImage), cached: cached, - isUploading: isUploading, downloadStatus: downloadStatus, + uploading: isUploading, progress: progress, isPreviewDownloadAllowed: isPreviewDownloadAllowed, isFullMediaDownloadAllowed: isFullMediaDownloadAllowed ) + + updateFileMessageFields(for: &messages[index], fileProprieties: fileProprieties) } func setupNewMessages( @@ -1453,18 +1459,10 @@ private extension ChatViewModel { func updateFileFields( _ messages: inout [ChatMessage], - id oldId: String, - newId: String? = nil, - fileNonce: String? = nil, - preview: UIImage?, - needToUpdatePreview: Bool, - cached: Bool? = nil, - isUploading: Bool? = nil, - downloadStatus: DownloadStatus? = nil, - progress: Int? = nil + fileProprieties: FileUpdateProperties ) { let indexes = messages.indices.filter { - messages[$0].getFiles().contains { $0.file.id == oldId } + messages[$0].getFiles().contains { $0.file.id == fileProprieties.id } } guard !indexes.isEmpty else { @@ -1472,20 +1470,58 @@ private extension ChatViewModel { } indexes.forEach { index in - messages[index].updateFields( - id: oldId, - newId: newId, - fileNonce: fileNonce, - preview: preview, - needToUpdatePeview: needToUpdatePreview, - cached: cached, - isUploading: isUploading, - downloadStatus: downloadStatus, - progress: progress - ) + 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(), @@ -1501,7 +1537,8 @@ private extension ChatViewModel { let files: [FileResult] = chatFiles.compactMap { file in guard file.isCached, !file.isBusy, - let fileDTO = try? filesStorage.getFile(with: file.file.id) else { + let fileDTO = try? filesStorage.getFile(with: file.file.id).get() + else { return nil } @@ -1588,89 +1625,29 @@ private extension ChatMessage { return model.value.content.fileModel.files } - mutating func updateFields( - id oldId: String, - newId: String? = nil, - fileNonce: String? = nil, - preview: UIImage?, - needToUpdatePeview: Bool, - cached: Bool? = nil, - isUploading: Bool? = nil, - downloadStatus: DownloadStatus? = nil, - progress: Int? = nil, - isPreviewDownloadAllowed: Bool? = nil, - isFullMediaDownloadAllowed: Bool? = nil + 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 == oldId } + where: { $0.file.id == id } ) else { return } - if let newId = newId { - model.content.fileModel.files[index].file.id = newId - } - if let fileNonce = fileNonce { - model.content.fileModel.files[index].file.nonce = fileNonce - } - if let value = cached { - model.content.fileModel.files[index].isCached = value - } - if let value = isUploading { - model.content.fileModel.files[index].isUploading = value - } - if let value = downloadStatus { - model.content.fileModel.files[index].downloadStatus = value - } - if needToUpdatePeview { - model.content.fileModel.files[index].previewImage = preview - } - if let progress = progress { - model.content.fileModel.files[index].progress = progress - } - if let value = isPreviewDownloadAllowed { - model.content.fileModel.files[index].isPreviewDownloadAllowed = value - } - if let value = isFullMediaDownloadAllowed { - model.content.fileModel.files[index].isFullMediaDownloadAllowed = value - } + let previousValue = model - model.status = getStatus(from: model) + mutateFile(&model.content.fileModel.files[index]) + mutateModel(&model) - guard model != fileModel.value else { + guard model != previousValue else { return } content = .file(.init(value: 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 - } } private extension Sequence where Element == ChatTransaction { diff --git a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift index 3bca6cc47..fbb88989e 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageFactory.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageFactory.swift @@ -19,9 +19,9 @@ struct StorageUsageFactory { func makeViewController() -> UIViewController { UIHostingController( - rootView: StorageUsageView( - viewModel: assembler.resolve(StorageUsageViewModel.self)! - ) + rootView: StorageUsageView { + assembler.resolve(StorageUsageViewModel.self)! + } ) } } diff --git a/Adamant/Modules/StorageUsage/StorageUsageView.swift b/Adamant/Modules/StorageUsage/StorageUsageView.swift index 5abe18894..daab84c31 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageView.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageView.swift @@ -13,8 +13,8 @@ import Charts struct StorageUsageView: View { @StateObject private var viewModel: StorageUsageViewModel - init(viewModel: StorageUsageViewModel) { - _viewModel = .init(wrappedValue: viewModel) + init(viewModel: @escaping () -> StorageUsageViewModel) { + _viewModel = .init(wrappedValue: viewModel()) } var body: some View { diff --git a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift index 9ec6b98c5..ae140d751 100644 --- a/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift +++ b/Adamant/Modules/StorageUsage/StorageUsageViewModel.swift @@ -119,7 +119,7 @@ final class StorageUsageViewModel: ObservableObject { private extension StorageUsageViewModel { func updateCacheSize() { DispatchQueue.global().async { - let size = (try? self.filesStorage.getCacheSize()) ?? .zero + let size = (try? self.filesStorage.getCacheSize().get()) ?? .zero DispatchQueue.main.async { self.storageUsedDescription = self.formatSize(size) } diff --git a/Adamant/ServiceProtocols/ChatFileProtocol.swift b/Adamant/ServiceProtocols/ChatFileProtocol.swift index eef3cf819..9e5b13348 100644 --- a/Adamant/ServiceProtocols/ChatFileProtocol.swift +++ b/Adamant/ServiceProtocols/ChatFileProtocol.swift @@ -12,22 +12,25 @@ 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<( - id: String, - newId: String?, - fileNonce: String?, - preview: UIImage?, - needUpdatePreview: Bool, - cached: Bool?, - downloadStatus: DownloadStatus?, - uploading: Bool?, - progress: Int? - ), Never> { + var updateFileFields: PassthroughSubject { get } diff --git a/Adamant/ServiceProtocols/FileApiServiceProtocol.swift b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift index ce1831d84..8a1d30942 100644 --- a/Adamant/ServiceProtocols/FileApiServiceProtocol.swift +++ b/Adamant/ServiceProtocols/FileApiServiceProtocol.swift @@ -12,10 +12,10 @@ protocol FileApiServiceProtocol: WalletApiService { func uploadFile( data: Data, uploadProgress: @escaping ((Progress) -> Void) - ) async throws -> String + ) async -> FileApiServiceResult func downloadFile( id: String, downloadProgress: @escaping ((Progress) -> Void) - ) async throws -> Data + ) async -> FileApiServiceResult } diff --git a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift index 8d1fa3a1a..76dba9f9c 100644 --- a/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift +++ b/Adamant/ServiceProtocols/FilesNetworkManagerProtocol.swift @@ -13,11 +13,11 @@ protocol FilesNetworkManagerProtocol { _ data: Data, type: NetworkFileProtocolType, uploadProgress: @escaping ((Progress) -> Void) - ) async throws -> String + ) async -> FileApiServiceResult func downloadFile( _ id: String, type: String, downloadProgress: @escaping ((Progress) -> Void) - ) async throws -> Data + ) async -> FileApiServiceResult } diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 734302e21..be62fd24f 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -1329,6 +1329,14 @@ extension AdamantChatsProvider { do { let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() + print("sendMessageTransaction id=\(id), transaction=\(transaction.height)") +// do { +// await Task.sleep(interval: 10) +// let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() +// print("sendMessageTransaction id=\(id)") +// } catch { +// print("sendMessageTransaction error=\(error)") +// } // Update ID with recieved, add to unconfirmed transactions. transaction.transactionId = String(id) transaction.chatMessageId = String(id) @@ -1706,6 +1714,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, @@ -1723,6 +1732,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 index 0a41d306e..8cd6b09e9 100644 --- a/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift +++ b/Adamant/Services/FilesNetworkManager/FilesNetworkManager.swift @@ -19,10 +19,10 @@ final class FilesNetworkManager: FilesNetworkManagerProtocol { _ data: Data, type: NetworkFileProtocolType, uploadProgress: @escaping ((Progress) -> Void) - ) async throws -> String { + ) async -> FileApiServiceResult { switch type { case .ipfs: - return try await ipfsService.uploadFile(data: data, uploadProgress: uploadProgress) + return await ipfsService.uploadFile(data: data, uploadProgress: uploadProgress) } } @@ -30,14 +30,14 @@ final class FilesNetworkManager: FilesNetworkManagerProtocol { _ id: String, type: String, downloadProgress: @escaping ((Progress) -> Void) - ) async throws -> Data { + ) async -> FileApiServiceResult { guard let netwrokProtocol = NetworkFileProtocolType(rawValue: type) else { - throw FileManagerError.cantDownloadFile + return .failure(.cantDownloadFile) } switch netwrokProtocol { case .ipfs: - return try await ipfsService.downloadFile( + return await ipfsService.downloadFile( id: id, downloadProgress: downloadProgress ) diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift b/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift index 2c5dd9d71..f75a75918 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiCore.swift @@ -9,6 +9,10 @@ import Foundation import CommonKit +extension IPFSApiCommands { + static let status = "/api/node/info" +} + final class IPFSApiCore { let apiCore: APICoreProtocol @@ -16,15 +20,11 @@ final class IPFSApiCore { self.apiCore = apiCore } - func getNodeStatus(node: Node) async -> ApiServiceResult { - await Task.sleep(interval: Double.random(in: 0.1...1)) - return .success(.init( - success: true, - nodeTimestamp: Date().timeIntervalSince1970, - network: nil, - version: nil, - wsClient: nil - )) + func getNodeStatus(node: Node) async -> ApiServiceResult { + await apiCore.sendRequestJsonResponse( + node: node, + path: IPFSApiCommands.status + ) } } diff --git a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift index 84bb9b83c..ece6b084b 100644 --- a/Adamant/Services/FilesNetworkManager/IPFSApiService.swift +++ b/Adamant/Services/FilesNetworkManager/IPFSApiService.swift @@ -37,34 +37,38 @@ final class IPFSApiService: FileApiServiceProtocol { func uploadFile( data: Data, uploadProgress: @escaping ((Progress) -> Void) - ) async throws -> String { + ) async -> FileApiServiceResult { let model: MultipartFormDataModel = .init( keyName: IPFSApiCommands.file.fieldName, fileName: defaultFileName, data: data ) - let result: IpfsDTO = try await request { core, node in + let result: Result = await request { core, node in await core.sendRequestMultipartFormDataJsonResponse( node: node, path: IPFSApiCommands.file.upload, models: [model], uploadProgress: uploadProgress ) - }.get() - - guard let cid = result.cids.first else { - throw FileManagerError.cantUploadFile } - return cid + 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 throws -> Data { - let result: Data = try await request { core, node in + ) async -> FileApiServiceResult { + let result: Result = await request { core, node in let result: APIResponseModel = await core.sendRequest( node: node, path: "\(IPFSApiCommands.file.download)\(id)", @@ -76,9 +80,10 @@ final class IPFSApiService: FileApiServiceProtocol { } return result.result - }.get() + } - return result + return result.flatMap { .success($0) } + .mapError { .apiError(error: $0) } } } diff --git a/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift index 0cba0b7ec..e36259dc5 100644 --- a/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift +++ b/Adamant/Services/FilesNetworkManager/Models/FileManagerError.swift @@ -17,6 +17,7 @@ enum FileManagerError: Error { case cantUploadFile case cantEncryptFile case cantDecryptFile + case apiError(error: ApiServiceError) } extension FileManagerError: LocalizedError { @@ -30,6 +31,8 @@ extension FileManagerError: LocalizedError { return .localized("FileManagerError.CantEncryptFile") case .cantDecryptFile: return .localized("FileManagerError.CantDecryptFile") + case let .apiError(error: error): + return error.localizedDescription } } } 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/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/Models/FileResult.swift b/CommonKit/Sources/CommonKit/Models/FileResult.swift index c039bd07f..7279ac4b0 100644 --- a/CommonKit/Sources/CommonKit/Models/FileResult.swift +++ b/CommonKit/Sources/CommonKit/Models/FileResult.swift @@ -13,7 +13,12 @@ public enum FileType { case other public var isMedia: Bool { - self == FileType.image || self == FileType.video + switch self { + case .image, .video: + return true + case .other: + return false + } } } diff --git a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift index 7de23eeb5..68b439014 100644 --- a/CommonKit/Sources/CommonKit/Models/FileValidationError.swift +++ b/CommonKit/Sources/CommonKit/Models/FileValidationError.swift @@ -27,6 +27,7 @@ public enum FileValidationError: Error { case tooManyFiles case fileSizeExceedsLimit case fileNotFound + case unknownError(Error) } extension FileValidationError: LocalizedError { @@ -44,6 +45,8 @@ extension FileValidationError: LocalizedError { ), Int(FilesConstants.maxFileSize / (1024 * 1024))) case .fileNotFound: return .localized("FileValidationError.FileNotFound") + case let .unknownError(error): + return error.localizedDescription } } } diff --git a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift index 6b66f7661..ec1e77081 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Helpers/FilesPickerKit.swift @@ -17,7 +17,7 @@ public final class FilesPickerKit: FilesPickerProtocol { } public func getFileSize(from url: URL) throws -> Int64 { - try storageKit.getFileSize(from: url) + try storageKit.getFileSize(from: url).get() } public func getUrl(for image: UIImage?, name: String) throws -> URL { @@ -189,7 +189,7 @@ private extension FilesPickerKit { ) throws -> FileResult { let newUrl = try storageKit.copyFileToTempCache(from: url) let preview = getPreview(for: newUrl) - let fileSize = try storageKit.getFileSize(from: newUrl) + let fileSize = try storageKit.getFileSize(from: newUrl).get() let duration = getVideoDuration(from: newUrl) let mimeType = getMimeType(for: newUrl) diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift index 482c7f63d..d310fed80 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentInteractionService.swift @@ -5,7 +5,6 @@ // Created by Stanislav Jelezoglo on 14.03.2024. // -import Foundation import UIKit import CommonKit import SwiftUI diff --git a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift index f84726f8c..afca35821 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Pickers/DocumentPickerService.swift @@ -5,7 +5,6 @@ // Created by Stanislav Jelezoglo on 21.02.2024. // -import Foundation import UIKit import CommonKit import MobileCoreServices diff --git a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift index d9729d1f5..5c59b2b7e 100644 --- a/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift +++ b/FilesPickerKit/Sources/FilesPickerKit/Protocols/FilePickerServiceProtocol.swift @@ -5,7 +5,6 @@ // Created by Stanislav Jelezoglo on 11.02.2024. // -import Foundation import UIKit import CommonKit diff --git a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift index e0a9482d8..bf5944508 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/FilesStorageKit.swift @@ -5,6 +5,8 @@ import CommonKit import UIKit import Combine +public typealias FileStorageServiceResult = Result + public final class FilesStorageKit: FilesStorageProtocol { public struct File { public let id: String @@ -55,16 +57,16 @@ public final class FilesStorageKit: FilesStorageProtocol { cachedFiles[id] != nil } - public func getFile(with id: String) throws -> File { + public func getFile(with id: String) -> FileStorageServiceResult { guard let file = cachedFiles[id] else { - throw FileValidationError.fileNotFound + return .failure(.fileNotFound) } - return file + return .success(file) } - public func getFileURL(with id: String) throws -> URL { - try getFile(with: id).url + public func getFileURL(with id: String) -> FileStorageServiceResult { + getFile(with: id).flatMap { .success($0.url) } } public func cacheFile( @@ -113,15 +115,16 @@ public final class FilesStorageKit: FilesStorageProtocol { ) } - public func getCacheSize() throws -> Int64 { - let url = try FileManager.default.url( + 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 try folderSize(at: url) + return folderSize(at: url) } public func clearCache() throws { @@ -222,7 +225,7 @@ public final class FilesStorageKit: FilesStorageProtocol { return targetURL } - public func getFileSize(from fileURL: URL) throws -> Int64 { + public func getFileSize(from fileURL: URL) -> FileStorageServiceResult { defer { fileURL.stopAccessingSecurityScopedResource() } @@ -235,9 +238,9 @@ public final class FilesStorageKit: FilesStorageProtocol { throw FileValidationError.fileNotFound } - return fileSize + return .success(fileSize) } catch { - throw error + return .failure(.unknownError(error)) } } } @@ -385,15 +388,15 @@ private extension FilesStorageKit { $cachedFiles.mutate { $0[id] = file } } - func folderSize(at url: URL) throws -> Int64 { + func folderSize(at url: URL) -> FileStorageServiceResult { let fileManager = FileManager.default guard fileManager.fileExists(atPath: url.path) else { - throw FileValidationError.fileNotFound + return .failure(.fileNotFound) } guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.totalFileAllocatedSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { - throw FileValidationError.fileNotFound + return .failure(.fileNotFound) } var folderSize: Int64 = 0 @@ -407,7 +410,7 @@ private extension FilesStorageKit { } catch { } } - return folderSize + return .success(folderSize) } func fileNameAndExtension(from url: URL) -> (name: String, extensions: [String]) { diff --git a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift index aab12f2cb..8141b216c 100644 --- a/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift +++ b/FilesStorageKit/Sources/FilesStorageKit/Protocols/FilesStorageProtocol.swift @@ -17,9 +17,9 @@ public protocol FilesStorageProtocol { func isCachedInMemory(_ id: String) -> Bool - func getFileURL(with id: String) throws -> URL + func getFileURL(with id: String) -> FileStorageServiceResult - func getFile(with id: String) throws -> FilesStorageKit.File + func getFile(with id: String) -> FileStorageServiceResult func cacheTemporaryFile( url: URL, @@ -41,7 +41,7 @@ public protocol FilesStorageProtocol { isPreview: Bool ) throws - func getCacheSize() throws -> Int64 + func getCacheSize() -> FileStorageServiceResult func clearCache() throws @@ -53,5 +53,5 @@ public protocol FilesStorageProtocol { func copyFileToTempCache(from url: URL) throws -> URL - func getFileSize(from fileURL: URL) throws -> Int64 + func getFileSize(from fileURL: URL) -> FileStorageServiceResult } From d3ea1bbaf92f2a73ebf3810cccd1ce98f97ca2c5 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Thu, 22 Aug 2024 12:15:25 +0300 Subject: [PATCH 122/123] [trello.com/c/uxBZaznD] code improvements --- Adamant/Modules/Chat/ViewModel/ChatViewModel.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index d912964ad..edc8eae10 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -747,9 +747,7 @@ final class ChatViewModel: NSObject { guard !file.isCached, !filesStorage.isCachedLocally(file.file.id) else { - Task { - self.presentFileInFullScreen(id: file.file.id, chatFiles: chatFiles) - } + self.presentFileInFullScreen(id: file.file.id, chatFiles: chatFiles) return } From 1138d7910c057ec1b0c079aee263879516433f88 Mon Sep 17 00:00:00 2001 From: StanislavDevIOS Date: Fri, 23 Aug 2024 17:24:37 +0300 Subject: [PATCH 123/123] [trello.com/c/QWRNlstX] Release 3.8.0 --- Adamant/Services/DataProviders/AdamantChatsProvider.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index be62fd24f..346873b93 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -1329,14 +1329,6 @@ extension AdamantChatsProvider { do { let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() - print("sendMessageTransaction id=\(id), transaction=\(transaction.height)") -// do { -// await Task.sleep(interval: 10) -// let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() -// print("sendMessageTransaction id=\(id)") -// } catch { -// print("sendMessageTransaction error=\(error)") -// } // Update ID with recieved, add to unconfirmed transactions. transaction.transactionId = String(id) transaction.chatMessageId = String(id)