diff --git a/Adamant/App/AppDelegate.swift b/Adamant/App/AppDelegate.swift index 96abbc238..5371fac4f 100644 --- a/Adamant/App/AppDelegate.swift +++ b/Adamant/App/AppDelegate.swift @@ -766,16 +766,16 @@ private enum TabScreens { private func makeSplitController() -> UISplitViewController { let controller = UISplitViewController() controller.preferredDisplayMode = .oneBesideSecondary - + // Set the default ratio to 1:2 controller.preferredPrimaryColumnWidthFraction = 0.3337 - + let minimumPrimaryColumnWidth: CGFloat = UIScreen.main.bounds.width * 0.2 // Set the minimum ratio to 1:5, or to 300px if 1:5 results in a smaller value controller.minimumPrimaryColumnWidth = minimumPrimaryColumnWidth > 300 ? minimumPrimaryColumnWidth : 300 - + // Set the maximum ratio to 3:1 controller.maximumPrimaryColumnWidth = UIScreen.main.bounds.width * 0.75 - + return controller } diff --git a/Adamant/App/DI/AppAssembly.swift b/Adamant/App/DI/AppAssembly.swift index daae8081a..8b912c763 100644 --- a/Adamant/App/DI/AppAssembly.swift +++ b/Adamant/App/DI/AppAssembly.swift @@ -405,8 +405,9 @@ struct AppAssembly: MainThreadAssembly { adamantCore: r.resolve(AdamantCore.self)!, accountsProvider: r.resolve(AccountsProvider.self)!, transactionService: r.resolve(ChatTransactionService.self)!, - SecureStore: r.resolve(SecureStore.self)!, - walletServiceCompose: r.resolve(WalletServiceCompose.self)! + secureStore: r.resolve(SecureStore.self)!, + walletServiceCompose: r.resolve(WalletServiceCompose.self)!, + timeouts: AdmWalletService.timeouts ) }.inObjectScope(.container) diff --git a/Adamant/Modules/Account/AccountViewController/AccountViewController.swift b/Adamant/Modules/Account/AccountViewController/AccountViewController.swift index 32c1dc3c5..7ea9837cf 100644 --- a/Adamant/Modules/Account/AccountViewController/AccountViewController.swift +++ b/Adamant/Modules/Account/AccountViewController/AccountViewController.swift @@ -90,7 +90,7 @@ final class AccountViewController: FormViewController { }() private var walletViewControllers: [WalletViewController] = [] - + private lazy var currentSelectedWallet: AccountWalletCellState? = { viewModel.state.wallets.first(where: { $0.model.index == 0 }) }() @@ -189,7 +189,7 @@ final class AccountViewController: FormViewController { pagingViewController.indicatorOptions = .visible(height: 2, zIndex: Int.max, spacing: UIEdgeInsets.zero, insets: UIEdgeInsets.zero) pagingViewController.dataSource = self pagingViewController.delegate = self - + accountHeaderView.walletViewContainer.addSubview(pagingViewController.view) pagingViewController.view.snp.makeConstraints { $0.directionalEdges.equalToSuperview() @@ -200,7 +200,7 @@ final class AccountViewController: FormViewController { updatePagingItemHeight() pagingViewController.borderColor = UIColor.clear - + // MARK: Rows&Sections // MARK: Application @@ -963,11 +963,13 @@ final class AccountViewController: FormViewController { @objc private func handleRefresh(_ refreshControl: UIRefreshControl) { guard let currencyNetwork = currentSelectedWallet?.model.currencyNetwork else { return } - - let unavailableNodes: Set = Set(NodeGroup.allCases.filter { - !(apiServiceCompose.get($0)?.hasSupportedNode ?? true) - }) - + + let unavailableNodes: Set = Set( + NodeGroup.allCases.filter { + !(apiServiceCompose.get($0)?.hasSupportedNode ?? true) + } + ) + if unavailableNodes.contains(where: { $0.name == currencyNetwork }) { @@ -977,7 +979,7 @@ final class AccountViewController: FormViewController { ).localizedDescription ) } - + Task { @MainActor in accountService.updateWithRefreshUI() } @@ -1073,7 +1075,7 @@ extension AccountViewController: PagingViewControllerDataSource, PagingViewContr wallet.model.index == pagingItem.identifier }) } - + DispatchQueue.onMainThreadSyncSafe { guard transitionSuccessful, let first = startingViewController as? WalletViewController, @@ -1098,7 +1100,6 @@ extension AccountViewController: PagingViewControllerDataSource, PagingViewContr } } - private func updateHeaderSize(with walletViewController: WalletViewController, animated: Bool) { guard case let .fixed(_, menuHeight) = pagingViewController.menuItemSize else { return diff --git a/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsState.swift b/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsState.swift index 30174ffa2..2b129c029 100644 --- a/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsState.swift +++ b/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsState.swift @@ -11,17 +11,17 @@ import Parchment struct AccountWalletsState { var wallets: [AccountWalletCellState] - + static let `default` = Self(wallets: []) } struct AccountWalletCellState { @ObservableValue var model: WalletCollectionViewCellModel - + init(model: WalletCollectionViewCellModel) { self.model = model } - + static let `default` = Self(model: .default) } @@ -31,14 +31,14 @@ extension AccountWalletCellState: Equatable { } } -extension AccountWalletCellState: PagingItem{ +extension AccountWalletCellState: PagingItem { var identifier: Int { model.index } - + func isBefore(item: PagingItem) -> Bool { guard let other = item as? Self else { return false } return self.model.index < other.model.index } - + func isEqual(to item: PagingItem) -> Bool { guard let other = item as? Self else { return false } return self == other diff --git a/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsViewModel.swift b/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsViewModel.swift index 43f1016ce..ae234a74c 100644 --- a/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsViewModel.swift +++ b/Adamant/Modules/Account/AccountViewController/AccountWallets/AccountWalletsViewModel.swift @@ -13,7 +13,7 @@ import Foundation @MainActor final class AccountWalletsViewModel { var state: AccountWalletsState = .default - + private let walletsStoreService: WalletStoreServiceProviderProtocol private var subscriptions = Set() diff --git a/Adamant/Modules/Account/WalletCollectionViewCell+Model.swift b/Adamant/Modules/Account/WalletCollectionViewCell+Model.swift index a9fe91807..7f08603dd 100644 --- a/Adamant/Modules/Account/WalletCollectionViewCell+Model.swift +++ b/Adamant/Modules/Account/WalletCollectionViewCell+Model.swift @@ -6,8 +6,8 @@ // Copyright © 2025 Adamant. All rights reserved. // -import Parchment import CommonKit +import Parchment import UIKit struct WalletCollectionViewCellModel { @@ -19,7 +19,7 @@ struct WalletCollectionViewCellModel { var isBalanceInitialized: Bool var balance: Decimal? var notificationBadgeCount: Int - + static let `default` = WalletCollectionViewCellModel( index: 0, coinID: "", diff --git a/Adamant/Modules/Account/WalletCollectionViewCell.swift b/Adamant/Modules/Account/WalletCollectionViewCell.swift index b6e01e368..a0c50a3dc 100644 --- a/Adamant/Modules/Account/WalletCollectionViewCell.swift +++ b/Adamant/Modules/Account/WalletCollectionViewCell.swift @@ -17,13 +17,13 @@ class WalletCollectionViewCell: PagingCell { @IBOutlet weak var balanceLabel: UILabel! @IBOutlet weak var currencySymbolLabel: UILabel! @IBOutlet weak var accessoryContainerView: AccessoryContainerView! - + private var cancellables = Set() - + override func prepareForReuse() { cancellables.removeAll() } - + override func setPagingItem( _ pagingItem: PagingItem, selected: Bool, @@ -33,9 +33,9 @@ class WalletCollectionViewCell: PagingCell { return } update(item: item.model) - + cancellables.removeAll() - + item.$model .removeDuplicates() .receive(on: DispatchQueue.main) @@ -46,9 +46,9 @@ class WalletCollectionViewCell: PagingCell { } } -private extension WalletCollectionViewCell { +extension WalletCollectionViewCell { @MainActor - func update(item: WalletCollectionViewCellModel) { + fileprivate func update(item: WalletCollectionViewCellModel) { currencyImageView.image = item.currencyImage if item.currencyNetwork == item.currencySymbol { currencySymbolLabel.text = item.currencySymbol diff --git a/Adamant/Modules/Chat/View/ChatViewController.swift b/Adamant/Modules/Chat/View/ChatViewController.swift index 9c77046d2..2618dcbe0 100644 --- a/Adamant/Modules/Chat/View/ChatViewController.swift +++ b/Adamant/Modules/Chat/View/ChatViewController.swift @@ -190,7 +190,7 @@ final class ChatViewController: MessagesViewController { : chatMessagesCollectionView.bottomOffset ) } - + override func collectionView( _ collectionView: UICollectionView, canPerformAction action: Selector, @@ -199,14 +199,14 @@ final class ChatViewController: MessagesViewController { ) -> Bool { return false } - + override func collectionView( _ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath ) -> Bool { return false } - + override func scrollViewDidEndDecelerating(_: UIScrollView) { scrollDidStop() } @@ -309,7 +309,7 @@ extension ChatViewController { self.viewModel.updatePreviewFor(indexes: indexes) } .store(in: &subscriptions) - + NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification) .sink { [weak self] _ in self?.state.isAppActive = false @@ -936,7 +936,12 @@ extension ChatViewController { } @MainActor - fileprivate func scrollToPosition(_ position: ChatStartPosition, animated: Bool = false, setExtraOffset: Bool = false, scrollAt: UICollectionView.ScrollPosition = .centeredVertically) { + fileprivate func scrollToPosition( + _ position: ChatStartPosition, + animated: Bool = false, + setExtraOffset: Bool = false, + scrollAt: UICollectionView.ScrollPosition = .centeredVertically + ) { chatMessagesCollectionView.fixedBottomOffset = nil switch position { @@ -1268,30 +1273,32 @@ extension ChatViewController { state.isAutoScrolling = false state.isScrollingToBottom = false updateUnreadMessages() - + guard !state.isAnimatingCellHighlight else { return } guard let messageId = viewModel.cellIdForAnimation, - let index = viewModel.messages.firstIndex(where: { $0.messageId == messageId }) else { + let index = viewModel.messages.firstIndex(where: { $0.messageId == messageId }) + else { return } - + let indexPath = IndexPath(item: 0, section: index) animateCell(at: indexPath) } - + private func animateCell(at indexPath: IndexPath) { state.isAnimatingCellHighlight = true - + //0.2 sec delay that all methods that can interrupt the animation have time to execute DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self = self else { return } - + guard self.messagesCollectionView.indexPathsForVisibleItems.contains(indexPath), - let cell = self.messagesCollectionView.cellForItem(at: indexPath) as? ChatCellProtocol else { + let cell = self.messagesCollectionView.cellForItem(at: indexPath) as? ChatCellProtocol + else { self.state.isAnimatingCellHighlight = false return } - + cell.animateMessageHighlight() self.viewModel.shortVibro() self.viewModel.cellIdForAnimation = nil diff --git a/Adamant/Modules/Chat/View/ChatViewControllerState.swift b/Adamant/Modules/Chat/View/ChatViewControllerState.swift index 264f8f423..145af2b8f 100644 --- a/Adamant/Modules/Chat/View/ChatViewControllerState.swift +++ b/Adamant/Modules/Chat/View/ChatViewControllerState.swift @@ -18,7 +18,7 @@ struct ChatViewControllerState { var isAutoScrolling = false var isAppActive = true var isScrollingToBottom = false - + //calculation for animation, might use for something else in the future var isAnimationAllowed: Bool { isMessagesLoaded && !isAutoScrolling && !isScrollingToBottom diff --git a/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift b/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift index 1e6356c46..c86178c6b 100644 --- a/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift +++ b/Adamant/Modules/Chat/View/Helpers/AdamantCellAnimation.swift @@ -34,22 +34,26 @@ extension UIView { layer.masksToBounds = masksToBounds layer.cornerRadius = cornerRadius } - + func animateHighlight( highlightColor: UIColor = UIColor.adamant.active.withAlphaComponent(0.5), duration: TimeInterval = 2.5 ) { let originalColor = self.backgroundColor - - UIView.animate(withDuration: 0.35, animations: { - self.backgroundColor = highlightColor - }, completion: { _ in - UIView.animate(withDuration: duration - 0.35) { - self.backgroundColor = originalColor + + UIView.animate( + withDuration: 0.35, + animations: { + self.backgroundColor = highlightColor + }, + completion: { _ in + UIView.animate(withDuration: duration - 0.35) { + self.backgroundColor = originalColor + } } - }) + ) } - + func animateHighlightOverlay( overlayColor: UIColor = UIColor.adamant.active.withAlphaComponent(0.5) ) { @@ -59,24 +63,30 @@ extension UIView { overlay.isUserInteractionEnabled = false overlay.layer.cornerRadius = layer.cornerRadius overlay.layer.masksToBounds = true - + addSubview(overlay) bringSubviewToFront(overlay) - - UIView.animate(withDuration: 1.5, delay: 0.5, options: [.curveEaseOut], animations: { - overlay.alpha = 0 - }, completion: { _ in - overlay.removeFromSuperview() - }) + + UIView.animate( + withDuration: 1.5, + delay: 0.5, + options: [.curveEaseOut], + animations: { + overlay.alpha = 0 + }, + completion: { _ in + overlay.removeFromSuperview() + } + ) } - + func animatePressDown(duration: TimeInterval = 0.1) { UIView.animate(withDuration: duration) { self.transform = CGAffineTransform(scaleX: 0.96, y: 0.96) self.alpha = 0.5 } } - + func animatePressUp(duration: TimeInterval = 0.1) { UIView.animate(withDuration: duration) { self.transform = .identity diff --git a/Adamant/Modules/Chat/View/Helpers/ChatReactionsView.swift b/Adamant/Modules/Chat/View/Helpers/ChatReactionsView.swift index 7fb0bbb88..900784b3b 100644 --- a/Adamant/Modules/Chat/View/Helpers/ChatReactionsView.swift +++ b/Adamant/Modules/Chat/View/Helpers/ChatReactionsView.swift @@ -23,7 +23,7 @@ struct ChatReactionsView: View { var didSelectEmoji: ((_ emoji: String, _ messageId: String) -> Void)? var didSelectMore: (() -> Void)? - + @State private var isPlusHovered = false init( @@ -63,8 +63,8 @@ struct ChatReactionsView: View { .frame(width: 30, height: 30) .background( isPlusHovered - ? Color.init(uiColor: .adamant.contextMenuSelectColor) - : Color.init(uiColor: .adamant.moreReactionsBackground) + ? Color.init(uiColor: .adamant.contextMenuSelectColor) + : Color.init(uiColor: .adamant.moreReactionsBackground) ) .clipShape(Circle()) .scaleEffect(isPlusHovered ? 1.15 : 1.0) @@ -73,7 +73,7 @@ struct ChatReactionsView: View { } .animation(.easeInOut(duration: 0.2), value: isPlusHovered) .padding([.top, .bottom], 5) - + Spacer() } .padding(.leading, 5) @@ -85,15 +85,17 @@ struct ChatReactionsView: View { struct ChatReactionButton: View { let emoji: String let isSelected: Bool - + @State private var isHovered = false var body: some View { Text(emoji) .font(.title) .frame(width: 40, height: 40) - .background( isHovered ? Color.init(uiColor: .adamant.contextMenuSelectColor) : - (isSelected ? Color.init(uiColor: .gray.withAlphaComponent(0.75)) : Color.clear)) + .background( + isHovered + ? Color.init(uiColor: .adamant.contextMenuSelectColor) : (isSelected ? Color.init(uiColor: .gray.withAlphaComponent(0.75)) : Color.clear) + ) .clipShape(Circle()) .onHover { hovering in isHovered = hovering diff --git a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift index 306c79cd3..43a70bc87 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatBaseMessage/ChatMessageCell.swift @@ -87,7 +87,7 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { layoutReactionLabel() } } - + var copyNotification: (() -> Void)? var reactionsContanerViewWidth: CGFloat { @@ -156,7 +156,7 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { func configureMenu() { containerView.layer.cornerRadius = 10 - + configureLongPressGesture() messageContainerView.removeFromSuperview() @@ -165,14 +165,14 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { chatMenuManager.setup(for: containerView) } - + private func configureLongPressGesture() { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressToCopy(_:))) longPress.minimumPressDuration = 0.2 messageContainerView.addGestureRecognizer(longPress) messageContainerView.isUserInteractionEnabled = true } - + func updateOwnReaction() { ownReactionLabel.text = getReaction(for: model.address) ownReactionLabel.backgroundColor = .adamant.pickedReactionBackground @@ -437,8 +437,8 @@ final class ChatMessageCell: TextMessageCell, ChatModelView { } } -private extension ChatMessageCell { - func makeContextMenu() -> AMenuSection { +extension ChatMessageCell { + fileprivate func makeContextMenu() -> AMenuSection { let remove = AMenuItem.action( title: .adamant.chat.remove, systemImageName: "trash", @@ -482,15 +482,15 @@ private extension ChatMessageCell { return AMenuSection([reply, copyInPart, copy, report, remove]) } - @objc func tapReactionAction() { + @objc fileprivate func tapReactionAction() { chatMenuManager.presentMenuProgrammatically(for: containerView) } - + @objc private func handleLongPressToCopy(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: messageContainerView.animatePressDown() - + case .ended: messageContainerView.animatePressUp() UIPasteboard.general.string = model.text.string diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift index e73163502..f81878d6e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/ChatMediaCell.swift @@ -97,26 +97,26 @@ extension ChatMediaCell { } configureLongPressGesture() } - + private func configureLongPressGesture() { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressToCopy(_:))) longPress.minimumPressDuration = 0.2 cellContainerView.addGestureRecognizer(longPress) cellContainerView.isUserInteractionEnabled = true } - + @objc private func handleLongPressToCopy(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: cellContainerView.animatePressDown() - + case .ended: cellContainerView.animatePressUp() if model.content.comment.string != "" { UIPasteboard.general.string = model.content.comment.string copyNotification?() } - + case .cancelled, .failed: cellContainerView.animatePressUp() 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 8a1c3fb60..369a8f66c 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView+Model.swift @@ -41,9 +41,14 @@ extension ChatMediaContainerView { }.count let otherFilesCount = content.fileModel.files.count - mediaFilesCount - - let result = FilePresentationHelper.getFilePresentationText(mediaFilesCount: mediaFilesCount, otherFilesCount: otherFilesCount, comment: content.comment.string, parsedWith: ChatMessageFactory.markdownParser) - + + let result = FilePresentationHelper.getFilePresentationText( + mediaFilesCount: mediaFilesCount, + otherFilesCount: otherFilesCount, + comment: content.comment.string, + parsedWith: ChatMessageFactory.markdownParser + ) + return result } } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift index 93ed8296f..a73fc8187 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatMedia/Container/ChatMediaContainerView.swift @@ -244,7 +244,7 @@ extension ChatMediaContainerView { @objc func tapReactionAction() { chatMenuManager.presentMenuProgrammatically(for: contentView) } - + func animateMediaHighlight() { contentView.animateHighlightOverlay() } diff --git a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift index 391529b2b..d6037775e 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatReply/ChatMessageReplyCell.swift @@ -150,7 +150,7 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { } } var copyNotification: (() -> Void)? - + var reactionsContanerViewWidth: CGFloat { if getReaction(for: model.address) == nil && getReaction(for: model.opponentAddress) == nil { return .zero @@ -233,7 +233,7 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { cellContainerView.addSubview(reactionsContanerView) } - + func configureReplyMessageGesture() { let tap = UITapGestureRecognizer(target: self, action: #selector(handleReplyTap)) replyMessageLabel.isUserInteractionEnabled = true @@ -241,7 +241,7 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { messageContainerView.isUserInteractionEnabled = true replyMessageLabel.addGestureRecognizer(tap) } - + func configureMenu() { containerView.layer.cornerRadius = 10 @@ -535,8 +535,8 @@ final class ChatMessageReplyCell: MessageContentCell, ChatModelView { } } -private extension ChatMessageReplyCell { - func makeContextMenu() -> AMenuSection { +extension ChatMessageReplyCell { + fileprivate func makeContextMenu() -> AMenuSection { let remove = AMenuItem.action( title: .adamant.chat.remove, systemImageName: "trash", @@ -576,29 +576,29 @@ private extension ChatMessageReplyCell { return AMenuSection([reply, copyInPart, copy, report, remove]) } - @objc func tapReactionAction() { + @objc fileprivate func tapReactionAction() { chatMenuManager.presentMenuProgrammatically(for: containerView) } - - @objc func handleReplyTap() { + + @objc fileprivate func handleReplyTap() { actionHandler(.scrollTo(message: model)) } - + @objc private func handleLongPressToCopy(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: messageContainerView.animatePressDown() - + case .ended: messageContainerView.animatePressUp() if model.message.string != "" { UIPasteboard.general.string = model.message.string copyNotification?() } - + case .cancelled, .failed: messageContainerView.animatePressUp() - + default: break } @@ -671,7 +671,7 @@ extension ChatMessageReplyCell { ) return cell } - + func configureLongPressGesture() { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressToCopy(_:))) longPress.minimumPressDuration = 0.2 diff --git a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift index d6b995fec..e35d2e913 100644 --- a/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift +++ b/Adamant/Modules/Chat/View/Subviews/ChatTransaction/Container/ChatTransactionContainerView.swift @@ -27,7 +27,7 @@ final class ChatTransactionContainerView: UIView { var actionHandler: (ChatAction) -> Void = { _ in } { didSet { contentView.actionHandler = actionHandler } } - + var copyNotification: (() -> Void)? private let contentView = ChatTransactionContentView() @@ -157,7 +157,7 @@ extension ChatTransactionContainerView { chatMenuManager.setup(for: contentView) configureLongPressGesture() } - + fileprivate func configureLongPressGesture() { let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressToCopy(_:))) longPress.minimumPressDuration = 0.2 @@ -197,22 +197,22 @@ extension ChatTransactionContainerView { @objc fileprivate func onStatusButtonTap() { actionHandler(.forceUpdateTransactionStatus(id: model.id)) } - + @objc fileprivate func handleLongPressToCopy(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: contentView.animatePressDown() - + case .ended: contentView.animatePressUp() if let comment = model.content.comment, !comment.isEmpty { UIPasteboard.general.string = comment copyNotification?() } - + case .cancelled, .failed: contentView.animatePressUp() - + default: break } @@ -322,19 +322,19 @@ extension ChatTransactionContainerView { ) { [actionHandler, model] in actionHandler(.reply(id: model.id)) } - + let copy = AMenuItem.action( title: .adamant.chat.copy, systemImageName: "doc.on.doc" ) { [actionHandler, model] in actionHandler(.copy(text: model.content.comment ?? "")) } - + let actions: [AMenuItem] = - model.content.comment == nil || model.content.comment == "" + model.content.comment == nil || model.content.comment == "" ? [reply, report, remove] : [reply, copy, report, remove] - + return AMenuSection(actions) } } diff --git a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift index 5a7da6de0..dab4d25fa 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatMessageFactory.swift @@ -219,7 +219,11 @@ extension ChatMessageFactory { let replyMessage = makeAttributed(replyMessageRaw) let decodedMessage = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." - let decodedMessageMarkDown = FilePresentationHelper.getFilePresentationText(from: decodedMessage, parsedWith: Self.markdownReplyParser, resolveLinkColor: true) + let decodedMessageMarkDown = FilePresentationHelper.getFilePresentationText( + from: decodedMessage, + parsedWith: Self.markdownReplyParser, + resolveLinkColor: true + ) let reactions = transaction.richContent?[RichContentKeys.react.reactions] as? Set let address = @@ -381,29 +385,30 @@ extension ChatMessageFactory { ) ) } - + func makeAttributed(_ text: String) -> NSMutableAttributedString { let attributedString = FilePresentationHelper.getFilePresentationText( from: text, parsedWith: Self.markdownParser, resolveLinkColor: false ) - + let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = lineSpacing - + attributedString.addAttribute( .paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attributedString.length) ) - + return attributedString } - + func decodeMessage(_ transaction: RichMessageTransaction) -> NSMutableAttributedString { let decoded = transaction.getRichValue(for: RichContentKeys.reply.decodedReplyMessage) ?? "..." - return FilePresentationHelper + return + FilePresentationHelper .getFilePresentationText( from: decoded, parsedWith: Self.markdownReplyParser, @@ -579,7 +584,8 @@ extension ChatMessageFactory { fileprivate func checkTransactionForUnreadReaction(transaction: ChatTransaction) -> Bool { if let richTransactions = transaction.richMessageTransactions, - !richTransactions.isEmpty { + !richTransactions.isEmpty + { return richTransactions.contains { $0.isUnread } } @@ -611,8 +617,8 @@ extension ChatSender { private let lineSpacing: CGFloat = 1.15 -private extension FilePresentationHelper { - static func getFilePresentationText( +extension FilePresentationHelper { + fileprivate static func getFilePresentationText( from string: String, parsedWith parser: MarkdownParser? = nil, resolveLinkColor: Bool = false @@ -620,37 +626,37 @@ private extension FilePresentationHelper { guard let parser = parser else { return NSMutableAttributedString(string: string) } - + let (emojiPrefix, markdownBody) = extractEmojiPrefixAndBody(from: string) - + var parsedEmodji = parser.parse(emojiPrefix) var parsedComment = parser.parse(markdownBody) - + if resolveLinkColor { parsedEmodji = parsedEmodji.resolveLinkColor() parsedComment = parsedComment.resolveLinkColor() } - + let result = NSMutableAttributedString() result.append(parsedEmodji) result.append(parsedComment) return result } - - static func extractEmojiPrefixAndBody(from string: String) -> (String, String) { + + fileprivate static func extractEmojiPrefixAndBody(from string: String) -> (String, String) { let pattern = #"^((?:[📸📄]\d*)+)"# let regex = try! NSRegularExpression(pattern: pattern) let nsrange = NSRange(string.startIndex.. 0 + match.range.length > 0 else { return ("", string) } - + let prefix = (string as NSString).substring(with: match.range) let body = (string as NSString).substring(from: match.range.length) - + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) return (prefix, trimmedBody) } diff --git a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift index 7e995d793..5e3279cfe 100644 --- a/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift +++ b/Adamant/Modules/Chat/ViewModel/ChatViewModel.swift @@ -261,7 +261,7 @@ final class ChatViewModel: NSObject { dialog.send(.freeTokenAlert) } } - + func presentKeyboardOnStartIfNeeded() { guard !inputText.isEmpty @@ -490,9 +490,9 @@ final class ChatViewModel: NSObject { Task { guard let transaction = chatTransactions.first(where: { $0.chatMessageId == id }) else { return } - + let message = messages.first(where: { $0.messageId == id }) - + if case let .file(model) = message?.content { try? await chatFileService.resendMessage( with: id, @@ -503,21 +503,21 @@ final class ChatViewModel: NSObject { ) return } - + do { try await chatsProvider.retrySendMessage(transaction) } catch { switch error as? ChatsProviderError { - case .invalidTransactionStatus: - break - case let .serverError(serverError): - switch serverError { - case .timestampIsInTheFuture: - dialog.send(.timestampIsInTheFuture) - default: dialog.send(.warning(error.localizedDescription)) - } - default: - dialog.send(.richError(error)) + case .invalidTransactionStatus: + break + case let .serverError(serverError): + switch serverError { + case .timestampIsInTheFuture: + dialog.send(.timestampIsInTheFuture) + default: dialog.send(.warning(error.localizedDescription)) + } + default: + dialog.send(.richError(error)) } } }.stored(in: tasksStorage) @@ -545,7 +545,7 @@ final class ChatViewModel: NSObject { await waitForMessage(withId: messageId) scrollToIdAndPosition = messageId - + dialog.send(.progress(false)) if let index = messages.firstIndex(where: { $0.id == messageId }) { markMessageAsRead(index: index) @@ -1085,7 +1085,7 @@ extension ChatViewModel { func unredMessageCount() -> Int? { unreadMessagesIds?.count } - + func shortVibro() { vibroService.applyVibration(.rigid) } @@ -1259,15 +1259,17 @@ extension ChatViewModel { .sink { [weak self] in self?.updateTransactions(performFetch: false) } .store(in: &subscriptions) } - + fileprivate func makeStartPosition() { guard messageIdToShow == nil, - let address = chatroom?.partner?.address else { + let address = chatroom?.partner?.address + else { startPosition = nil return } - startPosition = chatsProvider + startPosition = + chatsProvider .getChatPositon(for: address) .map { .offset(.init($0)) } } @@ -1489,24 +1491,24 @@ extension ChatViewModel { 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 - } - case let .serverError(error): - switch error { - case .timestampIsInTheFuture: - dialog.send(.timestampIsInTheFuture) - default: dialog.send(.warning(error.localizedDescription)) - } - case .accountNotFound, .accountNotInitiated, .dependencyError, .internalError, .networkError, .notLogged, .requestCancelled, .transactionNotFound, - .invalidTransactionStatus, .none: - break + case .messageNotValid: + inputText = sentText + case .notEnoughMoneyToSend: + inputText = sentText + self.filesPicked = filesPicked + guard await transfersProvider.hasTransactions else { + dialog.send(.freeTokenAlert) + return + } + case let .serverError(error): + switch error { + case .timestampIsInTheFuture: + dialog.send(.timestampIsInTheFuture) + default: dialog.send(.warning(error.localizedDescription)) + } + case .accountNotFound, .accountNotInitiated, .dependencyError, .internalError, .networkError, .notLogged, .requestCancelled, .transactionNotFound, + .invalidTransactionStatus, .none: + break } } diff --git a/Adamant/Modules/ChatsList/ChatListViewController.swift b/Adamant/Modules/ChatsList/ChatListViewController.swift index 5e8b2f92a..165ff5b37 100644 --- a/Adamant/Modules/ChatsList/ChatListViewController.swift +++ b/Adamant/Modules/ChatsList/ChatListViewController.swift @@ -609,7 +609,7 @@ extension ChatListViewController: UITableViewDelegate, UITableViewDataSource { let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top scrollUpButton.isHidden = offsetY < cellHeight * 0.75 } - + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { guard scrollView.contentOffset.y <= 0, scrollView.contentOffset.y < -100 else { return } @@ -620,11 +620,11 @@ extension ChatListViewController: UITableViewDelegate, UITableViewDataSource { } } } - + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isScrolling = true } - + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { isScrolling = false } @@ -1005,135 +1005,148 @@ extension ChatListViewController { let buyAndSellVC = screensFactory.makeBuyAndSell() navigationController?.pushViewController(buyAndSellVC, animated: true) } - + private func shortDescription(for transaction: ChatTransaction) -> NSAttributedString? { switch transaction { - case let message as MessageTransaction: - guard var text = message.message else { - return nil - } - text = MessageProcessHelper.process(text) - - var attributedText = markdownParser.parse(text).resolveLinkColor() - attributedText = MessageProcessHelper.process(attributedText: attributedText) - - if message.isOutgoing { - let prefix = markdownParser.parse("\(String.adamant.chatList.sentMessagePrefix)") - attributedText.insert(prefix, at: 0) - } - - return attributedText - - case let transfer as TransferTransaction: - if let admService = walletServiceCompose.getWallet( - by: AdmWalletService.richMessageType - )?.core as? AdmWalletService { - return markdownParser.parse(admService.shortDescription(for: transfer)) - } - + case let message as MessageTransaction: + guard var text = message.message else { return nil - case let richMessage as RichMessageTransaction: - if let type = richMessage.richType, - let provider = walletServiceCompose.getWallet(by: type) { - return provider.core.shortDescription(for: richMessage) - } - - if richMessage.additionalType == .reaction, - let content = richMessage.richContent, - let reaction = content[RichContentKeys.react.react_message] as? String { - let prefix = richMessage.isOutgoing + } + text = MessageProcessHelper.process(text) + + var attributedText = markdownParser.parse(text).resolveLinkColor() + attributedText = MessageProcessHelper.process(attributedText: attributedText) + + if message.isOutgoing { + let prefix = markdownParser.parse("\(String.adamant.chatList.sentMessagePrefix)") + attributedText.insert(prefix, at: 0) + } + + return attributedText + + case let transfer as TransferTransaction: + if let admService = walletServiceCompose.getWallet( + by: AdmWalletService.richMessageType + )?.core as? AdmWalletService { + return markdownParser.parse(admService.shortDescription(for: transfer)) + } + + return nil + case let richMessage as RichMessageTransaction: + if let type = richMessage.richType, + let provider = walletServiceCompose.getWallet(by: type) + { + return provider.core.shortDescription(for: richMessage) + } + + if richMessage.additionalType == .reaction, + let content = richMessage.richContent, + let reaction = content[RichContentKeys.react.react_message] as? String + { + let prefix = + richMessage.isOutgoing ? "\(String.adamant.chatList.sentMessagePrefix)" : "" - - let text = reaction.isEmpty + + let text = + reaction.isEmpty ? NSMutableAttributedString(string: "\(prefix)\(String.adamant.chatList.removedReaction) \(reaction)") : NSMutableAttributedString(string: "\(prefix)\(String.adamant.chatList.reacted) \(reaction)") - - return text + + return text + } + + if [.file, .reply].contains(richMessage.additionalType), + let rawContent = richMessage.richContent + { + + let youPrefixText = richMessage.isOutgoing ? String.adamant.chatList.sentMessagePrefix : "" + + if richMessage.additionalType == .reply, + let replyText = rawContent[RichContentKeys.reply.replyMessage] as? String + { + return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: replyText) } - - if [.file, .reply].contains(richMessage.additionalType), - let rawContent = richMessage.richContent { - - let youPrefixText = richMessage.isOutgoing ? String.adamant.chatList.sentMessagePrefix : "" - - if richMessage.additionalType == .reply, - let replyText = rawContent[RichContentKeys.reply.replyMessage] as? String { - return getRawReplyPresentation(isOutgoing: richMessage.isOutgoing, text: replyText) - } - - let result = NSMutableAttributedString(attributedString: markdownParser.parse(youPrefixText)) - - let replyAttributedText = richMessage.additionalType == .reply + + let result = NSMutableAttributedString(attributedString: markdownParser.parse(youPrefixText)) + + let replyAttributedText = + richMessage.additionalType == .reply ? makeReplayNSAttributedString() : NSAttributedString() - - result.append(replyAttributedText) - - let content = (rawContent[RichContentKeys.reply.replyMessage] as? [String: Any]) ?? rawContent - - let text = FilePresentationHelper.getFilePresentationText(content, parsedWith: markdownParser, resolveLinkColor: true) - - result.append(text) - - let formattedResult = MessageProcessHelper.process(attributedText: result) - - return formattedResult - } - - if let serialized = richMessage.serializedMessage() { - return NSAttributedString(string: serialized) - } - - return nil - - default: - return nil + + result.append(replyAttributedText) + + let content = (rawContent[RichContentKeys.reply.replyMessage] as? [String: Any]) ?? rawContent + + let text = FilePresentationHelper.getFilePresentationText(content, parsedWith: markdownParser, resolveLinkColor: true) + + result.append(text) + + let formattedResult = MessageProcessHelper.process(attributedText: result) + + return formattedResult + } + + if let serialized = richMessage.serializedMessage() { + return NSAttributedString(string: serialized) + } + + return nil + + default: + return nil } } - + private func shortDescription(for address: String) -> NSAttributedString? { var descriptionParts: [NSAttributedString] = [] - + if let preservedMessage = chatPreservation.getPreservedMessageFor(address: address) { var attributedText = markdownParser.parse(preservedMessage).resolveLinkColor() attributedText = MessageProcessHelper.process(attributedText: attributedText) descriptionParts.append(attributedText) } - + if let files = chatPreservation.getPreservedFiles(for: address), !files.isEmpty { let mediaCount = files.count(where: { $0.type.isMedia }) let otherCount = files.count(where: { !$0.type.isMedia }) - - let text = FilePresentationHelper.getFilePresentationText(mediaFilesCount: mediaCount, otherFilesCount: otherCount, comment: "", parsedWith: markdownParser, resolveLinkColor: true) - + + let text = FilePresentationHelper.getFilePresentationText( + mediaFilesCount: mediaCount, + otherFilesCount: otherCount, + comment: "", + parsedWith: markdownParser, + resolveLinkColor: true + ) + descriptionParts.insert(text, at: 0) } - + guard descriptionParts.contains(where: { !$0.string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty }) else { return nil } - + if chatPreservation.getReplyMessage(address: address) != nil { 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) - + descriptionParts.insert(NSAttributedString(attachment: replyImageAttachment), at: 0) } - + let draftPrefix = NSMutableAttributedString(string: "✏️: ") descriptionParts.insert(draftPrefix, at: 0) - + let result: NSMutableAttributedString = .init(string: "") for (index, part) in descriptionParts.enumerated() { if index > 0 { result.append(NSAttributedString(string: " ")) } result.append(part) } - + return result } - + private func getRawReplyPresentation(isOutgoing: Bool, text: String) -> NSMutableAttributedString { let prefix = isOutgoing @@ -1155,10 +1168,10 @@ extension ChatListViewController { let extraSpace = isOutgoing ? " " : "" let imageString = NSAttributedString(attachment: replyImageAttachment) - + let markDownText = markdownParser.parse("\(text)").resolveLinkColor() markDownText.insert(NSAttributedString(string: extraSpace), at: 0) - + let fullString = NSMutableAttributedString(string: prefix) if isOutgoing { fullString.append(imageString) @@ -1167,23 +1180,23 @@ extension ChatListViewController { return MessageProcessHelper.process(attributedText: fullString) } - + private func makeReplayNSAttributedString() -> NSMutableAttributedString { 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 fullString = NSMutableAttributedString(attachment: replyImageAttachment) - + return fullString } } diff --git a/Adamant/Modules/ChatsList/ComplexTransferViewController.swift b/Adamant/Modules/ChatsList/ComplexTransferViewController.swift index fb9e1a125..205628c1b 100644 --- a/Adamant/Modules/ChatsList/ComplexTransferViewController.swift +++ b/Adamant/Modules/ChatsList/ComplexTransferViewController.swift @@ -220,7 +220,7 @@ extension ComplexTransferViewController: PagingViewControllerDataSource { }) { network = type(of: service).tokenNetworkSymbol } - + let item = WalletCollectionViewCellModel( index: index, coinID: service.tokenUniqueID, @@ -231,7 +231,7 @@ extension ComplexTransferViewController: PagingViewControllerDataSource { balance: wallet.balance, notificationBadgeCount: 0 ) - + let model = AccountWalletCellState(model: item) return model } diff --git a/Adamant/Modules/Delegates/DelegatesListViewController.swift b/Adamant/Modules/Delegates/DelegatesListViewController.swift index fcffeccbc..7ed2838d8 100644 --- a/Adamant/Modules/Delegates/DelegatesListViewController.swift +++ b/Adamant/Modules/Delegates/DelegatesListViewController.swift @@ -444,7 +444,7 @@ extension DelegatesListViewController { let totalVotesColor = totalVoted > maxTotalVotes ? UIColor.adamant.attention : UIColor.adamant.primary DispatchQueue.onMainAsync { [self] in - bottomPanel.model = .init( + bottomPanel.model = DelegatesBottomPanel.Model( upvotes: upvoted, downvotes: downvoted, new: (changes.count, maxVotes), diff --git a/Adamant/Modules/Login/LoginViewController.swift b/Adamant/Modules/Login/LoginViewController.swift index 4e89526d0..18c359efb 100644 --- a/Adamant/Modules/Login/LoginViewController.swift +++ b/Adamant/Modules/Login/LoginViewController.swift @@ -247,210 +247,210 @@ final class LoginViewController: FormViewController { return result } - - private func setupSections(){ + + private func setupSections() { // MARK: Login section form - +++ Section(Sections.login.localized) { - $0.tag = Sections.login.tag - - $0.footer = { [weak self] in - var footer = HeaderFooterView( - .callback { - let view = ButtonsStripeView.adamantConfigured() - - var stripe: [StripeButtonType] = [.qrCameraReader, .qrPhotoReader] - - if let accountService = self?.accountService, - accountService.hasStayInAccount - { - stripe.append(.pinpad) - if accountService.useBiometry, - let button = self?.localAuth.biometryType.stripeButtonType + +++ Section(Sections.login.localized) { + $0.tag = Sections.login.tag + + $0.footer = { [weak self] in + var footer = HeaderFooterView( + .callback { + let view = ButtonsStripeView.adamantConfigured() + + var stripe: [StripeButtonType] = [.qrCameraReader, .qrPhotoReader] + + if let accountService = self?.accountService, + accountService.hasStayInAccount { - stripe.append(button) + stripe.append(.pinpad) + if accountService.useBiometry, + let button = self?.localAuth.biometryType.stripeButtonType + { + stripe.append(button) + } } + + view.stripe = stripe + view.delegate = self + + return view } - - view.stripe = stripe - view.delegate = self - - return view + ) + + footer.height = { ButtonsStripeView.adamantDefaultHeight } + + return footer + }() + } + + // Passphrase row + <<< PasteInterceptingPasswordRow { + $0.tag = Rows.passphrase.tag + $0.placeholder = Rows.passphrase.localized + $0.placeholderColor = UIColor.adamant.secondary + $0.cell._textField.pasteInterceptor = { [weak self] text in + if let text { + self?.loginWith(passphrase: text) } - ) - - footer.height = { ButtonsStripeView.adamantDefaultHeight } - - return footer - }() - } - - // Passphrase row - <<< PasteInterceptingPasswordRow { - $0.tag = Rows.passphrase.tag - $0.placeholder = Rows.passphrase.localized - $0.placeholderColor = UIColor.adamant.secondary - $0.cell._textField.pasteInterceptor = { [weak self] text in - if let text { - self?.loginWith(passphrase: text) } + $0.cell.textField.enablePasteButtonAndPasswordToggle { [weak self] in + // assing text to textfield like this to make loginButton update its enabled/disabled state + let row = self?.form.rowBy(tag: Rows.passphrase.tag) as? PasteInterceptingPasswordRow + row?.value = $0 + row?.cell.textField.text = $0 + self?.loginWith(passphrase: $0) + } + $0.keyboardReturnType = KeyboardReturnTypeConfiguration(nextKeyboardType: .go, defaultKeyboardType: .go) } - $0.cell.textField.enablePasteButtonAndPasswordToggle { [weak self] in - // assing text to textfield like this to make loginButton update its enabled/disabled state - let row = self?.form.rowBy(tag: Rows.passphrase.tag) as? PasteInterceptingPasswordRow - row?.value = $0 - row?.cell.textField.text = $0 - self?.loginWith(passphrase: $0) - } - $0.keyboardReturnType = KeyboardReturnTypeConfiguration(nextKeyboardType: .go, defaultKeyboardType: .go) - } - - // Login with passphrase row - <<< ButtonRow { - $0.tag = Rows.loginButton.tag - $0.title = Rows.loginButton.localized - $0.disabled = Condition.function( - [Rows.passphrase.tag], - { form -> Bool in - guard let row: PasteInterceptingPasswordRow = form.rowBy(tag: Rows.passphrase.tag), row.value != nil else { - return true + + // Login with passphrase row + <<< ButtonRow { + $0.tag = Rows.loginButton.tag + $0.title = Rows.loginButton.localized + $0.disabled = Condition.function( + [Rows.passphrase.tag], + { form -> Bool in + guard let row: PasteInterceptingPasswordRow = form.rowBy(tag: Rows.passphrase.tag), row.value != nil else { + return true + } + return false } - return false + ) + }.onCellSelection { [weak self] (_, _) in + guard let row: PasteInterceptingPasswordRow = self?.form.rowBy(tag: Rows.passphrase.tag), + let passphrase = row.value + else { + return } - ) - }.onCellSelection { [weak self] (_, _) in - guard let row: PasteInterceptingPasswordRow = self?.form.rowBy(tag: Rows.passphrase.tag), - let passphrase = row.value - else { - return + + self?.loginWith(passphrase: passphrase) } - - self?.loginWith(passphrase: passphrase) - } - + // MARK: New account section form - +++ Section(Sections.newAccount.localized) { - $0.tag = Sections.newAccount.tag - } - - // Alert - <<< TextAreaRow { - $0.tag = Rows.saveYourPassphraseAlert.tag - $0.textAreaHeight = .dynamic(initialTextViewHeight: 44) - $0.hidden = Condition.function( - [], - { [weak self] _ -> Bool in - return self?.hideNewPassphrase ?? false - } - ) - }.cellUpdate { (cell, _) in - cell.textView.textAlignment = .center - cell.textView.isSelectable = false - cell.textView.isEditable = false - - let parser = MarkdownParser(font: UIFont.systemFont(ofSize: UIFont.systemFontSize), color: UIColor.adamant.primary) - - let style = NSMutableParagraphStyle() - style.alignment = NSTextAlignment.center - - let mutableText = NSMutableAttributedString(attributedString: parser.parse(Rows.saveYourPassphraseAlert.localized)) - mutableText.addAttribute(NSAttributedString.Key.paragraphStyle, value: style, range: NSRange(location: 0, length: mutableText.length)) - - cell.textView.attributedText = mutableText - } - - // New genegated passphrase - <<< PassphraseRow { - $0.tag = Rows.newPassphrase.tag - $0.cell.tip = Rows.tapToSaveHint.localized - $0.cell.height = { 96.0 } - $0.hidden = Condition.function( - [], - { [weak self] _ -> Bool in - return self?.hideNewPassphrase ?? true - } - ) - }.cellUpdate({ (cell, _) in - cell.passphraseLabel.font = UIFont.systemFont(ofSize: 19) - cell.passphraseLabel.textColor = UIColor.adamant.primary - cell.passphraseLabel.textAlignment = .center - - cell.tipLabel.font = UIFont.systemFont(ofSize: 12) - cell.tipLabel.textColor = UIColor.adamant.secondary - cell.tipLabel.textAlignment = .center - }).onCellSelection({ [weak self] (cell, row) in - guard let passphrase = row.value, let dialogService = self?.dialogService else { - return + +++ Section(Sections.newAccount.localized) { + $0.tag = Sections.newAccount.tag } - - if let indexPath = row.indexPath, let tableView = self?.tableView { - tableView.deselectRow(at: indexPath, animated: true) + + // Alert + <<< TextAreaRow { + $0.tag = Rows.saveYourPassphraseAlert.tag + $0.textAreaHeight = .dynamic(initialTextViewHeight: 44) + $0.hidden = Condition.function( + [], + { [weak self] _ -> Bool in + return self?.hideNewPassphrase ?? false + } + ) + }.cellUpdate { (cell, _) in + cell.textView.textAlignment = .center + cell.textView.isSelectable = false + cell.textView.isEditable = false + + let parser = MarkdownParser(font: UIFont.systemFont(ofSize: UIFont.systemFontSize), color: UIColor.adamant.primary) + + let style = NSMutableParagraphStyle() + style.alignment = NSTextAlignment.center + + let mutableText = NSMutableAttributedString(attributedString: parser.parse(Rows.saveYourPassphraseAlert.localized)) + mutableText.addAttribute(NSAttributedString.Key.paragraphStyle, value: style, range: NSRange(location: 0, length: mutableText.length)) + + cell.textView.attributedText = mutableText } - - let encodedPassphrase = AdamantUriTools.encode(request: AdamantUri.passphrase(passphrase: passphrase)) - - let didSelectAction: ((ShareType) -> Void)? = { [weak self] type in - guard case .copyToPasteboard = type else { + + // New genegated passphrase + <<< PassphraseRow { + $0.tag = Rows.newPassphrase.tag + $0.cell.tip = Rows.tapToSaveHint.localized + $0.cell.height = { 96.0 } + $0.hidden = Condition.function( + [], + { [weak self] _ -> Bool in + return self?.hideNewPassphrase ?? true + } + ) + }.cellUpdate({ (cell, _) in + cell.passphraseLabel.font = UIFont.systemFont(ofSize: 19) + cell.passphraseLabel.textColor = UIColor.adamant.primary + cell.passphraseLabel.textAlignment = .center + + cell.tipLabel.font = UIFont.systemFont(ofSize: 12) + cell.tipLabel.textColor = UIColor.adamant.secondary + cell.tipLabel.textAlignment = .center + }).onCellSelection({ [weak self] (cell, row) in + guard let passphrase = row.value, let dialogService = self?.dialogService else { return } - - Task { @MainActor in - self?.tableView.scrollToBottom(animated: true) + + if let indexPath = row.indexPath, let tableView = self?.tableView { + tableView.deselectRow(at: indexPath, animated: true) + } + + let encodedPassphrase = AdamantUriTools.encode(request: AdamantUri.passphrase(passphrase: passphrase)) + + let didSelectAction: ((ShareType) -> Void)? = { [weak self] type in + guard case .copyToPasteboard = type else { + return + } + + Task { @MainActor in + self?.tableView.scrollToBottom(animated: true) + } } + + dialogService.presentShareAlertFor( + string: passphrase, + types: [.copyToPasteboard, .share, .generateQr(encodedContent: encodedPassphrase, sharingTip: nil, withLogo: false)], + excludedActivityTypes: ShareContentType.passphrase.excludedActivityTypes, + animated: true, + from: nil, + completion: nil, + didSelect: didSelectAction + ) + }) + + <<< ButtonRow { + $0.tag = Rows.generateNewPassphraseButton.tag + $0.title = Rows.generateNewPassphraseButton.localized + }.onCellSelection { [weak self] (_, _) in + self?.generateNewPassphrase() } - - dialogService.presentShareAlertFor( - string: passphrase, - types: [.copyToPasteboard, .share, .generateQr(encodedContent: encodedPassphrase, sharingTip: nil, withLogo: false)], - excludedActivityTypes: ShareContentType.passphrase.excludedActivityTypes, - animated: true, - from: nil, - completion: nil, - didSelect: didSelectAction - ) - }) - - <<< ButtonRow { - $0.tag = Rows.generateNewPassphraseButton.tag - $0.title = Rows.generateNewPassphraseButton.localized - }.onCellSelection { [weak self] (_, _) in - self?.generateNewPassphrase() - } - + // MARK: Nodes list settings form +++ Section() - <<< ButtonRow { - $0.title = Rows.nodes.localized - $0.tag = Rows.nodes.tag - }.cellSetup { (cell, _) in - cell.selectionStyle = .gray - }.cellUpdate { (cell, _) in - cell.textLabel?.textColor = UIColor.adamant.primary - }.onCellSelection { [weak self] (_, _) in - guard let self = self else { return } - let vc = screensFactory.makeNodesList() - let nav = UINavigationController(rootViewController: vc) - nav.modalPresentationStyle = .overFullScreen - present(nav, animated: true, completion: nil) - } - - // MARK: Coins nodes list settings - <<< ButtonRow { - $0.title = Rows.coinsNodes.localized - $0.tag = Rows.coinsNodes.tag - }.cellSetup { (cell, _) in - cell.selectionStyle = .gray - }.cellUpdate { (cell, _) in - cell.textLabel?.textColor = UIColor.adamant.primary - }.onCellSelection { [weak self] (_, _) in - guard let self = self else { return } - let vc = screensFactory.makeCoinsNodesList(context: .login) - let nav = UINavigationController(rootViewController: vc) - nav.modalPresentationStyle = .overFullScreen - present(nav, animated: true, completion: nil) - } - + <<< ButtonRow { + $0.title = Rows.nodes.localized + $0.tag = Rows.nodes.tag + }.cellSetup { (cell, _) in + cell.selectionStyle = .gray + }.cellUpdate { (cell, _) in + cell.textLabel?.textColor = UIColor.adamant.primary + }.onCellSelection { [weak self] (_, _) in + guard let self = self else { return } + let vc = screensFactory.makeNodesList() + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .overFullScreen + present(nav, animated: true, completion: nil) + } + + // MARK: Coins nodes list settings + <<< ButtonRow { + $0.title = Rows.coinsNodes.localized + $0.tag = Rows.coinsNodes.tag + }.cellSetup { (cell, _) in + cell.selectionStyle = .gray + }.cellUpdate { (cell, _) in + cell.textLabel?.textColor = UIColor.adamant.primary + }.onCellSelection { [weak self] (_, _) in + guard let self = self else { return } + let vc = screensFactory.makeCoinsNodesList(context: .login) + let nav = UINavigationController(rootViewController: vc) + nav.modalPresentationStyle = .overFullScreen + present(nav, animated: true, completion: nil) + } + // MARK: tableView position tuning if let row: PasteInterceptingPasswordRow = form.rowBy(tag: Rows.passphrase.tag) { NotificationCenter.default.addObserver( @@ -462,7 +462,7 @@ final class LoginViewController: FormViewController { guard let tableView = self?.tableView, let indexPath = self?.form.rowBy(tag: Rows.loginButton.tag)?.indexPath else { return } - + tableView.scrollToRow(at: indexPath, at: .none, animated: true) } } diff --git a/Adamant/Modules/Wallets/Adamant/AdmWalletService+RichMessageProvider.swift b/Adamant/Modules/Wallets/Adamant/AdmWalletService+RichMessageProvider.swift index 55f466983..81298f03d 100644 --- a/Adamant/Modules/Wallets/Adamant/AdmWalletService+RichMessageProvider.swift +++ b/Adamant/Modules/Wallets/Adamant/AdmWalletService+RichMessageProvider.swift @@ -36,6 +36,10 @@ extension AdmWalletService { return type(of: self).richMessageType } + static var timeouts: MessageTimeouts { + MessageTimeouts(message: 300, attachment: 300) + } + // MARK: Events func richMessageTapped(for transaction: RichMessageTransaction, in chat: ChatViewController) { diff --git a/Adamant/Modules/Wallets/Adamant/AdmWalletService+Timeouts.swift b/Adamant/Modules/Wallets/Adamant/AdmWalletService+Timeouts.swift new file mode 100644 index 000000000..edb5f91ac --- /dev/null +++ b/Adamant/Modules/Wallets/Adamant/AdmWalletService+Timeouts.swift @@ -0,0 +1,16 @@ +// +// AdmWalletService+Timeouts.swift +// Adamant +// +// Created by Christian Benua on 06.02.2025. +// Copyright © 2025 Adamant. All rights reserved. +// + +import Foundation + +extension AdmWalletService { + struct MessageTimeouts { + let message: TimeInterval + let attachment: TimeInterval + } +} diff --git a/Adamant/Modules/Wallets/Adamant/AdmWalletService.swift b/Adamant/Modules/Wallets/Adamant/AdmWalletService.swift index 125ee319a..186b69f19 100644 --- a/Adamant/Modules/Wallets/Adamant/AdmWalletService.swift +++ b/Adamant/Modules/Wallets/Adamant/AdmWalletService.swift @@ -158,20 +158,20 @@ final class AdmWalletService: NSObject, WalletCoreProtocol, WalletStaticCoreProt .store(in: &subscriptions) } - func updateWithRefreshUIBalance(){ - Task { + func updateWithRefreshUIBalance() { + Task { admWallet?.isBalanceInitialized = false await walletUpdateSender.send() update() } } - + func update() { guard let accountService = accountService, let account = accountService.account else { admWallet = nil return } - + let isRaised: Bool if let wallet = admWallet { diff --git a/Adamant/Modules/Wallets/Bitcoin/BtcWalletService.swift b/Adamant/Modules/Wallets/Bitcoin/BtcWalletService.swift index 47006dd37..58c2dd276 100644 --- a/Adamant/Modules/Wallets/Bitcoin/BtcWalletService.swift +++ b/Adamant/Modules/Wallets/Bitcoin/BtcWalletService.swift @@ -278,12 +278,12 @@ final class BtcWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @unc } } - func updateWithRefreshUIBalance(){ + func updateWithRefreshUIBalance() { Task { await update(updateWithRefreshUIBalance: true) } } - + @MainActor func update(updateWithRefreshUIBalance: Bool = false) async { guard let wallet = btcWallet else { @@ -297,12 +297,12 @@ final class BtcWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @unc case .upToDate: break } - + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance() { @@ -313,15 +313,15 @@ final class BtcWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @unc wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, userInfo: [AdamantUserInfoKey.WalletService.wallet: wallet] ) - + setState(.upToDate) if let rate = try? await getFeeRate() { diff --git a/Adamant/Modules/Wallets/Dash/DashWalletService.swift b/Adamant/Modules/Wallets/Dash/DashWalletService.swift index 1706df996..08e6cfb18 100644 --- a/Adamant/Modules/Wallets/Dash/DashWalletService.swift +++ b/Adamant/Modules/Wallets/Dash/DashWalletService.swift @@ -225,8 +225,8 @@ final class DashWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un await update() } } - - func updateWithRefreshUIBalance(){ + + func updateWithRefreshUIBalance() { Task { await update(updateWithRefreshUIBalance: true) } @@ -245,12 +245,12 @@ final class DashWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un case .upToDate: break } - - if updateWithRefreshUIBalance{ + + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance() { @@ -261,15 +261,15 @@ final class DashWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, userInfo: [AdamantUserInfoKey.WalletService.wallet: wallet] ) - + setState(.upToDate) } diff --git a/Adamant/Modules/Wallets/Doge/DogeWalletService.swift b/Adamant/Modules/Wallets/Doge/DogeWalletService.swift index 9fbcb4cc7..c4483d2fd 100644 --- a/Adamant/Modules/Wallets/Doge/DogeWalletService.swift +++ b/Adamant/Modules/Wallets/Doge/DogeWalletService.swift @@ -246,12 +246,12 @@ final class DogeWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un } } - func updateWithRefreshUIBalance(){ + func updateWithRefreshUIBalance() { Task { await update(updateWithRefreshUIBalance: true) } } - + @MainActor func update(updateWithRefreshUIBalance: Bool = false) async { guard let wallet = dogeWallet else { @@ -265,12 +265,12 @@ final class DogeWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un case .upToDate: break } - + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance() { @@ -281,9 +281,9 @@ final class DogeWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, @un wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, diff --git a/Adamant/Modules/Wallets/ERC20/ERC20WalletService.swift b/Adamant/Modules/Wallets/ERC20/ERC20WalletService.swift index 19cdc4d34..db0d2bb9c 100644 --- a/Adamant/Modules/Wallets/ERC20/ERC20WalletService.swift +++ b/Adamant/Modules/Wallets/ERC20/ERC20WalletService.swift @@ -260,7 +260,7 @@ final class ERC20WalletService: WalletCoreProtocol, ERC20GasAlgorithmComputable, await update(updateWithRefreshUIBalance: true) } } - + @MainActor func update(updateWithRefreshUIBalance: Bool = false) async { guard let wallet = ethWallet else { @@ -274,12 +274,12 @@ final class ERC20WalletService: WalletCoreProtocol, ERC20GasAlgorithmComputable, case .upToDate: break } - + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance(forAddress: wallet.ethAddress) { @@ -290,9 +290,9 @@ final class ERC20WalletService: WalletCoreProtocol, ERC20GasAlgorithmComputable, wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, @@ -319,7 +319,7 @@ final class ERC20WalletService: WalletCoreProtocol, ERC20GasAlgorithmComputable, let gasPriceFromChain = try await getGasPrices() let gasLimitFromChain = try await getGasLimit(to: address) try Task.checkCancellation() - + gasPrice = gasPriceFromChain gasLimit = gasLimitFromChain } catch { diff --git a/Adamant/Modules/Wallets/Ethereum/EthWalletService.swift b/Adamant/Modules/Wallets/Ethereum/EthWalletService.swift index 18b8474f8..a264fe58f 100644 --- a/Adamant/Modules/Wallets/Ethereum/EthWalletService.swift +++ b/Adamant/Modules/Wallets/Ethereum/EthWalletService.swift @@ -292,12 +292,12 @@ final class EthWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, ERC2 } } - func updateWithRefreshUIBalance(){ + func updateWithRefreshUIBalance() { Task { await update(updateWithRefreshUIBalance: true) } } - + @MainActor func update(updateWithRefreshUIBalance: Bool = false) async { guard let wallet = await getWallet() else { @@ -311,12 +311,12 @@ final class EthWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, ERC2 case .upToDate: break } - + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance(forAddress: wallet.ethAddress) { @@ -327,9 +327,9 @@ final class EthWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, ERC2 wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, @@ -372,7 +372,7 @@ final class EthWalletService: WalletCoreProtocol, WalletStaticCoreProtocol, ERC2 let gasPriceFromChain = try await getGasPrices() let gasLimitFromChain = try await getGasLimit(to: address) try Task.checkCancellation() - + gasPrice = gasPriceFromChain gasLimit = gasLimitFromChain } catch { diff --git a/Adamant/Modules/Wallets/Klayr/WalletService/KlyWalletService.swift b/Adamant/Modules/Wallets/Klayr/WalletService/KlyWalletService.swift index b7c5b4572..f0586edb6 100644 --- a/Adamant/Modules/Wallets/Klayr/WalletService/KlyWalletService.swift +++ b/Adamant/Modules/Wallets/Klayr/WalletService/KlyWalletService.swift @@ -249,12 +249,12 @@ extension KlyWalletService { .store(in: &subscriptions) } - func updateWithRefreshUIBalance(){ + func updateWithRefreshUIBalance() { Task { await update(updateWithRefreshUIBalance: true) } } - + @MainActor fileprivate func update(updateWithRefreshUIBalance: Bool = false) async { guard let wallet = klyWallet else { @@ -268,12 +268,12 @@ extension KlyWalletService { case .upToDate: break } - + if updateWithRefreshUIBalance { wallet.isBalanceInitialized = false walletUpdateSender.send() } - + setState(.updating) if let balance = try? await getBalance() { @@ -284,9 +284,9 @@ extension KlyWalletService { wallet.balance = balance markBalanceAsFresh(wallet) } - + walletUpdateSender.send() - + NotificationCenter.default.post( name: walletUpdatedNotification, object: self, diff --git a/Adamant/Services/AdamantAccountService.swift b/Adamant/Services/AdamantAccountService.swift index 59985bfeb..9965e70c2 100644 --- a/Adamant/Services/AdamantAccountService.swift +++ b/Adamant/Services/AdamantAccountService.swift @@ -233,7 +233,7 @@ extension AdamantAccountService { func update(_ completion: (@Sendable (AccountServiceResult) -> Void)?) { update(completion, updateOnlyVisible: true) } - + func updateWithRefreshUI() { update(nil, updateOnlyVisible: true, shouldUpdateUIBalance: true) } @@ -465,9 +465,9 @@ extension AdamantAccountService { if account != nil { NotificationCenter.default.post(name: Notification.Name.AdamantAccountService.userWillLogOut, object: self) } - + dropSavedAccount() - + let wasLogged = account != nil account = nil keypair = nil @@ -475,7 +475,7 @@ extension AdamantAccountService { state = .notLogged await apiService.cancelCurrentTasks() coreDataStack.clearCoreData() - + guard wasLogged else { return } NotificationCenter.default.post(name: .AdamantAccountService.userLoggedOut, object: self) } diff --git a/Adamant/Services/AdamantPushNotificationsTokenService.swift b/Adamant/Services/AdamantPushNotificationsTokenService.swift index 971980b7f..e663361c3 100644 --- a/Adamant/Services/AdamantPushNotificationsTokenService.swift +++ b/Adamant/Services/AdamantPushNotificationsTokenService.swift @@ -193,7 +193,7 @@ extension AdamantPushNotificationsTokenService { else { return nil } Task { - switch await apiService.sendMessageTransaction(transaction: messageTransaction) { + switch await apiService.sendMessageTransaction(transaction: messageTransaction, timeout: nil) { case .success: completion(true) case .failure: diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider+backgroundFetch.swift b/Adamant/Services/DataProviders/AdamantChatsProvider+backgroundFetch.swift index 74a16e672..2700ef13e 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider+backgroundFetch.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider+backgroundFetch.swift @@ -11,23 +11,23 @@ import Foundation extension AdamantChatsProvider: BackgroundFetchService { func fetchBackgroundData(notificationsService: NotificationsService) async -> FetchResult { - guard let address: String = SecureStore.get(StoreKey.chatProvider.address) else { + guard let address: String = secureStore.get(StoreKey.chatProvider.address) else { return .failed } var lastHeight: Int64? - if let raw: String = SecureStore.get(StoreKey.chatProvider.receivedLastHeight) { + if let raw: String = secureStore.get(StoreKey.chatProvider.receivedLastHeight) { lastHeight = Int64(raw) } else { lastHeight = nil } var notifiedCount = 0 - if let raw: String = SecureStore.get(StoreKey.chatProvider.notifiedLastHeight), let notifiedHeight = Int64(raw), let h = lastHeight { + if let raw: String = secureStore.get(StoreKey.chatProvider.notifiedLastHeight), let notifiedHeight = Int64(raw), let h = lastHeight { if h < notifiedHeight { lastHeight = notifiedHeight - if let raw: String = SecureStore.get(StoreKey.chatProvider.notifiedMessagesCount), let count = Int(raw) { + if let raw: String = secureStore.get(StoreKey.chatProvider.notifiedMessagesCount), let count = Int(raw) { notifiedCount = count } } @@ -44,13 +44,13 @@ extension AdamantChatsProvider: BackgroundFetchService { guard transactions.count > 0 else { return .noData } let total = transactions.count - SecureStore.set( + secureStore.set( String(total + notifiedCount), for: StoreKey.chatProvider.notifiedMessagesCount ) if let newLastHeight = transactions.map({ $0.height }).sorted().last { - SecureStore.set( + secureStore.set( String(newLastHeight), for: StoreKey.chatProvider.notifiedLastHeight ) diff --git a/Adamant/Services/DataProviders/AdamantChatsProvider.swift b/Adamant/Services/DataProviders/AdamantChatsProvider.swift index 148189f1c..c5f2a8546 100644 --- a/Adamant/Services/DataProviders/AdamantChatsProvider.swift +++ b/Adamant/Services/DataProviders/AdamantChatsProvider.swift @@ -20,10 +20,10 @@ actor AdamantChatsProvider: ChatsProvider { private let adamantCore: AdamantCore private let transactionService: ChatTransactionService private let walletServiceCompose: WalletServiceCompose - + private let timeouts: AdmWalletService.MessageTimeouts let accountService: AccountService let accountsProvider: AccountsProvider - let SecureStore: SecureStore + let secureStore: SecureStore let apiService: AdamantApiServiceProtocol let stack: CoreDataStack @@ -84,8 +84,9 @@ actor AdamantChatsProvider: ChatsProvider { adamantCore: AdamantCore, accountsProvider: AccountsProvider, transactionService: ChatTransactionService, - SecureStore: SecureStore, - walletServiceCompose: WalletServiceCompose + secureStore: SecureStore, + walletServiceCompose: WalletServiceCompose, + timeouts: AdmWalletService.MessageTimeouts ) { self.accountService = accountService self.apiService = apiService @@ -94,9 +95,9 @@ actor AdamantChatsProvider: ChatsProvider { self.adamantCore = adamantCore self.accountsProvider = accountsProvider self.transactionService = transactionService - self.SecureStore = SecureStore + self.secureStore = secureStore self.walletServiceCompose = walletServiceCompose - + self.timeouts = timeouts Task { await setupSecureStore() await addObservers() @@ -147,8 +148,8 @@ actor AdamantChatsProvider: ChatsProvider { // MARK: - Notifications action private func userLoggedInAction(_ notification: Notification) { - let store = self.SecureStore + let store = self.secureStore guard let loggedAddress = notification.userInfo?[AdamantUserInfoKey.AccountService.loggedAccountAddress] as? String else { store.remove(StoreKey.chatProvider.address) store.remove(StoreKey.chatProvider.receivedLastHeight) @@ -160,8 +161,8 @@ actor AdamantChatsProvider: ChatsProvider { if let savedAddress: String = store.get(StoreKey.chatProvider.address), savedAddress == loggedAddress { if let raw: String = store.get(StoreKey.chatProvider.readedLastHeight), - let h = Int64(raw), - let chatsMarkAsUnread: Set = store.get(StoreKey.chatProvider.markedChatsAsUnread) + let h = Int64(raw), + let chatsMarkAsUnread: Set = store.get(StoreKey.chatProvider.markedChatsAsUnread) { self.readedLastHeight = h self.chatsMarkAsUnread = chatsMarkAsUnread @@ -198,8 +199,8 @@ actor AdamantChatsProvider: ChatsProvider { } if state { - SecureStore.set(blockList, for: StoreKey.accountService.blockList) - SecureStore.set(removedMessages, for: StoreKey.accountService.removedMessages) + secureStore.set(blockList, for: StoreKey.accountService.blockList) + secureStore.set(removedMessages, for: StoreKey.accountService.removedMessages) } } @@ -274,13 +275,13 @@ actor AdamantChatsProvider: ChatsProvider { } private func setupSecureStore() { - blockList = SecureStore.get(StoreKey.accountService.blockList) ?? [] - removedMessages = SecureStore.get(StoreKey.accountService.removedMessages) ?? [] + blockList = secureStore.get(StoreKey.accountService.blockList) ?? [] + removedMessages = secureStore.get(StoreKey.accountService.removedMessages) ?? [] } func dropStateData() { - SecureStore.remove(StoreKey.chatProvider.notifiedLastHeight) - SecureStore.remove(StoreKey.chatProvider.notifiedMessagesCount) + secureStore.remove(StoreKey.chatProvider.notifiedLastHeight) + secureStore.remove(StoreKey.chatProvider.notifiedMessagesCount) } func isMessageDeleted(id: String) -> Bool { @@ -314,11 +315,11 @@ extension AdamantChatsProvider { chatLoadedMessages.removeAll() // Drop store - SecureStore.remove(StoreKey.chatProvider.address) - SecureStore.remove(StoreKey.chatProvider.receivedLastHeight) - SecureStore.remove(StoreKey.chatProvider.readedLastHeight) - SecureStore.remove(StoreKey.chatProvider.markedChatsAsUnread) + secureStore.remove(StoreKey.chatProvider.address) + secureStore.remove(StoreKey.chatProvider.receivedLastHeight) + secureStore.remove(StoreKey.chatProvider.readedLastHeight) + secureStore.remove(StoreKey.chatProvider.markedChatsAsUnread) // Set State setState(.empty, previous: prevState, notify: notify) } @@ -619,13 +620,13 @@ extension AdamantChatsProvider { } if let h = receivedLastHeight { - SecureStore.set(String(h), for: StoreKey.chatProvider.receivedLastHeight) + secureStore.set(String(h), for: StoreKey.chatProvider.receivedLastHeight) } if let h = readedLastHeight, h > 0 { - SecureStore.set(String(h), for: StoreKey.chatProvider.readedLastHeight) + secureStore.set(String(h), for: StoreKey.chatProvider.readedLastHeight) } if !isInitiallySynced { @@ -669,16 +670,16 @@ extension AdamantChatsProvider { func setManualMarkChatAsUnread(chatroomId: String) { chatsMarkAsUnread?.insert(chatroomId) - SecureStore.set(chatsMarkAsUnread, for: StoreKey.chatProvider.markedChatsAsUnread) } func removeManualMarkChatAsUnread(chatroomId: String) { chatsMarkAsUnread?.remove(chatroomId) - SecureStore.set(chatsMarkAsUnread, for: StoreKey.chatProvider.markedChatsAsUnread) } func getMarkAdressesFromChain() -> Set { - return SecureStore.get(StoreKey.chatProvider.markedChatsAsUnread) ?? Set() + secureStore.set(chatsMarkAsUnread, for: StoreKey.chatProvider.markedChatsAsUnread) + secureStore.set(chatsMarkAsUnread, for: StoreKey.chatProvider.markedChatsAsUnread) + return secureStore.get(StoreKey.chatProvider.markedChatsAsUnread) ?? Set() } func isChatLoading(with addressRecipient: String) -> Bool { @@ -1334,8 +1335,12 @@ extension AdamantChatsProvider { let locallyID = signedTransaction.generateId() ?? UUID().uuidString transaction.transactionId = locallyID transaction.chatMessageId = locallyID - - let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() + let id = try await apiService.sendMessageTransaction( + transaction: signedTransaction, + timeout: transaction.isFileTransfer + ? timeouts.attachment + : timeouts.message + ).get() // Update ID with recieved, add to unconfirmed transactions. transaction.transactionId = String(id) @@ -1350,8 +1355,8 @@ extension AdamantChatsProvider { return transaction } catch { - guard case let (apiError) = (error as? ApiServiceError), - case let (.serverError(text)) = apiError, + guard + case let (.serverError(text)) = error, text.contains("Transaction is already confirmed") || text.contains("Transaction is already processed") else { @@ -2018,7 +2023,7 @@ extension AdamantChatsProvider { self.blockList.append(address) if self.accountService.hasStayInAccount { - self.SecureStore.set(blockList, for: StoreKey.accountService.blockList) + self.secureStore.set(blockList, for: StoreKey.accountService.blockList) } } } @@ -2029,7 +2034,7 @@ extension AdamantChatsProvider { markTransactionAsHidden(id: id) if self.accountService.hasStayInAccount { - self.SecureStore.set(removedMessages, for: StoreKey.accountService.removedMessages) + self.secureStore.set(removedMessages, for: StoreKey.accountService.removedMessages) } } } @@ -2072,4 +2077,10 @@ extension AdamantChatsProvider { } } +extension ChatTransaction { + var isFileTransfer: Bool { + (self as? RichMessageTransaction)?.additionalType == RichAdditionalType.file + } +} + private let requestRepeatDelay: TimeInterval = 2 diff --git a/Adamant/Services/DataProviders/AdamantTransfersProvider.swift b/Adamant/Services/DataProviders/AdamantTransfersProvider.swift index 087a24095..67da2d61a 100644 --- a/Adamant/Services/DataProviders/AdamantTransfersProvider.swift +++ b/Adamant/Services/DataProviders/AdamantTransfersProvider.swift @@ -555,7 +555,10 @@ extension AdamantTransfersProvider { } do { - let id = try await apiService.sendMessageTransaction(transaction: signedTransaction).get() + let id = try await apiService.sendMessageTransaction( + transaction: signedTransaction, + timeout: nil + ).get() transaction.transactionId = String(id) await chatsProvider?.addUnconfirmed(transactionId: id, managedObjectId: transaction.objectID) diff --git a/AdamantWalletsKit/Sources/AdamantWalletsKit/Decoders/CoinInfoDTO.swift b/AdamantWalletsKit/Sources/AdamantWalletsKit/Decoders/CoinInfoDTO.swift index d8fba04a8..4830684b3 100644 --- a/AdamantWalletsKit/Sources/AdamantWalletsKit/Decoders/CoinInfoDTO.swift +++ b/AdamantWalletsKit/Sources/AdamantWalletsKit/Decoders/CoinInfoDTO.swift @@ -44,7 +44,7 @@ public struct CoinInfoDTO: Codable { case url case altIp = "alt_ip" } - + public let url: String public let altIp: String? } diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings index 7e70d1f25..eebcbd3f9 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/de.lproj/Localizable.strings @@ -571,6 +571,9 @@ /* Shared error: Network problems. In most cases - no connection */ "Error.NoNetwork" = "Keine Verbindung"; +/* Shared error: Timeout Problem. In most cases - no connection */ +"Error.TimeOut" = "Das Zeitlimit für die Antwort wurde überschritten"; + /* Shared error: Request cancelled */ "Error.RequestCancelled" = "Request cancelled"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings index 01e99f3db..4d2794030 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/en.lproj/Localizable.strings @@ -569,6 +569,9 @@ /* Shared error: Network problems. In most cases - no connection */ "Error.NoNetwork" = "No connection"; +/* Shared error: Timeout Problem. In most cases - no connection */ +"Error.TimeOut" = "Response waiting time exceeded"; + /* Shared error: Request cancelled */ "Error.RequestCancelled" = "Request cancelled"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings index 055ab24ca..7285889f6 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/ru.lproj/Localizable.strings @@ -570,6 +570,9 @@ /* Shared error: Network problems. In most cases - no connection */ "Error.NoNetwork" = "Нет соединения с сетью"; +/* Shared error: Timeout Problem. In most cases - no connection */ +"Error.TimeOut" = "Превышено время ожидания ответа"; + /* Shared error: Request cancelled */ "Error.RequestCancelled" = "Запрос отменён"; diff --git a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings index 888a6fc5b..3a0e191e7 100644 --- a/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings +++ b/CommonKit/Sources/CommonKit/Assets/Localization/zh.lproj/Localizable.strings @@ -562,6 +562,9 @@ /* Shared error: Network problems. In most cases - no connection */ "Error.NoNetwork" = "无连接"; +/* Shared error: Timeout Problem. In most cases - no connection */ +"Error.TimeOut" = "超过响应等待时间"; + /* Shared error: Request cancelled */ "Error.RequestCancelled" = "请求已取消"; diff --git a/CommonKit/Sources/CommonKit/Helpers/Deadline.swift b/CommonKit/Sources/CommonKit/Helpers/Deadline.swift new file mode 100644 index 000000000..4debf3b47 --- /dev/null +++ b/CommonKit/Sources/CommonKit/Helpers/Deadline.swift @@ -0,0 +1,78 @@ +// +// Deadline.swift +// CommonKit +// +// Created by Christian Benua on 06.02.2025. +// + +import Foundation +import QuartzCore + +/// Executes operation `operation` with deadline `instant` +/// Supports actor isolation +func deadline( + until instant: TimeInterval, + isolation: isolated (any Actor)? = #isolation, + operation: @Sendable () async throws -> R +) async throws -> R where R: Sendable { + let result = await withoutActuallyEscaping(operation) { operation in + await withTaskGroup( + of: DeadlineState.self, + returning: Result.self, + isolation: isolation + ) { taskGroup in + + taskGroup.addTask { + do { + let result = try await operation() + return .operationResult(.success(result)) + } catch { + return .operationResult(.failure(error)) + } + } + + taskGroup.addTask { + do { + let interval = instant - CACurrentMediaTime() + guard interval > 0 else { + return .sleepResult(.failure(DeadlineExceededError())) + } + try await Task.sleep(interval: interval) + return .sleepResult(.success(false)) + } catch where Task.isCancelled { + return .sleepResult(.success(true)) + } catch { + return .sleepResult(.failure(error)) + } + } + + defer { + taskGroup.cancelAll() + } + + for await next in taskGroup { + switch next { + case .operationResult(let result): + return result + case .sleepResult(.success(false)): + return .failure(DeadlineExceededError()) + case .sleepResult(.success(true)): + continue + case .sleepResult(.failure(let error)): + return .failure(error) + } + } + + preconditionFailure("Invalid state") + } + } + + return try result.get() +} + +enum DeadlineState: Sendable where T: Sendable { + case operationResult(Result) + case sleepResult(Result) +} + +public struct DeadlineExceededError: Error {} diff --git a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift index ff8bb5308..59e496d7c 100644 --- a/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift +++ b/CommonKit/Sources/CommonKit/Helpers/FilePresentationHelper.swift @@ -13,58 +13,62 @@ public class FilePresentationHelper { mediaFilesCount: Int, otherFilesCount: Int, comment: String, - parsedWith parser: MarkdownParser? = nil, + parsedWith parser: MarkdownParser? = nil, resolveLinkColor: Bool = false ) -> NSMutableAttributedString { let prefix = getFilePrefix(mediaFilesCount: mediaFilesCount, otherFilesCount: otherFilesCount) - - var parsedEmodji = parser?.parse(prefix) + + var parsedEmodji = parser?.parse(prefix) var parsedComment = parser?.parse(comment) - + if resolveLinkColor { parsedEmodji = (parsedEmodji ?? NSAttributedString()).resolveLinkColor() parsedComment = (parsedComment ?? NSAttributedString()).resolveLinkColor() } - + let result = NSMutableAttributedString(attributedString: parsedEmodji ?? NSAttributedString()) result.append(parsedComment ?? NSMutableAttributedString()) - + return result } public static func getFilePresentationText(_ richContent: [String: Any]) -> String { return getFilePrefix(richContent) + getText(from: richContent) } - - public static func getFilePresentationText(_ richContent: [String: Any], parsedWith parser: MarkdownParser? = nil, resolveLinkColor: Bool = false) -> NSMutableAttributedString { + + public static func getFilePresentationText( + _ richContent: [String: Any], + parsedWith parser: MarkdownParser? = nil, + resolveLinkColor: Bool = false + ) -> NSMutableAttributedString { guard parser != nil else { return NSMutableAttributedString(string: getFilePresentationText(richContent)) } - + let emodji = getFilePrefix(richContent) let comment = getText(from: richContent) - - var parsedEmodji = parser?.parse(emodji) + + var parsedEmodji = parser?.parse(emodji) var parsedComment = parser?.parse(comment) - + if resolveLinkColor { parsedEmodji = (parsedEmodji ?? NSAttributedString()).resolveLinkColor() parsedComment = (parsedComment ?? NSAttributedString()).resolveLinkColor() } - + let result = NSMutableAttributedString(attributedString: parsedEmodji ?? NSAttributedString()) result.append(parsedComment ?? NSMutableAttributedString()) - + return result } - + private static func getText(from richContent: [String: Any]) -> String { let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent return (content[RichContentKeys.file.comment] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? .empty } - + private static func getFilePrefix(_ richContent: [String: Any]) -> String { let content = richContent[RichContentKeys.reply.replyMessage] as? [String: Any] ?? richContent @@ -77,32 +81,34 @@ public class FilePresentationHelper { }.count let otherFilesCount = files.count - mediaFilesCount - + return Self.getFilePrefix( mediaFilesCount: mediaFilesCount, otherFilesCount: otherFilesCount ) } - + private static func getFilePrefix( mediaFilesCount: Int, otherFilesCount: Int ) -> String { - let mediaCountText = mediaFilesCount > 1 - ? "\(mediaFilesCount)" - : .empty - - let otherFilesCountText = otherFilesCount > 1 - ? "\(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].filter { !$0.isEmpty }.joined() - + return text } } diff --git a/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift b/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift index 7c0308697..0a49716ad 100644 --- a/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift +++ b/CommonKit/Sources/CommonKit/Localization/AdamantLocalized.swift @@ -60,6 +60,9 @@ extension String.adamant { public static var networkError: String { String.localized("Error.NoNetwork", comment: "Shared error: Network problems. In most cases - no connection") } + public static var timeoutError: String { + String.localized("Error.TimeOut", comment: "Shared error: Timeout Problem. In most cases - no connection") + } public static var requestCancelled: String { String.localized("Error.RequestCancelled", comment: "Shared error: Request cancelled") } diff --git a/CommonKit/Sources/CommonKit/Models/ApiServiceError.swift b/CommonKit/Sources/CommonKit/Models/ApiServiceError.swift index 5d9f94165..7156e02ef 100644 --- a/CommonKit/Sources/CommonKit/Models/ApiServiceError.swift +++ b/CommonKit/Sources/CommonKit/Models/ApiServiceError.swift @@ -85,7 +85,7 @@ extension ApiServiceError: Equatable { } } -extension ApiServiceError: HealthCheckableError { +extension ApiServiceError: HealthCheckableTimeoutableError { public var isNetworkError: Bool { switch self { case .networkError: @@ -99,6 +99,10 @@ extension ApiServiceError: HealthCheckableError { .networkError(error: AdamantError(message: .adamant.sharedErrors.networkError)) } + public static var timeoutError: ApiServiceError { + .networkError(error: AdamantError(message: .adamant.sharedErrors.timeoutError)) + } + public static func noEndpointsError(nodeGroupName: String) -> ApiServiceError { .noEndpointsAvailable(nodeGroupName: nodeGroupName) } diff --git a/CommonKit/Sources/CommonKit/Protocols/AdamantApiServiceProtocol.swift b/CommonKit/Sources/CommonKit/Protocols/AdamantApiServiceProtocol.swift index 6d03ccd93..567e3aa10 100644 --- a/CommonKit/Sources/CommonKit/Protocols/AdamantApiServiceProtocol.swift +++ b/CommonKit/Sources/CommonKit/Protocols/AdamantApiServiceProtocol.swift @@ -94,11 +94,13 @@ public protocol AdamantApiServiceProtocol: ApiServiceProtocol { func sendTransaction( path: String, - transaction: UnregisteredTransaction + transaction: UnregisteredTransaction, + timeout: TimeInterval? ) async -> ApiServiceResult func sendMessageTransaction( - transaction: UnregisteredTransaction + transaction: UnregisteredTransaction, + timeout: TimeInterval? ) async -> ApiServiceResult // MARK: - Delegates diff --git a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+AdamantApiTask.swift b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+AdamantApiTask.swift index d2f821684..295d8d65f 100644 --- a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+AdamantApiTask.swift +++ b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+AdamantApiTask.swift @@ -11,11 +11,11 @@ final class AdamantApiTask: CancellableTask { private var task: Task, Never>! private let id: UUID private var cancelled: Bool = false - + var isCancelled: Bool { cancelled } - + var value: Result { get async { await task.value @@ -25,8 +25,8 @@ final class AdamantApiTask: CancellableTask { init(id: UUID) { self.id = id } - - /// Must be called before `await` on `value`. + + /// Must be called before `await` on `value`. func startTask(_ task: Task, Never>) { self.task = task } diff --git a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Chats.swift b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Chats.swift index 8751081e4..73730617a 100644 --- a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Chats.swift +++ b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Chats.swift @@ -55,11 +55,13 @@ extension AdamantApiService { } public func sendMessageTransaction( - transaction: UnregisteredTransaction + transaction: UnregisteredTransaction, + timeout: TimeInterval? = nil ) async -> ApiServiceResult { await sendTransaction( path: ApiCommands.Chats.processTransaction, - transaction: transaction + transaction: transaction, + timeout: timeout ) } diff --git a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Transactions.swift b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Transactions.swift index 004e3ccbf..abf45198a 100644 --- a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Transactions.swift +++ b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApi+Transactions.swift @@ -20,9 +20,10 @@ extension ApiCommands { extension AdamantApiService { public func sendTransaction( path: String, - transaction: UnregisteredTransaction + transaction: UnregisteredTransaction, + timeout: TimeInterval? = nil ) async -> ApiServiceResult { - let response: ApiServiceResult = await request { core, origin in + let response: ApiServiceResult = await request(timeout: timeout) { core, origin in await core.sendRequestJsonResponse( origin: origin, path: path, diff --git a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApiService.swift b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApiService.swift index 59a5bf8eb..3e19f81dd 100644 --- a/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApiService.swift +++ b/CommonKit/Sources/CommonKit/Services/ApiService/AdamantApiService.swift @@ -7,7 +7,7 @@ // import Foundation - +import QuartzCore.CABase /// /// I need to override HealthCheckWrapped because of now we have an option to cancel the tasks @@ -21,21 +21,21 @@ import Foundation private final actor TasksStorage { private var adamantApiTaskStorage: [UUID: CancellableTask] = [:] - + init() {} - + func getTask(id: UUID) -> CancellableTask? { return adamantApiTaskStorage[id] } - + func addTask(_ task: CancellableTask, id: UUID) { adamantApiTaskStorage[id] = task } - + func removeTask(id: UUID) { adamantApiTaskStorage[id] = nil } - + func cancelAll() { adamantApiTaskStorage.forEach { _, task in task.cancel() @@ -58,28 +58,42 @@ public final class AdamantApiService: @unchecked Sendable { public func request( waitsForConnectivity: Bool = false, + timeout: TimeInterval? = nil, _ request: @Sendable @escaping (APICoreProtocol, NodeOrigin) async -> ApiServiceResult ) async -> ApiServiceResult { let taskId: UUID = .init() let task = AdamantApiTask(id: taskId) - + await tasksStorage.addTask(task, id: taskId) defer { Task { await tasksStorage.removeTask(id: taskId) } } - + task.startTask( Task { - await service.request( - waitsForConnectivity: waitsForConnectivity, - taskId: taskId, - isCancelled: { - return await tasksStorage.getTask(id: taskId)?.isCancelled ?? true + if let timeout { + await service.request( + waitsForConnectivity: waitsForConnectivity, + timeout: timeout, + taskId: taskId, + isCancelled: { + await tasksStorage.getTask(id: taskId)?.isCancelled ?? true + } + ) { admApiCore, origin in + await request(admApiCore.apiCore, origin) + } + } else { + await service.request( + waitsForConnectivity: waitsForConnectivity, + taskId: taskId, + isCancelled: { + await tasksStorage.getTask(id: taskId)?.isCancelled ?? true + } + ) { admApiCore, origin in + await request(admApiCore.apiCore, origin) } - ) { admApiCore, origin in - return await request(admApiCore.apiCore, origin) } } ) @@ -91,6 +105,15 @@ public final class AdamantApiService: @unchecked Sendable { } } +extension AdamantApiServiceProtocol { + public func sendTransaction( + path: String, + transaction: UnregisteredTransaction + ) async -> ApiServiceResult { + await sendTransaction(path: path, transaction: transaction, timeout: nil) + } +} + extension AdamantApiService: AdamantApiServiceProtocol { @MainActor public var nodesInfoPublisher: AnyObservable { service.nodesInfoPublisher } @@ -104,13 +127,14 @@ extension AdamantApiService: AdamantApiServiceProtocol { public final class AdamantHealthCheck: BlockchainHealthCheckWrapper { func request( waitsForConnectivity: Bool, + timeout: TimeInterval? = nil, taskId: UUID, isCancelled: () async -> Bool, _ requestAction: (AdamantApiCore, NodeOrigin) async -> Result ) async -> Result { var usedNodesIds: Set = .init() - var lastConnectionError: Error? - + var lastConnectionError: AdamantApiCore.Error? + /// Check the cancellation of the task from the outside guard await !isCancelled() else { return .failure(.requestCancelled) @@ -132,17 +156,44 @@ public final class AdamantHealthCheck: BlockchainHealthCheckWrapper Self } +public protocol HealthCheckableTimeoutableError: HealthCheckableError { + static var timeoutError: Self { get } +} + @HealthCheckActor open class HealthCheckWrapper: Sendable { @ObservableValue private(set) var nodes: [Node] = .init() @@ -136,6 +140,29 @@ open class HealthCheckWrapper: S open func healthCheckInternal() async {} } +extension HealthCheckWrapper where Error: HealthCheckableTimeoutableError { + public func request( + waitsForConnectivity: Bool, + timeout: TimeInterval, + _ requestAction: @Sendable (Service, NodeOrigin) async -> Result + ) async -> Result { + let startTime = CACurrentMediaTime() + + do { + let result = try await deadline(until: startTime + timeout) { + await self.request(waitsForConnectivity: waitsForConnectivity, requestAction) + } + return result + } catch _ as DeadlineExceededError { + return .failure(.timeoutError) + } catch let error as Error { + return .failure(error) + } catch { + return .failure(.noNetworkError) + } + } +} + extension HealthCheckWrapper { private enum AppState { case active diff --git a/PopupKit/Sources/PopupKit/Implementation/Services/AutoDismissManager.swift b/PopupKit/Sources/PopupKit/Implementation/Services/AutoDismissManager.swift index d8c7a8765..a7fe7d271 100644 --- a/PopupKit/Sources/PopupKit/Implementation/Services/AutoDismissManager.swift +++ b/PopupKit/Sources/PopupKit/Implementation/Services/AutoDismissManager.swift @@ -39,7 +39,7 @@ final class AutoDismissManager { self?.popupCoordinatorModel.toastMessage = nil } } - + func dismissPreviousToast() { self.toastDismissSubscription = nil self.popupCoordinatorModel.toastMessage = nil diff --git a/PopupKit/Sources/PopupKit/PopupManager.swift b/PopupKit/Sources/PopupKit/PopupManager.swift index bb363f6fa..ded12c51f 100644 --- a/PopupKit/Sources/PopupKit/PopupManager.swift +++ b/PopupKit/Sources/PopupKit/PopupManager.swift @@ -23,16 +23,16 @@ public final class PopupManager { // MARK: - Toast -public extension PopupManager { - func showToastMessage(_ message: String) { +extension PopupManager { + public func showToastMessage(_ message: String) { autoDismissManager.dismissPreviousToast() DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.coordinatorModel.toastMessage = message self?.autoDismissManager.dismissToast() } } - - func dismissToast() { + + public func dismissToast() { coordinatorModel.toastMessage = nil } }