diff --git a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj index 368c61dff..f37d84d90 100644 --- a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj +++ b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj @@ -2237,6 +2237,10 @@ C6F6AF6328A35FB900A7B571 /* CNavigationBarScrollingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F6AF6228A35FB900A7B571 /* CNavigationBarScrollingController.swift */; }; C6F6AF6928A4D20A00A7B571 /* TitleVisibilityAfterLimitNavBarScrollingBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F6AF6828A4D20A00A7B571 /* TitleVisibilityAfterLimitNavBarScrollingBehaviour.swift */; }; C6F6AF6E28A4D4BA00A7B571 /* BlurVisibilityAfterLimitNavBarScrollingBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F6AF6D28A4D4BA00A7B571 /* BlurVisibilityAfterLimitNavBarScrollingBehaviour.swift */; }; + C6F7D9CF2B8D6EFC00764708 /* MessageActionReplyButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7D9CE2B8D6EFC00764708 /* MessageActionReplyButtonView.swift */; }; + C6F7D9D02B8D6EFC00764708 /* MessageActionReplyButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7D9CE2B8D6EFC00764708 /* MessageActionReplyButtonView.swift */; }; + C6F7D9D32B8D766500764708 /* ChatReplyInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7D9D22B8D766500764708 /* ChatReplyInfoView.swift */; }; + C6F7D9D42B8D766500764708 /* ChatReplyInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7D9D22B8D766500764708 /* ChatReplyInfoView.swift */; }; C6F946DB2A6788E0008043AC /* MockEntitiesFabric.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F946DA2A6788E0008043AC /* MockEntitiesFabric.swift */; }; C6F9FBB82A25C30C00102F81 /* MessagingWebSocketsServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F9FBB72A25C30C00102F81 /* MessagingWebSocketsServiceProtocol.swift */; }; C6F9FBBE2A25C32700102F81 /* MessagingWebSocketEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F9FBBD2A25C32700102F81 /* MessagingWebSocketEvent.swift */; }; @@ -2254,6 +2258,8 @@ C6FAED842B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */; }; C6FAED862B8C684700CC1844 /* MessageMentionString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED852B8C684700CC1844 /* MessageMentionString.swift */; }; C6FAED872B8C684700CC1844 /* MessageMentionString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED852B8C684700CC1844 /* MessageMentionString.swift */; }; + C6FAED892B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED882B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift */; }; + C6FAED8A2B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED882B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift */; }; C6FAFDE628119E0400734E0F /* TextButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAFDE528119E0400734E0F /* TextButton.swift */; }; C6FE49D6285CBAA50058F9D1 /* CoreAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FE49D5285CBAA50058F9D1 /* CoreAppCoordinator.swift */; }; C6FE49DB285CBAB10058F9D1 /* CoreAppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FE49DA285CBAB10058F9D1 /* CoreAppCoordinatorProtocol.swift */; }; @@ -3585,6 +3591,8 @@ C6F6AF6228A35FB900A7B571 /* CNavigationBarScrollingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CNavigationBarScrollingController.swift; sourceTree = ""; }; C6F6AF6828A4D20A00A7B571 /* TitleVisibilityAfterLimitNavBarScrollingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleVisibilityAfterLimitNavBarScrollingBehaviour.swift; sourceTree = ""; }; C6F6AF6D28A4D4BA00A7B571 /* BlurVisibilityAfterLimitNavBarScrollingBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurVisibilityAfterLimitNavBarScrollingBehaviour.swift; sourceTree = ""; }; + C6F7D9CE2B8D6EFC00764708 /* MessageActionReplyButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActionReplyButtonView.swift; sourceTree = ""; }; + C6F7D9D22B8D766500764708 /* ChatReplyInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatReplyInfoView.swift; sourceTree = ""; }; C6F946DA2A6788E0008043AC /* MockEntitiesFabric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockEntitiesFabric.swift; sourceTree = ""; }; C6F9FBB72A25C30C00102F81 /* MessagingWebSocketsServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingWebSocketsServiceProtocol.swift; sourceTree = ""; }; C6F9FBBD2A25C32700102F81 /* MessagingWebSocketEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingWebSocketEvent.swift; sourceTree = ""; }; @@ -3599,6 +3607,7 @@ C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMentionSuggestionRowView.swift; sourceTree = ""; }; C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMentionSuggestionsView.swift; sourceTree = ""; }; C6FAED852B8C684700CC1844 /* MessageMentionString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageMentionString.swift; sourceTree = ""; }; + C6FAED882B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagingChatMessageReplyTypeDisplayInfo.swift; sourceTree = ""; }; C6FAFDE528119E0400734E0F /* TextButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextButton.swift; sourceTree = ""; }; C6FE49D5285CBAA50058F9D1 /* CoreAppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAppCoordinator.swift; sourceTree = ""; }; C6FE49DA285CBAB10058F9D1 /* CoreAppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAppCoordinatorProtocol.swift; sourceTree = ""; }; @@ -5439,6 +5448,7 @@ C603D7992A56B11800CEC696 /* MessagingChatMessageUnknownTypeDisplayInfo.swift */, C6157AFD2A7108B400081574 /* MessagingChatMessageRemoteContentTypeDisplayInfo.swift */, C623967B2A288A8A00363F60 /* MessagingChatMessageDisplayType.swift */, + C6FAED882B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift */, C631DFA62B7B530800040221 /* MessagingChatMessageReactionTypeDisplayInfo.swift */, C62396802A288A9400363F60 /* MessagingChatMessageDisplayInfo.swift */, C62396852A288A9E00363F60 /* MessagingChatMessage.swift */, @@ -6207,6 +6217,7 @@ isa = PBXGroup; children = ( C684A0232B85990600B751A5 /* MessageActionBlockUserButtonView.swift */, + C6F7D9CE2B8D6EFC00764708 /* MessageActionReplyButtonView.swift */, ); path = "Message Action Buttons"; sourceTree = ""; @@ -6301,6 +6312,7 @@ children = ( C630E4A82B7F4959008F3269 /* ChatViewModel.swift */, C630E4A52B7F4918008F3269 /* ChatView.swift */, + C6F7D9D22B8D766500764708 /* ChatReplyInfoView.swift */, C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */, C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */, C6D8FF2E2B8307E70094A21E /* ChatNavTitleView.swift */, @@ -7314,10 +7326,7 @@ children = ( C6D8FF1A2B82E7A80094A21E /* MessageRowView.swift */, C684A0272B85A8E500B751A5 /* MessageReactionSelectionView.swift */, - C6D8FF262B82FAFB0094A21E /* UnknownMessageRowView.swift */, - C6D8FF202B82F3DC0094A21E /* ImageMessageRowView.swift */, - C6D8FF232B82F8FE0094A21E /* RemoteContentMessageRowView.swift */, - C6D8FF1D2B82EB740094A21E /* TextMessageRowView.swift */, + C6F7D9D12B8D6F7E00764708 /* Rows */, C684A0262B859AAC00B751A5 /* Message Action Buttons */, ); path = Messages; @@ -7571,6 +7580,17 @@ path = CustomScrollingBehaviourProtocols; sourceTree = ""; }; + C6F7D9D12B8D6F7E00764708 /* Rows */ = { + isa = PBXGroup; + children = ( + C6D8FF202B82F3DC0094A21E /* ImageMessageRowView.swift */, + C6D8FF232B82F8FE0094A21E /* RemoteContentMessageRowView.swift */, + C6D8FF1D2B82EB740094A21E /* TextMessageRowView.swift */, + C6D8FF262B82FAFB0094A21E /* UnknownMessageRowView.swift */, + ); + path = Rows; + sourceTree = ""; + }; C6F9FBB62A25C2F900102F81 /* SubServices */ = { isa = PBXGroup; children = ( @@ -8437,6 +8457,7 @@ C64939F82B6373EC00457363 /* DeviceShakeViewModifier.swift in Sources */, C6BA746E2AD4FE1800628DC6 /* PullUpViewService+Tools.swift in Sources */, C6124CF029253BB0005E6537 /* ImportExistingExternalWalletPresenter.swift in Sources */, + C6F7D9CF2B8D6EFC00764708 /* MessageActionReplyButtonView.swift in Sources */, 299713C828F692D700743003 /* ABIError.swift in Sources */, C6C8F8552B217EAE00A9834D /* NetworkReachabilityServiceProtocol.swift in Sources */, C61B3E90283E708500500B6D /* EnterEmailVerificationCodeViewController.swift in Sources */, @@ -8643,6 +8664,7 @@ C6D6E5452819388D008C66BB /* BaseCollectionViewPresenterProtocol.swift in Sources */, C685D81A2AA978DE00212879 /* UDBTSearchView.swift in Sources */, C61002EA2940936F00462983 /* UIGestureRecognizer.swift in Sources */, + C6FAED892B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift in Sources */, 29EDB61E28FED24D00A0BD08 /* NetworkService+ProfilesApi.swift in Sources */, C689C1772ADE693900AA0186 /* LaunchDarklyService.swift in Sources */, C66804C9280D9EC8007E6390 /* PasscodeInputView.swift in Sources */, @@ -8770,6 +8792,7 @@ C63095EB2B0DA66400205054 /* UDWalletSigner.swift in Sources */, C6526DCF29D2DDFD00D6F2EB /* LoadingParkedDomainsInAppViewPresenter.swift in Sources */, C6C75C692848E82000DD8E3F /* Sequence.swift in Sources */, + C6F7D9D32B8D766500764708 /* ChatReplyInfoView.swift in Sources */, C686BF172A5FE8870036C7C1 /* MessagingFilesService.swift in Sources */, C6098322282B8BA300546392 /* WalletViewTransactionsAction.swift in Sources */, C695C2682A57F99700B94DA8 /* PushChatsSecretKeysStorage.swift in Sources */, @@ -9353,6 +9376,7 @@ C6D646062B1DC02300D724AC /* UDTitleLabel.swift in Sources */, C60CB4F12B2020DD007FD3CF /* UIImage+Preview.swift in Sources */, C6C8F9A72B2191CC00A9834D /* PreviewGIFAnimationsService.swift in Sources */, + C6F7D9D02B8D6EFC00764708 /* MessageActionReplyButtonView.swift in Sources */, C6C8F92C2B2183C700A9834D /* DomainsCollectionListCell.swift in Sources */, C618080F2B19AA420032E543 /* UserDataServiceProtocol.swift in Sources */, C618083A2B19AF800032E543 /* PreviewExternalEventsService.swift in Sources */, @@ -9493,6 +9517,7 @@ C6D647422B1EDA2F00D724AC /* Chat.swift in Sources */, C6C8F8E42B21836400A9834D /* LoadingParkedDomainsOnboardingViewPresenter.swift in Sources */, C6C8F96D2B21880E00A9834D /* ExternalWalletConnectionService.swift in Sources */, + C6F7D9D42B8D766500764708 /* ChatReplyInfoView.swift in Sources */, C6C8F8BD2B2182E400A9834D /* SetupReverseResolutionViewController.swift in Sources */, C6DA0B792B7C9DBA009920B5 /* MessageReactionDescription.swift in Sources */, C61808372B19AF120032E543 /* UITextField.swift in Sources */, @@ -10007,6 +10032,7 @@ C6D645C02B1DBD2500D724AC /* KeyboardService.swift in Sources */, C6C8F9462B2184E000A9834D /* MockEntitiesFabric.swift in Sources */, C6D646E12B1ED49300D724AC /* ShareDomainImagePullUpView.swift in Sources */, + C6FAED8A2B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift in Sources */, C688C1992B84840700BD233A /* ChatCommonEmptyView.swift in Sources */, C6C8F8802B21827700A9834D /* LoginViewPresenter.swift in Sources */, C6C8F8882B21829000A9834D /* SettingsFooterView.swift in Sources */, diff --git a/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift b/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift index 2c99f9eb1..c4dcb56e4 100644 --- a/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift +++ b/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift @@ -299,7 +299,7 @@ extension MockEntitiesFabric { image: UIImage?, isThisUser: Bool, deliveryState: MessagingChatMessageDisplayInfo.DeliveryState = .delivered) -> MessagingChatMessageDisplayInfo { - let sender = chatSenderFor(isThisUser: isThisUser) + let sender = chatSenderFor(isThisUser: false) var imageDetails = MessagingChatMessageImageBase64TypeDisplayInfo(base64: "") imageDetails.image = image diff --git a/unstoppable-ios-app/domains-manager-ios/Extensions/Extension-String+Preview.swift b/unstoppable-ios-app/domains-manager-ios/Extensions/Extension-String+Preview.swift index 596fa6d60..a7cd32fbe 100644 --- a/unstoppable-ios-app/domains-manager-ios/Extensions/Extension-String+Preview.swift +++ b/unstoppable-ios-app/domains-manager-ios/Extensions/Extension-String+Preview.swift @@ -218,6 +218,7 @@ extension String { static let more = "MORE" static let home = "HOME" static let messages = "MESSAGES" + static let reply = "REPLY" //Onboarding static let alreadyMintedDomain = "ALREADY_MINTED_DOMAIN" diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/Chat.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/Chat.swift index 030af497b..abf1cc906 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/Chat.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/Chat.swift @@ -57,5 +57,6 @@ extension Chat { case sendReaction(content: String, toMessage: MessagingChatMessageDisplayInfo) case saveImage(UIImage) case blockUserInGroup(MessagingChatUserDisplayInfo) + case reply(MessagingChatMessageDisplayInfo) } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatReplyInfoView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatReplyInfoView.swift new file mode 100644 index 000000000..49c7d69ab --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatReplyInfoView.swift @@ -0,0 +1,101 @@ +// +// ChatReplyInfoView.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 27.02.2024. +// + +import SwiftUI + +struct ChatReplyInfoView: View { + + @EnvironmentObject var viewModel: ChatViewModel + + let messageToReply: MessagingChatMessageDisplayInfo + + var body: some View { + HStack(spacing: 20) { + replyIndicatorView() + Line(direction: .vertical) + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(width: 1) + .padding(.init(vertical: 4)) + clickableMessageDescriptionView() + Spacer() + removeReplyView() + } + .foregroundStyle(Color.foregroundAccent) + .frame(height: 40) + .padding(.init(horizontal: 16)) + .background(.regularMaterial) + } +} + +// MARK: - Private methods +private extension ChatReplyInfoView { + func getNameOfMessageSender() -> String { + messageToReply.senderType.userDisplayInfo.displayName + } + + func getMessageContentDescription() -> String { + messageToReply.type.getContentDescriptionText() + } +} + +// MARK: - Private methods +private extension ChatReplyInfoView { + @ViewBuilder + func clickableMessageDescriptionView() -> some View { + Button { + UDVibration.buttonTap.vibrate() + withAnimation { + viewModel.didTapJumpToReplyButton() + } + } label: { + messageDescriptionView() + } + .buttonStyle(.plain) + } + + @ViewBuilder + func messageDescriptionView() -> some View { + VStack(alignment: .leading) { + HStack { + Text("Reply to \(getNameOfMessageSender())") + .font(.currentFont(size: 14, weight: .medium)) + Spacer() + } + HStack { + Text(getMessageContentDescription()) + .foregroundStyle(Color.foregroundDefault) + .font(.currentFont(size: 14)) + Spacer() + } + } + .multilineTextAlignment(.leading) + .lineLimit(1) + } + + @ViewBuilder + func replyIndicatorView() -> some View { + Image(systemName: "arrowshape.turn.up.left") + .font(.title2) + } + + @ViewBuilder + func removeReplyView() -> some View { + Button { + UDVibration.buttonTap.vibrate() + withAnimation { + viewModel.didTapRemoveReplyButton() + } + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + } +} + +#Preview { + ChatReplyInfoView(messageToReply: MockEntitiesFabric.Messaging.createTextMessage(text: "Hello kjsdfh dflj hsdfkjhsdkf hsdkj fh skdjfh sdkjfh", isThisUser: false)) +} diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatView.swift index 3c1571830..be8341ecf 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatView.swift @@ -185,8 +185,14 @@ private extension ChatView { if !viewModel.suggestingUsers.isEmpty { mentionSuggestionsView() } - messageInputView() - .background(.regularMaterial) + + VStack(spacing: 0) { + if let messageToReply = viewModel.messageToReply { + ChatReplyInfoView(messageToReply: messageToReply) + } + messageInputView() + .background(.regularMaterial) + } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatViewModel.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatViewModel.swift index 7549b5555..cccaab774 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatViewModel.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatViewModel.swift @@ -11,6 +11,8 @@ import Combine @MainActor final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { + typealias LoadMoreMessagesTask = Task<[MessagingChatMessageDisplayInfo], Error> + private let profile: MessagingChatUserProfileDisplayInfo private let messagingService: MessagingServiceProtocol private let featureFlagsService: UDFeatureFlagsServiceProtocol @@ -31,11 +33,13 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { @Published private(set) var navActions: [ChatView.NavAction] = [] @Published private(set) var titleType: ChatNavTitleView.TitleType = .walletAddress("") @Published private(set) var suggestingUsers: [MessagingChatUserDisplayInfo] = [] + @Published private(set) var messageToReply: MessagingChatMessageDisplayInfo? @Published var input: String = "" @Published var keyboardFocused: Bool = false @Published var error: Error? var isGroupChatMessage: Bool { conversationState.isGroupConversation } + var isAbleToReply: Bool { isGroupChatMessage } var analyticsName: Analytics.ViewName { .chatDialog } @@ -43,6 +47,7 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { private let serialQueue = DispatchQueue(label: "com.unstoppable.chat.view.serial") private var messagesToReactions: [String : Set] = [:] private var cancellables: Set = [] + private var loadMoreMessagesTask: LoadMoreMessagesTask? init(profile: MessagingChatUserProfileDisplayInfo, conversationState: MessagingChatConversationState, @@ -63,6 +68,11 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { self?.scrollToBottom() } }.store(in: &cancellables) + $messageToReply.sink { [weak self] _ in + DispatchQueue.main.async { + self?.setIfUserCanSendAttachments() + } + }.store(in: &cancellables) chatState = .loading setupTitle() setupPlaceholder() @@ -88,8 +98,7 @@ extension ChatViewModel { } if messageIndex >= (messages.count - Constants.numberOfUnreadMessagesBeforePrefetch), - let last = messagesCache.lazy.sorted(by: { $0.time > $1.time }).last, - !last.isFirstInChat { + let last = getLatestMessageToLoadMore() { loadMoreMessagesBefore(message: last) } } @@ -167,6 +176,9 @@ extension ChatViewModel { } case .sendReaction(let content, let toMessage): sendReactionMessage(content, toMessage: toMessage) + case .reply(let message): + messageToReply = message + keyboardFocused = true } } @@ -192,10 +204,43 @@ extension ChatViewModel { replaceCurrentInputWithSelectedMention(mention) } } + + func didTapJumpToReplyButton() { + scrollToMessage = messageToReply + } + + func didTapRemoveReplyButton() { + messageToReply = nil + } + + func getReferenceMessageWithId(_ messageId: String) -> MessagingChatMessageDisplayInfo? { + if let message = messages.first(where: { $0.id == messageId }) { + return message + } else { + loadMessagesToReach(messageId: messageId) + return nil + } + } + + func didTapJumpToMessage(_ message: MessagingChatMessageDisplayInfo) { + scrollToMessage = message + } } // MARK: - Private methods private extension ChatViewModel { + func getLastMessageInCache() -> MessagingChatMessageDisplayInfo? { + messagesCache.lazy.sorted(by: { $0.time > $1.time }).last + } + + func getLatestMessageToLoadMore() -> MessagingChatMessageDisplayInfo? { + if let message = getLastMessageInCache(), + !message.isFirstInChat { + return message + } + return nil + } + func showMentionSuggestions(using listOfGroupParticipants: [MessagingChatUserDisplayInfo], mention: MessageMentionString) { let mentionUsername = mention.mentionWithoutPrefix.lowercased() @@ -357,12 +402,18 @@ private extension ChatViewModel { } func setIfUserCanSendAttachments() { + let isReplying = self.messageToReply != nil let isProfileHasDomain = appContext.walletsDataService.wallets.findWithAddress(profile.wallet)?.rrDomain != nil - if isCommunityChat() { - let isCommunityMediaEnabled = featureFlagsService.valueFor(flag: .communityMediaEnabled) - canSendAttachments = isCommunityMediaEnabled && isProfileHasDomain + if !isReplying, + isProfileHasDomain { + if isCommunityChat() { + let isCommunityMediaEnabled = featureFlagsService.valueFor(flag: .communityMediaEnabled) + canSendAttachments = isCommunityMediaEnabled + } else { + canSendAttachments = true + } } else { - canSendAttachments = isProfileHasDomain + canSendAttachments = false } } @@ -407,10 +458,9 @@ private extension ChatViewModel { isLoadingMessages = true Task { do { - let unreadMessages = try await messagingService.getMessagesForChat(chat, - before: message, - cachedOnly: false, - limit: fetchLimit) + let unreadMessages = try await createTaskAndLoadMoreMessagesIn(chat: chat, + beforeMessage: message) + await addMessages(unreadMessages, scrollToBottom: false) } catch { self.error = error @@ -418,6 +468,45 @@ private extension ChatViewModel { isLoadingMessages = false } } + + func loadMessagesToReach(messageId: String) { + guard case .existingChat(let chat) = conversationState else { return } + + Task { + isLoadingMessages = true + + while messages.first(where: { $0.id == messageId }) == nil { + guard let lastMessage = getLatestMessageToLoadMore() else { return } + + do { + + let newMessages = try await createTaskAndLoadMoreMessagesIn(chat: chat, + beforeMessage: lastMessage) + + await addMessages(newMessages, scrollToBottom: false) + } catch { break } + } + + isLoadingMessages = false + } + } + + func createTaskAndLoadMoreMessagesIn(chat: MessagingChatDisplayInfo, + beforeMessage: MessagingChatMessageDisplayInfo) async throws -> [MessagingChatMessageDisplayInfo]{ + if let loadMoreMessagesTask { + return try await loadMoreMessagesTask.value + } + let task: Task<[MessagingChatMessageDisplayInfo], Error> = Task { + try await messagingService.getMessagesForChat(chat, + before: beforeMessage, + cachedOnly: false, + limit: fetchLimit) + } + self.loadMoreMessagesTask = task + let result = try await task.value + loadMoreMessagesTask = nil + return result + } func reloadCachedMessages() { Task { @@ -911,7 +1000,7 @@ private extension ChatViewModel { func sendTextMesssage(_ text: String) { let textTypeDetails = MessagingChatMessageTextTypeDisplayInfo(text: text) let messageType = MessagingChatMessageDisplayType.text(textTypeDetails) - sendMessageOfType(messageType) + wrapMessageInReplyIfNeededAndSend(messageType: messageType) } func sendReactionMessage(_ content: String, toMessage: MessagingChatMessageDisplayInfo) { @@ -926,9 +1015,21 @@ private extension ChatViewModel { sendMessageOfType(.imageData(imageTypeDetails)) } + func wrapMessageInReplyIfNeededAndSend(messageType: MessagingChatMessageDisplayType) { + if let messageToReply { + let replyDetails = MessagingChatMessageReplyTypeDisplayInfo(contentType: messageType, + messageId: messageToReply.id) + let replyType = MessagingChatMessageDisplayType.reply(replyDetails) + sendMessageOfType(replyType) + } else { + sendMessageOfType(messageType) + } + } + func sendMessageOfType(_ type: MessagingChatMessageDisplayType) { logAnalytic(event: .willSendMessage, parameters: [.messageType: type.analyticName]) + self.messageToReply = nil Task { do { var newMessage: MessagingChatMessageDisplayInfo @@ -985,7 +1086,7 @@ extension ChatViewModel: MessagingServiceListener { nonisolated func messagingDataTypeDidUpdated(_ messagingDataType: MessagingDataType) { Task { @MainActor in switch messagingDataType { - case .chats(let chats, let profile): + case .chats: return case .messagesAdded(let messages, let chatId, let userId): if userId == self.profile.id, diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionBlockUserButtonView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionBlockUserButtonView.swift index 3367802d4..f36ae97eb 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionBlockUserButtonView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionBlockUserButtonView.swift @@ -14,10 +14,12 @@ struct MessageActionBlockUserButtonView: View { let sender: MessagingChatSender var body: some View { - Button(role: .destructive) { - viewModel.handleChatMessageAction(.blockUserInGroup(sender.userDisplayInfo)) - } label: { - Label(String.Constants.blockUser.localized(), systemImage: "xmark.circle") + if !sender.isThisUser { + Button(role: .destructive) { + viewModel.handleChatMessageAction(.blockUserInGroup(sender.userDisplayInfo)) + } label: { + Label(String.Constants.blockUser.localized(), systemImage: "xmark.circle") + } } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionReplyButtonView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionReplyButtonView.swift new file mode 100644 index 000000000..0717dfc10 --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionReplyButtonView.swift @@ -0,0 +1,30 @@ +// +// MessageActionReplyButtonView.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 27.02.2024. +// + +import SwiftUI + +struct MessageActionReplyButtonView: View { + + @EnvironmentObject var viewModel: ChatViewModel + + let message: MessagingChatMessageDisplayInfo + + var body: some View { + if viewModel.isAbleToReply, + !message.senderType.isThisUser { + Button { + viewModel.handleChatMessageAction(.reply(message)) + } label: { + Label(String.Constants.reply.localized(), systemImage: "arrowshape.turn.up.left.fill") + } + } + } +} + +#Preview { + MessageActionReplyButtonView(message: MockEntitiesFabric.Messaging.createTextMessage(text: "Hi", isThisUser: false)) +} diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/MessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/MessageRowView.swift index 07dd6b99b..eea495fe8 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/MessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/MessageRowView.swift @@ -48,38 +48,55 @@ struct MessageRowView: View { } .frame(maxWidth: .infinity) .background(Color.clear) + .modifier(SwipeToReplyGestureModifier(message: message)) } } // MARK: - Private methods private extension MessageRowView { var isFailedMessage: Bool { - message.deliveryState == .failedToSend + message.isFailedMessage } var sender: MessagingChatSender { message.senderType } var isThisUser: Bool { sender.isThisUser } @ViewBuilder func messageContentView() -> some View { - switch message.type { - case .text(let info): - TextMessageRowView(info: info, - sender: sender, - isFailed: isFailedMessage) - case .imageData(let info): - ImageMessageRowView(image: info.image, - sender: sender) - case .imageBase64(let info): - ImageMessageRowView(image: info.image, - sender: sender) - case .remoteContent: - RemoteContentMessageRowView(sender: sender) - case .unknown(let info): - UnknownMessageRowView(message: message, - info: info, - sender: sender) - default: - Text("Hello world") + MessageRowContentView(message: message, + messageType: message.type, + referenceMessageId: nil) + } + + struct MessageRowContentView: View { + + let message: MessagingChatMessageDisplayInfo + let messageType: MessagingChatMessageDisplayType + let referenceMessageId: String? + + var body: some View { + switch messageType { + case .text(let info): + TextMessageRowView(message: message, + info: info, + referenceMessageId: referenceMessageId) + case .imageData(let info): + ImageMessageRowView(message: message, + image: info.image) + case .imageBase64(let info): + ImageMessageRowView(message: message, + image: info.image) + case .remoteContent: + RemoteContentMessageRowView(sender: message.senderType) + case .unknown(let info): + UnknownMessageRowView(message: message, + info: info) + case .reply(let info): + MessageRowContentView(message: message, + messageType: info.contentType, + referenceMessageId: info.messageId) + case .reaction(let info): + Text(info.content) + } } } @@ -267,13 +284,113 @@ private extension MessageRowView { } } +// MARK: - Private methods +private extension MessageRowView { + struct SwipeToReplyGestureModifier: ViewModifier { + + @EnvironmentObject var viewModel: ChatViewModel + + let message: MessagingChatMessageDisplayInfo + @State private var offset: CGFloat = 0 + @State private var didNotifyWithHapticForCurrentSwipeSession: Bool = false + private let offsetToStartReply: CGFloat = 100 + private var progressToStartReply: CGFloat { abs(offset) / offsetToStartReply } + + func body(content: Content) -> some View { + if message.senderType.isThisUser || !viewModel.isAbleToReply { + content + } else { + HStack(spacing: 10) { + content + replyIndicatorView() + Spacer() + } + .animation(.linear, value: offset) + .offset(x: offset) + .simultaneousGesture( + DragGesture() + .onChanged { gesture in + let translation = gesture.translation.width + calculateOffsetFor(xTranslation: translation) + } + .onEnded { _ in + didFinishSwipe() + } + ) + } + } + + @ViewBuilder + func replyIndicatorView() -> some View { + Image(systemName: "arrowshape.turn.up.left") + .foregroundStyle(.white) + .padding(.init(8)) + .background(Color.white.opacity(0.2)) + .clipShape(Circle()) + .overlay { + Circle() + .stroke(lineWidth: 0.5) + .foregroundStyle(Color.white.opacity(0.3)) + + } + .offset(y: -30) + .opacity(progressToStartReply) + } + + private func calculateOffsetFor(xTranslation: CGFloat) { + if xTranslation >= 0 { + offset = 0 + return + } + let frictionlessOffset: CGFloat = offsetToStartReply + + let absXTranslation = abs(xTranslation) + if absXTranslation <= frictionlessOffset { + offset = -absXTranslation + } else { + if !didNotifyWithHapticForCurrentSwipeSession { + Vibration.success.vibrate() + didNotifyWithHapticForCurrentSwipeSession = true + } + offset = -(frictionlessOffset + (absXTranslation - frictionlessOffset) / 3) + } + } + + private func didFinishSwipe() { + if isSwipeOffsetEnoughToReply() { + didSwipeToReply() + } + resetOffset() + didNotifyWithHapticForCurrentSwipeSession = false + } + + private func isSwipeOffsetEnoughToReply() -> Bool { + abs(offset) > offsetToStartReply + } + + private func didSwipeToReply() { + viewModel.handleChatMessageAction(.reply(message)) + } + + private func resetOffset() { + offset = .zero + } + } +} + #Preview { let reactions = MockEntitiesFabric.Reactions.reactionsToTest - let message = MockEntitiesFabric.Messaging.createTextMessage(text: "Hello @oleg.x, here's the link: https://google.com", + var message = MockEntitiesFabric.Messaging.createTextMessage(text: "Hello @oleg.x, here's the link: https://google.com", isThisUser: false, deliveryState: .delivered, reactions: reactions) + message.type = .reply(.init(contentType: message.type, messageId: "1")) return MessageRowView(message: message, isGroupChatMessage: true) + .environmentObject(ChatViewModel(profile: .init(id: "", + wallet: "", + serviceIdentifier: .push), + conversationState: MockEntitiesFabric.Messaging.existingChatConversationState(isGroup: true), + router: HomeTabRouter(profile: .wallet(MockEntitiesFabric.Wallet.mockEntities().first!)))) .padding() } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/ImageMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift similarity index 79% rename from unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/ImageMessageRowView.swift rename to unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift index a63277242..bc80803a1 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/ImageMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift @@ -11,8 +11,9 @@ struct ImageMessageRowView: View { @EnvironmentObject var viewModel: ChatViewModel + let message: MessagingChatMessageDisplayInfo var image: UIImage? - let sender: MessagingChatSender + var sender: MessagingChatSender { message.senderType } var body: some View { ZStack { @@ -32,6 +33,7 @@ struct ImageMessageRowView: View { } .clipShape(RoundedRectangle(cornerRadius: 12)) .contextMenu { + MessageActionReplyButtonView(message: message) if let image = self.image { Button { viewModel.handleChatMessageAction(.saveImage(image)) @@ -45,7 +47,7 @@ struct ImageMessageRowView: View { MessageActionBlockUserButtonView(sender: sender) } } preview: { - ImageMessageRowView(image: image, sender: sender) + ImageMessageRowView(message: message, image: image) } } } @@ -54,7 +56,7 @@ struct ImageMessageRowView: View { private extension ImageMessageRowView { func imageSize() -> CGSize { if let imageSize = image?.size { - let maxSize: CGFloat = (294/390) * UIScreen.main.bounds.width + let maxSize: CGFloat = (224/390) * UIScreen.main.bounds.width if imageSize.width > imageSize.height { let height = maxSize * (imageSize.height / imageSize.width) @@ -73,5 +75,7 @@ private extension ImageMessageRowView { } #Preview { - ImageMessageRowView(image: .appleIcon, sender: MockEntitiesFabric.Messaging.chatSenderFor(isThisUser: false)) + ImageMessageRowView(message: MockEntitiesFabric.Messaging.createImageMessage(image: .appleIcon, + isThisUser: false), + image: .appleIcon) } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/RemoteContentMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/RemoteContentMessageRowView.swift similarity index 78% rename from unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/RemoteContentMessageRowView.swift rename to unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/RemoteContentMessageRowView.swift index 0e7859e51..1333bea21 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/RemoteContentMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/RemoteContentMessageRowView.swift @@ -15,9 +15,7 @@ struct RemoteContentMessageRowView: View { ProgressView() .squareFrame(50) .contextMenu { - if !sender.isThisUser { - MessageActionBlockUserButtonView(sender: sender) - } + MessageActionBlockUserButtonView(sender: sender) } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/TextMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift similarity index 69% rename from unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/TextMessageRowView.swift rename to unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift index 7e4997dad..f5ba59a88 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/TextMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift @@ -11,13 +11,17 @@ struct TextMessageRowView: View { @EnvironmentObject var viewModel: ChatViewModel + let message: MessagingChatMessageDisplayInfo let info: MessagingChatMessageTextTypeDisplayInfo - let sender: MessagingChatSender - let isFailed: Bool + let referenceMessageId: String? + var sender: MessagingChatSender { message.senderType } @Environment(\.openURL) private var openURL var body: some View { - Text(toDetectedAttributedString(info.text)) + VStack(alignment: .leading) { + replyReferenceView() + Text(toDetectedAttributedString(info.text)) + } .padding(.init(horizontal: 12)) .padding(.init(vertical: 6)) .foregroundStyle(foregroundColor) @@ -28,12 +32,12 @@ struct TextMessageRowView: View { return .discarded }) .contextMenu { + MessageActionReplyButtonView(message: message) Button { viewModel.handleChatMessageAction(.copyText(info.text)) } label: { Label(String.Constants.copy.localized(), systemImage: "doc.on.doc") } - if !sender.isThisUser { Divider() MessageActionBlockUserButtonView(sender: sender) @@ -45,7 +49,7 @@ struct TextMessageRowView: View { // MARK: - Private methods private extension TextMessageRowView { var foregroundColor: Color { - if isFailed { + if message.isFailedMessage { return .foregroundOnEmphasisOpacity } return sender.isThisUser ? .foregroundOnEmphasis : .foregroundDefault @@ -129,8 +133,51 @@ private extension TextMessageRowView { } +// MARK: - Private methods +private extension TextMessageRowView { + @ViewBuilder + func replyReferenceView() -> some View { + if let referenceMessageId, + let message = viewModel.getReferenceMessageWithId(referenceMessageId) { + Button { + UDVibration.buttonTap.vibrate() + viewModel.didTapJumpToMessage(message) + } label: { + HStack(spacing: 2) { + Line(direction: .vertical) + .stroke(lineWidth: 6) + .foregroundStyle(Color.brandUnstoppableBlue) + .frame(width: 6) + .padding(.init(vertical: -8)) + .offset(x: -6) + .frame(height: 30) + VStack(alignment: .leading) { + Text(message.senderType.userDisplayInfo.displayName) + .font(.currentFont(size: 14, weight: .semibold)) + Text(message.type.getContentDescriptionText()) + .font(.currentFont(size: 14)) + } + .lineLimit(1) + Spacer() + } + .padding(.init(horizontal: 8, vertical: 8)) + .background(Color.white.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + + struct ReferenceWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = value + nextValue() + } + } +} + #Preview { - TextMessageRowView(info: .init(text: "Hello world"), - sender: MockEntitiesFabric.Messaging.chatSenderFor(isThisUser: false), - isFailed: true) + TextMessageRowView(message: MockEntitiesFabric.Messaging.createTextMessage(text: "Hello world", isThisUser: false), + info: .init(text: "Hello world"), + referenceMessageId: nil) } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/UnknownMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift similarity index 93% rename from unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/UnknownMessageRowView.swift rename to unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift index 3198f88a0..2ac3cfdfa 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/UnknownMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift @@ -11,7 +11,7 @@ struct UnknownMessageRowView: View { let message: MessagingChatMessageDisplayInfo let info: MessagingChatMessageUnknownTypeDisplayInfo - let sender: MessagingChatSender + var sender: MessagingChatSender { message.senderType } @State private var error: Error? var body: some View { @@ -31,9 +31,8 @@ struct UnknownMessageRowView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .displayError($error) .contextMenu { - if !sender.isThisUser { - MessageActionBlockUserButtonView(sender: sender) - } + MessageActionReplyButtonView(message: message) + MessageActionBlockUserButtonView(sender: sender) } } } @@ -108,5 +107,5 @@ private extension UnknownMessageRowView { #Preview { UnknownMessageRowView(message: MockEntitiesFabric.Messaging.createUnknownContentMessage(isThisUser: false), - info: .init(fileName: "Filename", type: "zip"), sender: MockEntitiesFabric.Messaging.chatSenderFor(isThisUser: false)) + info: .init(fileName: "Filename", type: "zip")) } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift index 08335385e..0e5e94065 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift @@ -123,7 +123,8 @@ private extension ChatListChatRowView { func getSubtitleText() -> String? { if let lastMessage = chat.lastMessage { return lastMessageTextFrom(message: lastMessage) - } else if case .community(let details) = chat.type { + } else if case .community(let details) = chat.type, + !details.isJoined { switch details.type { case .badge(let badgeDetailedInfo): let holders = badgeDetailedInfo.usage.holders @@ -135,18 +136,7 @@ private extension ChatListChatRowView { } func lastMessageTextFrom(message: MessagingChatMessageDisplayInfo) -> String { - switch message.type { - case .text(let description): - return description.text - case .imageBase64, .imageData: - return String.Constants.photo.localized() - case .unknown: - return String.Constants.messageNotSupported.localized() - case .remoteContent: - return String.Constants.messagingRemoteContent.localized() - case .reaction(let info): - return info.content - } + message.type.getContentDescriptionText() } @ViewBuilder diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListView.swift index 8fe176719..1fe48d1f1 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListView.swift @@ -50,8 +50,10 @@ struct ChatListView: View, ViewAnalyticsLogger { } .onChange(of: viewModel.isSearchActive) { keyboardFocused in setSearchFieldActive(keyboardFocused) - withAnimation { - navigationState?.isTitleVisible = !keyboardFocused + if !isOtherScreenPushed { + withAnimation { + navigationState?.isTitleVisible = !keyboardFocused + } } } .onChange(of: tabRouter.chatTabNavPath) { path in diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayInfo.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayInfo.swift index ead4254a1..636850558 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayInfo.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayInfo.swift @@ -25,7 +25,7 @@ struct MessagingChatMessageDisplayInfo: Hashable { time = Date() } switch type { - case .text, .unknown, .remoteContent, .reaction: + case .text, .unknown, .remoteContent, .reaction, .reply: return case .imageData(var info): if info.image == nil { @@ -43,6 +43,10 @@ struct MessagingChatMessageDisplayInfo: Hashable { // MARK: - Open methods extension MessagingChatMessageDisplayInfo { + var isFailedMessage: Bool { + deliveryState == .failedToSend + } + enum DeliveryState: Int { case delivered, sending, failedToSend } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayType.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayType.swift index 6ba8ba026..9df19f078 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayType.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageDisplayType.swift @@ -7,13 +7,14 @@ import Foundation -enum MessagingChatMessageDisplayType: Hashable { +indirect enum MessagingChatMessageDisplayType: Hashable { case text(MessagingChatMessageTextTypeDisplayInfo) case imageBase64(MessagingChatMessageImageBase64TypeDisplayInfo) case imageData(MessagingChatMessageImageDataTypeDisplayInfo) case unknown(MessagingChatMessageUnknownTypeDisplayInfo) case remoteContent(MessagingChatMessageRemoteContentTypeDisplayInfo) case reaction(MessagingChatMessageReactionTypeDisplayInfo) + case reply(MessagingChatMessageReplyTypeDisplayInfo) var analyticName: String { switch self { @@ -27,6 +28,28 @@ enum MessagingChatMessageDisplayType: Hashable { return "RemoteContent" case .reaction: return "Reaction" + case .reply: + return "Reply" + } + } +} + +// MARK: - Open methods +extension MessagingChatMessageDisplayType { + func getContentDescriptionText() -> String { + switch self { + case .text(let description): + return description.text + case .imageBase64, .imageData: + return String.Constants.photo.localized() + case .unknown: + return String.Constants.messageNotSupported.localized() + case .remoteContent: + return String.Constants.messagingRemoteContent.localized() + case .reaction(let info): + return info.content + case .reply(let info): + return info.contentType.getContentDescriptionText() } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReactionTypeDisplayInfo.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReactionTypeDisplayInfo.swift index 722be2a83..5d770c66f 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReactionTypeDisplayInfo.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReactionTypeDisplayInfo.swift @@ -11,4 +11,3 @@ struct MessagingChatMessageReactionTypeDisplayInfo: Hashable { let content: String let messageId: String } - diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReplyTypeDisplayInfo.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReplyTypeDisplayInfo.swift new file mode 100644 index 000000000..d89e8caa6 --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReplyTypeDisplayInfo.swift @@ -0,0 +1,13 @@ +// +// MessagingChatMessageReplyTypeDisplayInfo.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 26.02.2024. +// + +import Foundation + +struct MessagingChatMessageReplyTypeDisplayInfo: Hashable { + let contentType: MessagingChatMessageDisplayType + let messageId: String +} diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService+Chats.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService+Chats.swift index 0403c73d2..98d9d7637 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService+Chats.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService+Chats.swift @@ -70,6 +70,7 @@ extension MessagingService { group.addTask { if !apiService.capabilities.isRequiredToReloadLastMessage, let localChat = localChats.first(where: { $0.displayInfo.id == remoteChat.displayInfo.id }), + localChat.displayInfo.lastMessage != nil, localChat.isUpToDateWith(otherChat: remoteChat) { return localChat } else { diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService.swift index 668af4c4f..2c86a9cdc 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/MessagingService.swift @@ -328,6 +328,20 @@ extension MessagingService: MessagingServiceProtocol { chatMessage.displayInfo.type = loadedType await storageService.saveMessages([chatMessage]) return chatMessage.displayInfo + case .reply(var info): + switch info.contentType { + case .text, .imageData, .imageBase64, .unknown, .reaction, .reply: + return message + case .remoteContent(let remoteInfo): + let loadedType = try await apiService.loadRemoteContentFor(chatMessage, + user: profile, + serviceData: remoteInfo.serviceData, + filesService: filesService) + chatMessage.displayInfo.type = .reply(.init(contentType: loadedType, + messageId: info.messageId)) + await storageService.saveMessages([chatMessage]) + return chatMessage.displayInfo + } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEntitiesTransformer.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEntitiesTransformer.swift index 7ea7c8fe6..96a5962d6 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEntitiesTransformer.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEntitiesTransformer.swift @@ -215,6 +215,8 @@ struct PushEntitiesTransformer { return chatMessages } + private static let pgpEncryptionTypes: Set = ["pgp", "pgpv1:group"] + static func convertPushMessageToChatMessage(_ pushMessage: Push.Message, in chat: MessagingChat, pgpKey: String, @@ -261,85 +263,7 @@ struct PushEntitiesTransformer { serviceMetadata: serviceMetadata) return chatMessage } - - private static let pgpEncryptionTypes: Set = ["pgp", "pgpv1:group"] - - private static func extractPushMessageType(from pushMessage: Push.Message, - messageId: String, - userId: String, - pgpKey: String, - filesService: MessagingFilesServiceProtocol, - env: Push.ENV) async throws -> MessagingChatMessageDisplayType? { - let messageType = PushMessageType(rawValue: pushMessage.messageType) ?? .unknown - - guard let (decryptedContent, messageObj) = try? await decrypt(pushMessage: pushMessage, - pgpKey: pgpKey, - env: env) else { - return nil - } - - let type: MessagingChatMessageDisplayType - - switch messageType { - case .text: - let textDisplayInfo = MessagingChatMessageTextTypeDisplayInfo(text: decryptedContent) - type = .text(textDisplayInfo) - case .image: - guard let contentInfo = PushEnvironment.PushMessageContentResponse.objectFromJSONString(decryptedContent) else { return nil } - let base64Image = contentInfo.content - let imageBase64DisplayInfo = MessagingChatMessageImageBase64TypeDisplayInfo(base64: base64Image) - type = .imageBase64(imageBase64DisplayInfo) - case .reaction: - guard let messageObj, - let contentInfo = PushEnvironment.PushMessageReactionContent.objectFromJSONString(messageObj) else { return nil } - let messageId = contentInfo.reference.replacingOccurrences(of: "previous:", with: "") /// Push prefix - return .reaction(.init(content: contentInfo.content, messageId: messageId)) - case .meta: - guard let messageObj, - let contentInfo = PushEnvironment.PushMessageMetaContent.objectFromJSONString(messageObj) else { return nil } - - return nil - case .mediaEmbed: - guard let messageObj, - let contentInfo = PushEnvironment.PushMessageMediaEmbeddedContent.objectFromJSONString(messageObj), - let serviceData = try? contentInfo.jsonDataThrowing() else { return nil } - - - let displayInfo = MessagingChatMessageRemoteContentTypeDisplayInfo(serviceData: serviceData) - return .remoteContent(displayInfo) - default: - guard let contentInfo = PushEnvironment.PushMessageContentResponse.objectFromJSONString(decryptedContent) else { return nil } - guard let data = contentInfo.content.data(using: .utf8) else { return nil } - - let fileName = messageId + "_" + String(userId.suffix(4)) + "_" + (contentInfo.name ?? "") - try filesService.saveData(data, fileName: fileName) - let unknownDisplayInfo = MessagingChatMessageUnknownTypeDisplayInfo(fileName: fileName, - type: pushMessage.messageType, - name: contentInfo.name, - size: contentInfo.size) - type = .unknown(unknownDisplayInfo) - } - - return type - } - - private static func decrypt(pushMessage: Push.Message, - pgpKey: String, - env: Push.ENV) async throws -> (String, String?) { - - if let sessionKey = pushMessage.sessionKey, - let secretKey = PushChatsSecretKeysStorage.instance.getSecretKeyFor(sessionKey: sessionKey) { - return try Push.PushChat.decryptPrivateGroupMessage(pushMessage, - using: secretKey, - privateKeyArmored: pgpKey, - env: env) - } - return try await Push.PushChat.decryptMessage(message: pushMessage, - privateKeyArmored: pgpKey, - env: env) - } - static func convertPushMessageToWebSocketMessageEntity(_ pushMessage: Push.Message, pgpKey: String) -> MessagingWebSocketMessageEntity? { guard let senderWallet = getWalletAddressFrom(eip155String: pushMessage.fromDID), @@ -447,3 +371,109 @@ struct PushEntitiesTransformer { } } + +// MARK: - Message related methods +private extension PushEntitiesTransformer { + static func extractPushMessageType(from pushMessage: Push.Message, + messageId: String, + userId: String, + pgpKey: String, + filesService: MessagingFilesServiceProtocol, + env: Push.ENV) async throws -> MessagingChatMessageDisplayType? { + let messageType = PushMessageType(rawValue: pushMessage.messageType) ?? .unknown + + guard let (decryptedContent, messageObj) = try? await decrypt(pushMessage: pushMessage, + pgpKey: pgpKey, + env: env) else { + return nil + } + + return try parseMessageFromPushMessage(decryptedContent: decryptedContent, + messageObj: messageObj, + messageType: messageType, + messageId: messageId, + userId: userId, + filesService: filesService) + } + + static func parseMessageFromPushMessage(decryptedContent: String, + messageObj: String?, + messageType: PushMessageType, + messageId: String, + userId: String, + filesService: MessagingFilesServiceProtocol) throws -> MessagingChatMessageDisplayType? { + switch messageType { + case .text: + let textDisplayInfo = MessagingChatMessageTextTypeDisplayInfo(text: decryptedContent) + return .text(textDisplayInfo) + case .image: + guard let contentInfo = PushEnvironment.PushMessageContentResponse.objectFromJSONString(decryptedContent) else { return nil } + let base64Image = contentInfo.content + let imageBase64DisplayInfo = MessagingChatMessageImageBase64TypeDisplayInfo(base64: base64Image) + return .imageBase64(imageBase64DisplayInfo) + case .reaction: + guard let messageObj, + let contentInfo = PushEnvironment.PushMessageReactionContent.objectFromJSONString(messageObj) else { return nil } + let messageId = parseReferenceIdToMessage(from: contentInfo.reference) + return .reaction(.init(content: contentInfo.content, messageId: messageId)) + case .reply: + guard let messageObj, + let contentInfo = PushEnvironment.PushMessageReplyContent.objectFromJSONString(messageObj), + let messageType = PushMessageType(rawValue: contentInfo.content.messageType) else { return nil } + guard let contentType = try parseMessageFromPushMessage(decryptedContent: contentInfo.content.messageObj.content, + messageObj: nil, + messageType: messageType, + messageId: messageId, + userId: userId, + filesService: filesService) else { return nil } + let messageId = parseReferenceIdToMessage(from: contentInfo.reference) + + return .reply(.init(contentType: contentType, messageId: messageId)) + case .meta: + guard let messageObj, + let contentInfo = PushEnvironment.PushMessageMetaContent.objectFromJSONString(messageObj) else { return nil } + + return nil + case .mediaEmbed: + guard let messageObj, + let contentInfo = PushEnvironment.PushMessageMediaEmbeddedContent.objectFromJSONString(messageObj), + let serviceData = try? contentInfo.jsonDataThrowing() else { return nil } + + + let displayInfo = MessagingChatMessageRemoteContentTypeDisplayInfo(serviceData: serviceData) + return .remoteContent(displayInfo) + default: + guard let contentInfo = PushEnvironment.PushMessageContentResponse.objectFromJSONString(decryptedContent) else { return nil } + guard let data = contentInfo.content.data(using: .utf8) else { return nil } + + let fileName = messageId + "_" + String(userId.suffix(4)) + "_" + (contentInfo.name ?? "") + try filesService.saveData(data, fileName: fileName) + let unknownDisplayInfo = MessagingChatMessageUnknownTypeDisplayInfo(fileName: fileName, + type: messageType.rawValue, + name: contentInfo.name, + size: contentInfo.size) + return .unknown(unknownDisplayInfo) + } + } + + static func parseReferenceIdToMessage(from reference: String) -> String { + reference.replacingOccurrences(of: "previous:", with: "") /// Push prefix + } + + static func decrypt(pushMessage: Push.Message, + pgpKey: String, + env: Push.ENV) async throws -> (String, String?) { + + if let sessionKey = pushMessage.sessionKey, + let secretKey = PushChatsSecretKeysStorage.instance.getSecretKeyFor(sessionKey: sessionKey) { + return try Push.PushChat.decryptPrivateGroupMessage(pushMessage, + using: secretKey, + privateKeyArmored: pgpKey, + env: env) + } + + return try await Push.PushChat.decryptMessage(message: pushMessage, + privateKeyArmored: pgpKey, + env: env) + } +} diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEnvironment.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEnvironment.swift index c5b62558d..e9945f9d9 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEnvironment.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/Push/PushEnvironment.swift @@ -41,6 +41,25 @@ enum PushEnvironment { struct PushMessageReactionContent: Codable { let content: String let reference: String + + enum CodingKeys: String, CodingKey { + case content + case reference = "refrence" + } + } + + struct PushMessageReplyContent: Codable { + let content: ReplyContent + let reference: String + + struct ReplyContent: Codable { + let messageType: String + let messageObj: ReplyObjectContent + } + + struct ReplyObjectContent: Codable { + let content: String + } } struct PushMessageMetaContent: Codable { diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Files/MessagingFilesService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Files/MessagingFilesService.swift index 27c731f8e..647c61a00 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Files/MessagingFilesService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Files/MessagingFilesService.swift @@ -39,14 +39,7 @@ extension MessagingFilesService: MessagingFilesServiceProtocol { } func decryptedContentURLFor(message: MessagingChatMessageDisplayInfo) async -> URL? { - let fileName: String - - switch message.type { - case .text, .imageBase64, .imageData, .remoteContent, .reaction: - return nil - case .unknown(let info): - fileName = info.fileName - } + guard let fileName = getContentFilenameFor(messageType: message.type) else { return nil } if let url = getDecryptedDataURLFor(fileName: fileName) { return url @@ -158,6 +151,17 @@ private extension MessagingFilesService { func directoryPath(for directory: Directory) -> String { (NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] as NSString).appendingPathComponent(directory.rawValue) } + + func getContentFilenameFor(messageType: MessagingChatMessageDisplayType) -> String? { + switch messageType { + case .text, .imageBase64, .imageData, .remoteContent, .reaction: + return nil + case .unknown(let info): + return info.fileName + case .reply(let info): + return getContentFilenameFor(messageType: info.contentType) + } + } } // MARK: - Private methods diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift index a67242fb5..673a22472 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift @@ -681,6 +681,13 @@ private extension PushMessagingAPIService { return try await getImagePushMessageContentFrom(data: details.data, by: user) case .reaction(let details): return details.content + case .reply(let info): + let replyType = info.contentType + if case .text = replyType { + return try await getPushMessageContentFrom(displayType: replyType, + by: user) + } + throw PushMessagingAPIServiceError.canReplyOnlyWithText case .unknown, .remoteContent: throw PushMessagingAPIServiceError.unsupportedType } @@ -699,6 +706,8 @@ private extension PushMessagingAPIService { switch displayType { case .reaction(let details): return details.messageId + case .reply(let info): + return info.messageId case .text, .imageBase64, .imageData, .unknown, .remoteContent: return nil } @@ -712,6 +721,8 @@ private extension PushMessagingAPIService { return .reaction case .imageBase64, .imageData: return .mediaEmbed + case .reply: + return .reply case .unknown, .remoteContent: throw PushMessagingAPIServiceError.unsupportedType } @@ -745,6 +756,7 @@ extension PushMessagingAPIService { case blockUserInGroupChatsNotSupported case unsupportedType case groupChatWithGivenIdNotFound + case canReplyOnlyWithText case failedToDecodeServiceData case failedToConvertPushChat diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/XMTPMessagingAPIService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/XMTPMessagingAPIService.swift index ebd8ad48a..284eace3e 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/XMTPMessagingAPIService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/XMTPMessagingAPIService.swift @@ -329,7 +329,7 @@ private extension XMTPMessagingAPIService { in: conversation, client: client, by: senderWallet) - case .unknown, .remoteContent, .reaction: + case .unknown, .remoteContent, .reaction, .reply: throw XMTPServiceError.unsupportedAction } diff --git a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Storage/CoreDataMessagingStorageService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Storage/CoreDataMessagingStorageService.swift index 5f9df02cd..f325c30e1 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Storage/CoreDataMessagingStorageService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/Storage/CoreDataMessagingStorageService.swift @@ -640,12 +640,13 @@ private extension CoreDataMessagingStorageService { } // Message type - enum CoreDataMessageTypeWrapper: Int { + enum CoreDataMessageTypeWrapper: Int, Codable { case text = 0 case imageBase64 = 1 case imageData = 2 case remoteContent = 3 case reaction = 4 + case reply = 5 case unknown = 999 static func valueFor(_ messageType: MessagingChatMessageDisplayType) -> CoreDataMessageTypeWrapper { @@ -662,6 +663,8 @@ private extension CoreDataMessagingStorageService { return .remoteContent case .reaction: return .reaction + case .reply: + return .reply } } } @@ -671,38 +674,51 @@ private extension CoreDataMessagingStorageService { func getDecryptedContent() -> String? { guard let messageContent = coreDataMessage.messageContent, - let decrypted = try? decrypterService.decryptText(messageContent) else { return nil } + let decrypted = try? decryptMessageContent(messageContent) else { return nil } return decrypted } - + let decryptedContent = getDecryptedContent() + let genericMessageDetails = coreDataMessage.genericMessageDetails + return getMessageDisplayTypeFor(coreDataMessageType: coreDataMessageType, + decryptedContent: decryptedContent, + genericMessageDetails: genericMessageDetails) + } + + func decryptMessageContent(_ content: String) throws -> String { + try decrypterService.decryptText(content) + } + + func getMessageDisplayTypeFor(coreDataMessageType: CoreDataMessageTypeWrapper, + decryptedContent: String?, + genericMessageDetails: [String : Any]?) -> MessagingChatMessageDisplayType? { switch coreDataMessageType { case .text: - guard let decryptedContent = getDecryptedContent() else { return nil } + guard let decryptedContent else { return nil } let textDisplayInfo = MessagingChatMessageTextTypeDisplayInfo(text: decryptedContent) return .text(textDisplayInfo) case .imageBase64: - guard let decryptedContent = getDecryptedContent() else { return nil } + guard let decryptedContent else { return nil } let imageBase64DisplayInfo = MessagingChatMessageImageBase64TypeDisplayInfo(base64: decryptedContent) return .imageBase64(imageBase64DisplayInfo) case .imageData: - guard let decryptedContent = getDecryptedContent(), + guard let decryptedContent, let decryptedData = Data(base64Encoded: decryptedContent) else { return nil } let imageDataDisplayInfo = MessagingChatMessageImageDataTypeDisplayInfo(data: decryptedData) return .imageData(imageDataDisplayInfo) case .remoteContent: - guard let decryptedContent = getDecryptedContent(), + guard let decryptedContent, let decryptedData = Data(base64Encoded: decryptedContent) else { return nil } let remoteContentDisplayInfo = MessagingChatMessageRemoteContentTypeDisplayInfo(serviceData: decryptedData) return .remoteContent(remoteContentDisplayInfo) case .reaction: - guard let decryptedContent = getDecryptedContent(), + guard let decryptedContent, let decryptedData = CoreDataMessageReactionDetails.objectFromJSONString(decryptedContent) else { return nil } let reactionDisplayInfo = MessagingChatMessageReactionTypeDisplayInfo(content: decryptedData.content, messageId: decryptedData.messageId) return .reaction(reactionDisplayInfo) case .unknown: - guard let json = coreDataMessage.genericMessageDetails, + guard let json = genericMessageDetails, let details = CoreDataUnknownMessageDetails.objectFromJSON(json) else { return nil } let unknownDisplayInfo = MessagingChatMessageUnknownTypeDisplayInfo(fileName: details.fileName, @@ -710,6 +726,16 @@ private extension CoreDataMessagingStorageService { name: details.name, size: details.size) return .unknown(unknownDisplayInfo) + case .reply: + guard let decryptedContent, + let decryptedData = CoreDataMessageReplyDetails.objectFromJSONString(decryptedContent), + let decryptedDataContent = try? decryptMessageContent(decryptedData.content), + let displayType = getMessageDisplayTypeFor(coreDataMessageType: decryptedData.contentMessageType, + decryptedContent: decryptedDataContent, + genericMessageDetails: nil) else { return nil } + let reactionDisplayInfo = MessagingChatMessageReplyTypeDisplayInfo(contentType: displayType, + messageId: decryptedData.messageId) + return .reply(reactionDisplayInfo) } } @@ -723,30 +749,51 @@ private extension CoreDataMessagingStorageService { let coreDataMessageType = CoreDataMessageTypeWrapper.valueFor(messageType) coreDataMessage.messageType = Int64(coreDataMessageType.rawValue) + coreDataMessage.messageContent = try getEncryptedContentToSaveToCoreDataMessage(from: messageType) + coreDataMessage.genericMessageDetails = getGenericMessageDetailsToSaveToCoreDataMessage(from: messageType) + } + + func getEncryptedContentToSaveToCoreDataMessage(from messageType: MessagingChatMessageDisplayType) throws -> String { + func encryptDataContent(_ data: Data) throws -> String { + let content = data.base64EncodedString() + let encryptedContent = try decrypterService.encryptText(content) + return encryptedContent + } switch messageType { case .text(let info): - let encryptedContent = try decrypterService.encryptText(info.text) - coreDataMessage.messageContent = encryptedContent + return try decrypterService.encryptText(info.text) case .imageBase64(let info): - let encryptedContent = try decrypterService.encryptText(info.base64) - coreDataMessage.messageContent = encryptedContent + return try decrypterService.encryptText(info.base64) case .imageData(let info): - let encryptedContent = try encryptDataContent(info.data) - coreDataMessage.messageContent = encryptedContent + return try encryptDataContent(info.data) case .remoteContent(let info): - let encryptedContent = try encryptDataContent(info.serviceData) - coreDataMessage.messageContent = encryptedContent - case .unknown(let info): - coreDataMessage.genericMessageDetails = CoreDataUnknownMessageDetails(type: info.type, - fileName: info.fileName, - name: info.name, - size: info.size).jsonRepresentation() + return try encryptDataContent(info.serviceData) + case .unknown: + return "" case .reaction(let info): let content = try CoreDataMessageReactionDetails(content: info.content, messageId: info.messageId).jsonStringThrowing() - let encryptedContent = try decrypterService.encryptText(content) - coreDataMessage.messageContent = encryptedContent + return try decrypterService.encryptText(content) + case .reply(let info): + let messageContent = try getEncryptedContentToSaveToCoreDataMessage(from: info.contentType) + let contentMessageType = CoreDataMessageTypeWrapper.valueFor(info.contentType) + let content = try CoreDataMessageReplyDetails(content: messageContent, + contentMessageType: contentMessageType, + messageId: info.messageId).jsonStringThrowing() + return try decrypterService.encryptText(content) + } + } + + func getGenericMessageDetailsToSaveToCoreDataMessage(from messageType: MessagingChatMessageDisplayType) -> [String : Any]? { + switch messageType { + case .unknown(let info): + return CoreDataUnknownMessageDetails(type: info.type, + fileName: info.fileName, + name: info.name, + size: info.size).jsonRepresentation() + default: + return nil } } @@ -985,6 +1032,12 @@ private extension CoreDataMessagingStorageService { var messageId: String } + struct CoreDataMessageReplyDetails: Codable { + var content: String + var contentMessageType: CoreDataMessageTypeWrapper + var messageId: String + } + struct FileDetails: Codable { var fileName: String } diff --git a/unstoppable-ios-app/domains-manager-ios/SupportingFiles/Localization/en.lproj/Localizable.strings b/unstoppable-ios-app/domains-manager-ios/SupportingFiles/Localization/en.lproj/Localizable.strings index fd61008ca..142b45da4 100644 --- a/unstoppable-ios-app/domains-manager-ios/SupportingFiles/Localization/en.lproj/Localizable.strings +++ b/unstoppable-ios-app/domains-manager-ios/SupportingFiles/Localization/en.lproj/Localizable.strings @@ -164,6 +164,7 @@ "MORE" = "More"; "HOME" = "Home"; "MESSAGES" = "Messages"; +"REPLY" = "Reply"; /* ONBOARDING */ // Tutorial screens