From ce26c1741fc70ff821ec24e756a70fc532e4fa46 Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 11:30:41 +0700 Subject: [PATCH 01/18] Detect mentions in the group chat message Block users refactoring --- .../Chat/ChatView/ChatViewModel.swift | 186 ++++++++++++------ .../ChatView/Messages/MessageRowView.swift | 4 +- .../Messages/TextMessageRowView.swift | 62 +++++- 3 files changed, 183 insertions(+), 69 deletions(-) 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 7099dcc95..3f462565b 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 @@ -21,6 +21,7 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { @Published private(set) var isChannelEncrypted: Bool = true @Published private(set) var isAbleToContactUser: Bool = true @Published private(set) var messages: [MessagingChatMessageDisplayInfo] = [] + @Published private(set) var listOfGroupParticipants: [String] = [] @Published private(set) var scrollToMessage: MessagingChatMessageDisplayInfo? @Published private(set) var messagesCache: Set = [] @Published private(set) var isLoading = false @@ -65,6 +66,7 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { setupPlaceholder() setupFunctionality() loadAndShowData() + setListOfGroupParticipants() } } @@ -99,7 +101,12 @@ extension ChatViewModel { } func didPressUnblockButton() { - setOtherUser(blocked: false) + Task { + if case .existingChat(let chat) = conversationState, + case .private(let details) = chat.type { + try? await setUser(details.otherUser, in: chat, blocked: true) + } + } } func additionalActionPressed(_ action: MessageInputView.AdditionalAction) { @@ -154,11 +161,7 @@ extension ChatViewModel { parameters: [.chatId : chat.id, .wallet: user.wallet]) Task { - isLoading = true - try? await setGroupChatUser(user, - blocked: true, - chat: chat) - isLoading = false + try? await setUser(user, in: chat, blocked: true) } case .sendReaction(let content, let toMessage): sendReactionMessage(content, toMessage: toMessage) @@ -166,8 +169,34 @@ extension ChatViewModel { } func handleExternalLinkPressed(_ url: URL, by sender: MessagingChatSender) { + verifyAndHandleExternalLink(url, by: sender) + } +} + +// MARK: - Private methods +private extension ChatViewModel { + func verifyAndHandleExternalLink(_ url: URL, by sender: MessagingChatSender) { + if let domainName = parseMentionDomainNameFrom(url: url) { + handleMentionPressedTo(domainName: domainName) + } else { + handleOtherLinkPressed(url, by: sender) + } + } + + func handleMentionPressedTo(domainName: String) { + + } + + func parseMentionDomainNameFrom(url: URL) -> String? { + let string = url.absoluteString + if string.first == "@" { + return String(string.dropFirst()) + } + return nil + } + + func handleOtherLinkPressed(_ url: URL, by sender: MessagingChatSender) { guard case .existingChat(let chat) = conversationState else { return } - guard let view = appContext.coreAppCoordinator.topVC else { return } keyboardFocused = false @@ -175,32 +204,35 @@ extension ChatViewModel { case .thisUser: openLinkOrDomainProfile(url) case .otherUser(let otherUser): - Task { - do { - let action = try await appContext.pullUpViewService.showHandleChatLinkSelectionPullUp(in: view) - await view.dismissPullUpMenu() - - switch action { - case .handle: - openLinkOrDomainProfile(url) - case .block: - switch chat.type { - case .private: - try await messagingService.setUser(in: .chat(chat), blocked: true) - case .group, .community: - try await setGroupChatUser(otherUser, blocked: true, chat: chat) - } - - view.cNavigationController?.popViewController(animated: true) - } - } catch { } - } + handleLinkFromOtherUserPressed(url, + in: chat, + by: otherUser) } } -} + + func handleLinkFromOtherUserPressed(_ url: URL, + in chat: MessagingChatDisplayInfo, + by otherUser: MessagingChatUserDisplayInfo) { + guard let view = appContext.coreAppCoordinator.topVC else { return } -// MARK: - Private methods -private extension ChatViewModel { + Task { + do { + let action = try await appContext.pullUpViewService.showHandleChatLinkSelectionPullUp(in: view) + await view.dismissPullUpMenu() + + switch action { + case .handle: + openLinkOrDomainProfile(url) + case .block: + try await setUser(otherUser, in: chat, blocked: true) + view.cNavigationController?.popViewController(animated: true) + } + } catch { } + } + } + +// func blockUser(_ user: MessagingChatUserDisplayInfo) + func choosePhotoButtonPressed() { keyboardFocused = false guard let view = appContext.coreAppCoordinator.topVC else { return } @@ -223,6 +255,23 @@ private extension ChatViewModel { }) } + func setListOfGroupParticipants() { + if case .existingChat(let chat) = conversationState { + switch chat.type { + case .private: + return + case .group(let messagingGroupChatDetails): + setListOfGroupParticipantsFrom(users: messagingGroupChatDetails.members) + case .community(let messagingCommunitiesChatDetails): + setListOfGroupParticipantsFrom(users: messagingCommunitiesChatDetails.members) + } + } + } + + func setListOfGroupParticipantsFrom(users: [MessagingChatUserDisplayInfo]) { + self.listOfGroupParticipants = users.compactMap { $0.rrDomainName ?? $0.domainName } + } + func setupTitle() { switch conversationState { case .existingChat(let chat): @@ -535,7 +584,7 @@ private extension ChatViewModel { case .unblocked, .currentUserIsBlocked: actions.append(.init(type: .block, callback: { [weak self] in self?.logButtonPressedAnalyticEvents(button: .block) - self?.didPressBlockButton() + self?.didPressBlockPrivateChatButton(user: details.otherUser, chat: chat) })) case .bothBlocked, .otherUserIsBlocked: Void() @@ -652,26 +701,30 @@ private extension ChatViewModel { await appContext.pullUpViewService.showCommunityBlockedUsersListPullUp(communityDetails: communityDetails, by: profile, unblockCallback: { [weak self] user in - Task { - self?.isLoading = true - if let chat = try? await self?.setGroupChatUser(user, - blocked: false, - chat: chat), - case .community(let communityDetails) = chat.type { - if communityDetails.blockedUsersList.isEmpty { - await view.dismissPullUpMenu() - } else { - self?.didPressViewBlockedUsersListButton(communityDetails: communityDetails, in: chat) - } - } - self?.isLoading = false - } + self?.didPressUnblockGroupChat(user: user, in: chat) }, in: view) } } - func didPressBlockButton() { + func didPressUnblockGroupChat(user: MessagingChatUserDisplayInfo, + in chat: MessagingChatDisplayInfo) { + Task { + guard let view = appContext.coreAppCoordinator.topVC else { return } + + if let chat = try? await setUser(user, in: chat, blocked: false), + case .community(let communityDetails) = chat.type { + if communityDetails.blockedUsersList.isEmpty { + await view.dismissPullUpMenu() + } else { + didPressViewBlockedUsersListButton(communityDetails: communityDetails, in: chat) + } + } + } + } + + func didPressBlockPrivateChatButton(user: MessagingChatUserDisplayInfo, + chat: MessagingChatDisplayInfo) { Task { do { guard let view = appContext.coreAppCoordinator.topVC else { return } @@ -679,7 +732,7 @@ private extension ChatViewModel { try await appContext.pullUpViewService.showMessagingBlockConfirmationPullUp(blockUserName: conversationState.userInfo?.displayName ?? "", in: view) await view.dismissPullUpMenu() - setOtherUser(blocked: true) + try? await setUser(user, in: chat, blocked: true) } catch { } } } @@ -699,22 +752,38 @@ private extension ChatViewModel { } } - func setOtherUser(blocked: Bool) { - guard case .existingChat(let chat) = conversationState else { return } - - Task { - do { - isLoading = true - try await messagingService.setUser(in: .chat(chat), blocked: blocked) - await updateUIForChatApprovedState() - } catch { - self.error = error + @discardableResult + func setUser(_ user: MessagingChatUserDisplayInfo, + in chat: MessagingChatDisplayInfo, + blocked: Bool) async throws -> MessagingChatDisplayInfo? { + isLoading = true + + do { + let updatedChat: MessagingChatDisplayInfo? + switch chat.type { + case .private: + updatedChat = try await setPrivateChatUser(blocked: blocked, + in: chat) + case .group, .community: + updatedChat = try await setGroupChatUser(user, blocked: blocked, chat: chat) } - + await updateUIForChatApprovedState() + isLoading = false + return updatedChat + } catch { + self.error = error isLoading = false + throw error } } + @discardableResult + func setPrivateChatUser(blocked: Bool, + in chat: MessagingChatDisplayInfo) async throws -> MessagingChatDisplayInfo? { + try await messagingService.setUser(in: .chat(chat), blocked: blocked) + return nil + } + @discardableResult func setGroupChatUser(_ otherUser: MessagingChatUserDisplayInfo, blocked: Bool, @@ -741,7 +810,6 @@ private extension ChatViewModel { } else { await addMessages(Array(messagesCache), scrollToBottom: false) } - await setupBarButtons() return chat case .private: return nil 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 bc6104be1..2780c3fc9 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 @@ -269,8 +269,8 @@ private extension MessageRowView { #Preview { let reactions = MockEntitiesFabric.Reactions.reactionsToTest - let message = MockEntitiesFabric.Messaging.createTextMessage(text: "Hello world js ", - isThisUser: true, + let message = MockEntitiesFabric.Messaging.createTextMessage(text: "Hello @oleg.x, here's the link: https://google.com", + isThisUser: false, deliveryState: .delivered, reactions: reactions) return MessageRowView(message: message, 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/TextMessageRowView.swift index d1db6adc1..db0587912 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/TextMessageRowView.swift @@ -54,15 +54,16 @@ private extension TextMessageRowView { func toDetectedAttributedString(_ string: String) -> AttributedString { var attributedString = AttributedString(string) - let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue - - guard let detector = try? NSDataDetector(types: types) else { - return attributedString - } + detectAndInsertLinks(to: &attributedString) + detectAndInsertUserMentions(to: &attributedString) - let matches = detector.matches(in: string, options: [], range: NSRange(location: 0, length: string.count)) + return attributedString + } + + func detectAndInsertLinks(to attributedString: inout AttributedString) { + let linkMatches = detectLinksIn(string: NSAttributedString(attributedString).string) - for match in matches { + for match in linkMatches { let range = match.range let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: range.lowerBound) let endIndex = attributedString.index(startIndex, offsetByCharacters: range.length) @@ -70,6 +71,7 @@ private extension TextMessageRowView { if match.resultType == .link, let url = match.url { attributedString[startIndex.. [NSTextCheckingResult] { + let types = NSTextCheckingResult.CheckingType.link.rawValue | NSTextCheckingResult.CheckingType.phoneNumber.rawValue + guard let detector = try? NSDataDetector(types: types) else { return [] } + + return detector.matches(in: string, options: [], range: NSRange(location: 0, length: string.count)) + } + + func detectAndInsertUserMentions(to attributedString: inout AttributedString) { + let string = NSAttributedString(attributedString).string + let usernameMatches = detectUsernamesIn(string: string, + users: viewModel.listOfGroupParticipants) + let nsText = string as NSString + + for match in usernameMatches { + let range = match.range + let startIndex = attributedString.index(attributedString.startIndex, offsetByCharacters: range.lowerBound) + let endIndex = attributedString.index(startIndex, offsetByCharacters: range.length) + + let username = nsText.substring(with: range) + + attributedString[startIndex.. [NSTextCheckingResult] { + let pattern = "@([\\w.]+)" + let regex = try! NSRegularExpression(pattern: pattern, options: []) + let nsText = string as NSString + + let matches = regex.matches(in: string, options: [], range: NSRange(location: 0, length: string.count)) + var detectedUsers: [NSTextCheckingResult] = [] + for match in matches { + let rangeOfUsername = match.range(at: 1) + let username = nsText.substring(with: rangeOfUsername) + + if users.contains(username) { + detectedUsers.append(match) + } + } + return detectedUsers + } + } #Preview { From 40fd68f9ce7e614de301c917d63954553a898aaa Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 11:43:56 +0700 Subject: [PATCH 02/18] Handle mention pressed in text. --- .../Entities/DomainProfileLinkValidator.swift | 15 ++++--- .../Chat/ChatView/ChatViewModel.swift | 39 ++++++++++++------- .../DeepLinksService/DeepLinksService.swift | 2 +- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/unstoppable-ios-app/domains-manager-ios/Entities/DomainProfileLinkValidator.swift b/unstoppable-ios-app/domains-manager-ios/Entities/DomainProfileLinkValidator.swift index 21aae9c16..0d6f53a8a 100644 --- a/unstoppable-ios-app/domains-manager-ios/Entities/DomainProfileLinkValidator.swift +++ b/unstoppable-ios-app/domains-manager-ios/Entities/DomainProfileLinkValidator.swift @@ -35,15 +35,15 @@ struct DomainProfileLinkValidator { return nil } - static func getShowDomainProfileResultFor(url: URL) async -> ShowDomainProfileResult { + static func getShowDomainProfilePresentationDetailsFor(url: URL) async -> ShowDomainProfilePresentationDetails? { guard let components = NSURLComponents(url: url, resolvingAgainstBaseURL: true), - let domainName = getUDmeDomainName(in: components) else { return .none } + let domainName = getUDmeDomainName(in: components) else { return nil } - return await getShowDomainProfileResultFor(domainName: domainName, params: components.queryItems) + return await getShowDomainProfilePresentationDetailsFor(domainName: domainName, params: components.queryItems) } - static func getShowDomainProfileResultFor(domainName: String, - params: [URLQueryItem]?) async -> ShowDomainProfileResult { + static func getShowDomainProfilePresentationDetailsFor(domainName: String, + params: [URLQueryItem]?) async -> ShowDomainProfilePresentationDetails? { var preRequestedAction: PreRequestedProfileAction? if let params, @@ -66,11 +66,10 @@ struct DomainProfileLinkValidator { action: preRequestedAction) } - return .none + return nil } - enum ShowDomainProfileResult { - case none + enum ShowDomainProfilePresentationDetails { case showUserDomainProfile(domain: DomainDisplayInfo, wallet: WalletEntity, action: PreRequestedProfileAction?) case showPublicDomainProfile(publicDomainDisplayInfo: PublicDomainDisplayInfo, wallet: WalletEntity, action: PreRequestedProfileAction?) } 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 3f462565b..5eb42a163 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 @@ -184,7 +184,12 @@ private extension ChatViewModel { } func handleMentionPressedTo(domainName: String) { - + Task { + guard let presentationDetails = await DomainProfileLinkValidator.getShowDomainProfilePresentationDetailsFor(domainName: domainName, params: nil) else { return } + + UDVibration.buttonTap.vibrate() + await showDomainProfileWith(presentationDetails: presentationDetails) + } } func parseMentionDomainNameFrom(url: URL) -> String? { @@ -732,7 +737,7 @@ private extension ChatViewModel { try await appContext.pullUpViewService.showMessagingBlockConfirmationPullUp(blockUserName: conversationState.userInfo?.displayName ?? "", in: view) await view.dismissPullUpMenu() - try? await setUser(user, in: chat, blocked: true) + _ = (try? await setUser(user, in: chat, blocked: true)) } catch { } } } @@ -818,23 +823,27 @@ private extension ChatViewModel { func openLinkOrDomainProfile(_ url: URL) { Task { - let showDomainResult = await DomainProfileLinkValidator.getShowDomainProfileResultFor(url: url) - - switch showDomainResult { - case .none: + if let presentationDetails = await DomainProfileLinkValidator.getShowDomainProfilePresentationDetailsFor(url: url) { + await showDomainProfileWith(presentationDetails: presentationDetails) + } else { appContext.coreAppCoordinator.topVC?.openLink(.generic(url: url.absoluteString)) - case .showUserDomainProfile(let domain, let wallet, let action): - await router.showDomainProfile(domain, - wallet: wallet, - preRequestedAction: action, - dismissCallback: nil) - case .showPublicDomainProfile(let publicDomainDisplayInfo, let wallet, let action): - router.showPublicDomainProfile(of: publicDomainDisplayInfo, - by: wallet, - preRequestedAction: action) } } } + + func showDomainProfileWith(presentationDetails: DomainProfileLinkValidator.ShowDomainProfilePresentationDetails) async { + switch presentationDetails { + case .showUserDomainProfile(let domain, let wallet, let action): + await router.showDomainProfile(domain, + wallet: wallet, + preRequestedAction: action, + dismissCallback: nil) + case .showPublicDomainProfile(let publicDomainDisplayInfo, let wallet, let action): + router.showPublicDomainProfile(of: publicDomainDisplayInfo, + by: wallet, + preRequestedAction: action) + } + } } // MARK: - Images related methods diff --git a/unstoppable-ios-app/domains-manager-ios/Services/DeepLinksService/DeepLinksService.swift b/unstoppable-ios-app/domains-manager-ios/Services/DeepLinksService/DeepLinksService.swift index 7aaaf768b..51b62811e 100644 --- a/unstoppable-ios-app/domains-manager-ios/Services/DeepLinksService/DeepLinksService.swift +++ b/unstoppable-ios-app/domains-manager-ios/Services/DeepLinksService/DeepLinksService.swift @@ -94,7 +94,7 @@ private extension DeepLinksService { params: [URLQueryItem]?, receivedState: ExternalEventReceivedState) { Task { - let showDomainResult = await DomainProfileLinkValidator.getShowDomainProfileResultFor(domainName: domainName, + let showDomainResult = await DomainProfileLinkValidator.getShowDomainProfilePresentationDetailsFor(domainName: domainName, params: params) switch showDomainResult { From de28fd911383bdc0dfcb89d9a52c29d486b4bc0f Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 13:00:29 +0700 Subject: [PATCH 03/18] Working on UI for list of suggested mentions --- .../project.pbxproj | 12 ++++ .../Entities/MockEntitiesFabric.swift | 7 ++ .../ChatMentionSuggestionRowView.swift | 69 +++++++++++++++++++ .../ChatView/ChatMentionSuggestionsView.swift | 48 +++++++++++++ .../Messaging/Chat/ChatView/ChatView.swift | 42 ++++++++++- .../Chat/ChatView/ChatViewModel.swift | 8 +-- .../ChatView/InputView/MessageInputView.swift | 17 +++++ .../ChatView/Messages/MessageRowView.swift | 2 +- .../Messages/TextMessageRowView.swift | 3 +- .../ChatListRows/ChatListChatRowView.swift | 9 +-- .../ChatListRows/ChatListUserRowView.swift | 6 +- .../MessagingChatUserDisplayInfo.swift | 6 +- 12 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift diff --git a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj index 7bab4e2e4..31ff805c2 100644 --- a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj +++ b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj @@ -2248,6 +2248,10 @@ C6F9FBDE2A25C5AF00102F81 /* PushMessagingChannelsWebSocketsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F9FBDD2A25C5AF00102F81 /* PushMessagingChannelsWebSocketsService.swift */; }; C6F9FBE32A25C5C600102F81 /* CoreDataMessagingStorageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F9FBE22A25C5C600102F81 /* CoreDataMessagingStorageService.swift */; }; C6F9FBE82A25CB5800102F81 /* PushMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F9FBE72A25CB5800102F81 /* PushMessageType.swift */; }; + C6FAED802B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */; }; + C6FAED812B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */; }; + C6FAED832B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */; }; + C6FAED842B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.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 */; }; @@ -3590,6 +3594,8 @@ C6F9FBDD2A25C5AF00102F81 /* PushMessagingChannelsWebSocketsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessagingChannelsWebSocketsService.swift; sourceTree = ""; }; C6F9FBE22A25C5C600102F81 /* CoreDataMessagingStorageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataMessagingStorageService.swift; sourceTree = ""; }; C6F9FBE72A25CB5800102F81 /* PushMessageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessageType.swift; sourceTree = ""; }; + C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMentionSuggestionRowView.swift; sourceTree = ""; }; + C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMentionSuggestionsView.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 = ""; }; @@ -6291,6 +6297,8 @@ children = ( C630E4A82B7F4959008F3269 /* ChatViewModel.swift */, C630E4A52B7F4918008F3269 /* ChatView.swift */, + C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */, + C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */, C6D8FF2E2B8307E70094A21E /* ChatNavTitleView.swift */, C688C1942B8483D700BD233A /* ChatMessagesEmptyView.swift */, C6D8FF2A2B8302A40094A21E /* InputView */, @@ -8897,6 +8905,7 @@ C615E0D228891BC10082490B /* DomainsCollectionSearchEmptyCell.swift in Sources */, C664B6602914FAC100A76154 /* DomainProfileNoSocialsCell.swift in Sources */, C61002A0293E075300462983 /* DomainProfileDataToClipboardCopier.swift in Sources */, + C6FAED832B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */, C6A7E1DE2B6B46F3009154F7 /* HomeWalletLinkNavigationDestination.swift in Sources */, C617FDA12B58E75B00B93433 /* WalletEntity.swift in Sources */, C630E4AC2B7F4B8D008F3269 /* FlippedUpsideDownModifier.swift in Sources */, @@ -8994,6 +9003,7 @@ C6EA6D6828B3B85D00B2785F /* MintingNotPrimaryDomainsInProgressViewPresenter.swift in Sources */, C66FCD1328450A8F009B9525 /* GIFAnimationImageView.swift in Sources */, C606863D2A30D3D300927396 /* MessagingNewsChannel.swift in Sources */, + C6FAED802B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift in Sources */, C671E3C229014CFC00A2B3A0 /* RaisedTertiaryWhiteButton.swift in Sources */, C65272662A15DEB9001A084C /* KeyboardService.swift in Sources */, C6A2D51E284DBDF000327C47 /* QRScannerSightView.swift in Sources */, @@ -9708,6 +9718,7 @@ C6C8F92F2B2183C700A9834D /* DomainsListViewController.swift in Sources */, C6B65F912B565C70006D1812 /* HomeWalletSortingSelectorView.swift in Sources */, C6C8F8D62B21834200A9834D /* UpgradeToPolygonTutorial.swift in Sources */, + C6FAED842B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */, C61807BF2B19A2CC0032E543 /* AppContextProtocol.swift in Sources */, C61807D62B19A4580032E543 /* ExternalEventsServiceProtocol.swift in Sources */, C618084E2B19B9FD0032E543 /* PreviewFirebasePurchaseDomainsService.swift in Sources */, @@ -9827,6 +9838,7 @@ C6C8F8CE2B21832F00A9834D /* MintingInProgressViewPresenter.swift in Sources */, C6C8F8622B21801700A9834D /* PrivateKeyStorage.swift in Sources */, C688C1A82B84A3A600BD233A /* MessagingImageView.swift in Sources */, + C6FAED812B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift in Sources */, C6C8F8AA2B2182CF00A9834D /* EnterEmailViewPresenter.swift in Sources */, C6D6470E2B1ED7D000D724AC /* MessagingChatMessage.swift in Sources */, C6D645752B1D721D00D724AC /* PurchaseDomainsEnterZIPCodeView.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 b378baba6..2c99f9eb1 100644 --- a/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift +++ b/unstoppable-ios-app/domains-manager-ios/Entities/MockEntitiesFabric.swift @@ -375,6 +375,13 @@ extension MockEntitiesFabric { let pfpURL: URL? = !withPFP ? nil : MockEntitiesFabric.remoteImageURL return MessagingChatUserDisplayInfo(wallet: wallet, domainName: domainName, pfpURL: pfpURL) } + + static func suggestingGroupChatMembersDisplayInfo() -> [MessagingChatUserDisplayInfo] { + [.init(wallet: "0x1"), + .init(wallet: "0x2", domainName: "domain_oleg.x"), + .init(wallet: "0x3", rrDomainName: "rr_domain_nick.crypto", pfpURL: MockEntitiesFabric.remoteImageURL), + .init(wallet: "0x4", domainName: "domain_daniil.x", rrDomainName: "rr_domain_daniil.x", pfpURL: MockEntitiesFabric.remoteImageURL)] + } } enum Wallet { diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift new file mode 100644 index 000000000..b596429b3 --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift @@ -0,0 +1,69 @@ +// +// ChatMentionSuggestionRowView.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 26.02.2024. +// + +import SwiftUI + +struct ChatMentionSuggestionRowView: View { + + let user: MessagingChatUserDisplayInfo + + @State private var avatar: UIImage? + + var body: some View { + HStack(spacing: 12) { + avatarView() + titleView() + + Spacer() + } + .frame(height: 36) + .onAppear(perform: onAppear) + } +} + +// MARK: - Private methods +private extension ChatMentionSuggestionRowView { + func onAppear() { + loadAvatar() + } + + func loadAvatar() { + let userInfo = user + Task { + let name = userInfo.displayName + avatar = await appContext.imageLoadingService.loadImage(from: .initials(name, + size: .default, + style: .accent), + downsampleDescription: nil) + + if let image = await appContext.imageLoadingService.loadImage(from: .messagingUserPFPOrInitials(userInfo, + size: .default), + downsampleDescription: .icon) { + avatar = image + } + } + } + + @ViewBuilder + func avatarView() -> some View { + UIImageBridgeView(image: avatar, + width: 20, + height: 20) + .squareFrame(24) + } + + @ViewBuilder + func titleView() -> some View { + Text(user.displayName) + .font(.currentFont(size: 17, weight: .semibold)) + .lineLimit(1) + } +} + +#Preview { + ChatMentionSuggestionRowView(user: MockEntitiesFabric.Messaging.messagingChatUserDisplayInfo(withPFP: true)) +} diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift new file mode 100644 index 000000000..d63db67cc --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift @@ -0,0 +1,48 @@ +// +// ChatMentionSuggestionsView.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 26.02.2024. +// + +import SwiftUI + +struct ChatMentionSuggestionsView: View { + + let suggestingUsers: [MessagingChatUserDisplayInfo] + + private let maximumNumberOfVisibleSuggestingUsers = 6 + + var body: some View { + VStack(alignment: .leading) { + ForEach(suggestingUsersToDisplay, + id: \.wallet) { user in + selectableRowViewFor(user: user) + } + } + .padding() + .background(Color.backgroundDefault) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Private methods +private extension ChatMentionSuggestionsView { + var suggestingUsersToDisplay: [MessagingChatUserDisplayInfo] { + Array(suggestingUsers.prefix(maximumNumberOfVisibleSuggestingUsers)) + } + + @ViewBuilder + func selectableRowViewFor(user: MessagingChatUserDisplayInfo) -> some View { + Button { + UDVibration.buttonTap.vibrate() + } label: { + ChatMentionSuggestionRowView(user: user) + } + .buttonStyle(.plain) + } +} + +#Preview { + ChatMentionSuggestionsView(suggestingUsers: MockEntitiesFabric.Messaging.suggestingGroupChatMembersDisplayInfo()) +} 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 165327514..73e7f03ef 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 @@ -12,6 +12,8 @@ struct ChatView: View, ViewAnalyticsLogger { @EnvironmentObject var navigationState: NavigationStateManager @StateObject var viewModel: ChatViewModel @FocusState var focused: Bool + @State private var suggestingUsers: [MessagingChatUserDisplayInfo] = MockEntitiesFabric.Messaging.suggestingGroupChatMembersDisplayInfo() + var analyticsName: Analytics.ViewName { .chatDialog } var additionalAppearAnalyticParameters: Analytics.EventParameters { [:] } @@ -36,6 +38,9 @@ struct ChatView: View, ViewAnalyticsLogger { focused = keyboardFocused } } + .onChange(of: viewModel.input) { newValue in + showMentionSuggestionsIfNeeded(in: newValue) + } .toolbar { if !viewModel.navActions.isEmpty { ToolbarItem(placement: .topBarTrailing) { @@ -47,7 +52,6 @@ struct ChatView: View, ViewAnalyticsLogger { if hasBottomView { bottomView() .frame(maxWidth: .infinity) - .background(.regularMaterial) } } .onAppear(perform: onAppear) @@ -77,6 +81,23 @@ private extension ChatView { } } + func showMentionSuggestionsIfNeeded(in text: String) { + let listOfGroupParticipants = viewModel.listOfGroupParticipants + if !listOfGroupParticipants.isEmpty { + let components = text.components(separatedBy: " ") + if let lastComponent = components.last, + lastComponent.first == "@" { + let mentionPart = String(lastComponent.dropFirst()).lowercased() + if mentionPart.isEmpty { + suggestingUsers = listOfGroupParticipants + } else { +// suggestingNames = listOfGroupParticipants.filter { $0.contains(mentionPart)} + } + print("Mention: \(mentionPart)") + } + } + } + @ViewBuilder func chatContentView() -> some View { ScrollViewReader { proxy in @@ -171,6 +192,17 @@ private extension ChatView { @ViewBuilder func chatInputView() -> some View { + VStack { + if !suggestingUsers.isEmpty { + mentionSuggestionsView() + } + messageInputView() + .background(.regularMaterial) + } + } + + @ViewBuilder + func messageInputView() -> some View { MessageInputView(input: $viewModel.input, placeholder: viewModel.placeholder, focused: $focused, @@ -178,6 +210,12 @@ private extension ChatView { additionalActionCallback: viewModel.additionalActionPressed) } + @ViewBuilder + func mentionSuggestionsView() -> some View { + ChatMentionSuggestionsView(suggestingUsers: suggestingUsers) + .padding(.init(horizontal: 20)) + } + @ViewBuilder func inviteUserBottomView() -> some View { UDButtonView(text: String.Constants.messagingInvite.localized(), @@ -205,7 +243,7 @@ private extension ChatView { } extension ChatView { - enum State { + enum ChatState { case loading case chat case otherUserIsBlocked 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 5eb42a163..aa3ac15e7 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 @@ -21,11 +21,11 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { @Published private(set) var isChannelEncrypted: Bool = true @Published private(set) var isAbleToContactUser: Bool = true @Published private(set) var messages: [MessagingChatMessageDisplayInfo] = [] - @Published private(set) var listOfGroupParticipants: [String] = [] + @Published private(set) var listOfGroupParticipants: [MessagingChatUserDisplayInfo] = [] @Published private(set) var scrollToMessage: MessagingChatMessageDisplayInfo? @Published private(set) var messagesCache: Set = [] @Published private(set) var isLoading = false - @Published private(set) var chatState: ChatView.State = .loading + @Published private(set) var chatState: ChatView.ChatState = .loading @Published private(set) var canSendAttachments = true @Published private(set) var placeholder: String = "" @Published private(set) var navActions: [ChatView.NavAction] = [] @@ -274,7 +274,7 @@ private extension ChatViewModel { } func setListOfGroupParticipantsFrom(users: [MessagingChatUserDisplayInfo]) { - self.listOfGroupParticipants = users.compactMap { $0.rrDomainName ?? $0.domainName } + self.listOfGroupParticipants = users } func setupTitle() { @@ -295,7 +295,7 @@ private extension ChatViewModel { } func setupTitleFor(userInfo: MessagingChatUserDisplayInfo) { - if let domainName = userInfo.rrDomainName ?? userInfo.domainName { + if let domainName = userInfo.anyDomainName { titleType = .domainName(domainName) } else { titleType = .walletAddress(userInfo.wallet) diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/InputView/MessageInputView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/InputView/MessageInputView.swift index 4b2807c48..70f6f1598 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/InputView/MessageInputView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/InputView/MessageInputView.swift @@ -101,3 +101,20 @@ private extension MessageInputView { .clipShape(RoundedRectangle(cornerRadius: 12)) } } + +#Preview { + struct Preview: View { + @State var text = "" + @FocusState var focused: Bool + + var body: some View { + MessageInputView(input: $text, + placeholder: "Placeholder", + focused: $focused, + sendCallback: { }, + additionalActionCallback: { _ in }) + } + } + + return Preview() +} 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 2780c3fc9..07dd6b99b 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 @@ -152,7 +152,7 @@ private extension MessageRowView { func loadAvatarForOtherUserInfo() { let userInfo = message.senderType.userDisplayInfo Task { - let name = userInfo.domainName ?? userInfo.wallet.droppedHexPrefix + let name = userInfo.displayName otherUserAvatar = await appContext.imageLoadingService.loadImage(from: .initials(name, size: .default, style: .accent), 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/TextMessageRowView.swift index db0587912..6113ec76f 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/TextMessageRowView.swift @@ -91,8 +91,9 @@ private extension TextMessageRowView { func detectAndInsertUserMentions(to attributedString: inout AttributedString) { let string = NSAttributedString(attributedString).string + let users = viewModel.listOfGroupParticipants.compactMap { $0.anyDomainName } let usernameMatches = detectUsernamesIn(string: string, - users: viewModel.listOfGroupParticipants) + users: users) let nsText = string as NSString for match in usernameMatches { 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 6053a2b4e..69b3d8d9a 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 @@ -102,14 +102,7 @@ private extension ChatListChatRowView { var currentTitle: String { switch chat.type { case .private(let messagingPrivateChatDetails): - let userInfo = messagingPrivateChatDetails.otherUser - - - if userInfo.rrDomainName == nil { - return userInfo.displayName - } else { - return userInfo.domainName ?? "" - } + return messagingPrivateChatDetails.otherUser.displayName case .group(let messagingGroupChatDetails): return messagingGroupChatDetails.displayName case .community(let messagingCommunitiesChatDetails): diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListUserRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListUserRowView.swift index 58b6371ff..4894f2854 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListUserRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListUserRowView.swift @@ -43,11 +43,7 @@ private extension ChatListUserRowView { } var chatName: String { - if user.rrDomainName == nil { - return user.displayName - } else { - return user.rrDomainName ?? "" - } + user.displayName } func setAvatarFrom(url: URL?, name: String) { diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift index ab1f69b96..746c84756 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift @@ -14,7 +14,11 @@ struct MessagingChatUserDisplayInfo: Hashable, Codable { var pfpURL: URL? var displayName: String { - rrDomainName ?? domainName ?? wallet.walletAddressTruncated + anyDomainName ?? wallet.walletAddressTruncated + } + + var anyDomainName: String? { + rrDomainName ?? domainName } var isUDDomain: Bool { From 6d651b7b7c42b4f7fbe28ac8212cf281a6f10aef Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 13:36:49 +0700 Subject: [PATCH 04/18] Mentions refactoring Handle mention selection --- .../project.pbxproj | 6 +++ .../ChatMentionSuggestionRowView.swift | 1 + .../ChatView/ChatMentionSuggestionsView.swift | 5 ++- .../Messaging/Chat/ChatView/ChatView.swift | 43 ++++++++++++++----- .../Messages/TextMessageRowView.swift | 2 +- .../Entities/MessageMentionString.swift | 33 ++++++++++++++ .../MessagingChatUserDisplayInfo.swift | 4 ++ 7 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessageMentionString.swift diff --git a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj index 31ff805c2..f99677609 100644 --- a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj +++ b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj @@ -2252,6 +2252,8 @@ C6FAED812B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */; }; C6FAED832B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */; }; 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 */; }; 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 */; }; @@ -3596,6 +3598,7 @@ C6F9FBE72A25CB5800102F81 /* PushMessageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushMessageType.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -5420,6 +5423,7 @@ C606863C2A30D3D300927396 /* MessagingNewsChannel.swift */, C66107932A3180DA0076782F /* MessagingNewsChannelFeed.swift */, C62BDA402B5E4104008E21AD /* MessagingBlockUserInChatType.swift */, + C6FAED852B8C684700CC1844 /* MessageMentionString.swift */, C65272142A14937A001A084C /* Chats */, C65272132A149370001A084C /* Messages */, ); @@ -8268,6 +8272,7 @@ C6DDF2542B147F1A006D1F0B /* UDButtonImage.swift in Sources */, C609820A2822D92300546392 /* ToastMessageService.swift in Sources */, C6BA74742AD5013500628DC6 /* PullUpViewService+DomainProfile.swift in Sources */, + C6FAED862B8C684700CC1844 /* MessageMentionString.swift in Sources */, C6ECBF8B28D2D20900E94309 /* WalletInfoBadgeView.swift in Sources */, C6B91EB429BB718100389FF5 /* StripeService.swift in Sources */, C613C22229E3BEEC0001A3AF /* NFCService.swift in Sources */, @@ -9905,6 +9910,7 @@ C6D646DA2B1ED33400D724AC /* SocialDescription.swift in Sources */, C61807E92B19A6290032E543 /* DomainPFPInfo.swift in Sources */, C6C8F8D22B21833D00A9834D /* UBTSearchingView.swift in Sources */, + C6FAED872B8C684700CC1844 /* MessageMentionString.swift in Sources */, C6D646722B1ED11B00D724AC /* DomainProfileUpdatingRecordsData.swift in Sources */, C6D646FB2B1ED74E00D724AC /* GhostTertiaryWhiteButton.swift in Sources */, C61808702B19BC100032E543 /* ForEach+Skeleton.swift in Sources */, diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift index b596429b3..5447ff619 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionRowView.swift @@ -54,6 +54,7 @@ private extension ChatMentionSuggestionRowView { width: 20, height: 20) .squareFrame(24) + .clipShape(Circle()) } @ViewBuilder diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift index d63db67cc..c922c001c 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatMentionSuggestionsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ChatMentionSuggestionsView: View { let suggestingUsers: [MessagingChatUserDisplayInfo] + let selectionCallback: (MessagingChatUserDisplayInfo)->() private let maximumNumberOfVisibleSuggestingUsers = 6 @@ -36,6 +37,7 @@ private extension ChatMentionSuggestionsView { func selectableRowViewFor(user: MessagingChatUserDisplayInfo) -> some View { Button { UDVibration.buttonTap.vibrate() + selectionCallback(user) } label: { ChatMentionSuggestionRowView(user: user) } @@ -44,5 +46,6 @@ private extension ChatMentionSuggestionsView { } #Preview { - ChatMentionSuggestionsView(suggestingUsers: MockEntitiesFabric.Messaging.suggestingGroupChatMembersDisplayInfo()) + ChatMentionSuggestionsView(suggestingUsers: MockEntitiesFabric.Messaging.suggestingGroupChatMembersDisplayInfo(), + selectionCallback: { _ in }) } 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 73e7f03ef..dec8c04ee 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 @@ -12,7 +12,7 @@ struct ChatView: View, ViewAnalyticsLogger { @EnvironmentObject var navigationState: NavigationStateManager @StateObject var viewModel: ChatViewModel @FocusState var focused: Bool - @State private var suggestingUsers: [MessagingChatUserDisplayInfo] = MockEntitiesFabric.Messaging.suggestingGroupChatMembersDisplayInfo() + @State private var suggestingUsers: [MessagingChatUserDisplayInfo] = [] var analyticsName: Analytics.ViewName { .chatDialog } var additionalAppearAnalyticParameters: Analytics.EventParameters { [:] } @@ -39,7 +39,9 @@ struct ChatView: View, ViewAnalyticsLogger { } } .onChange(of: viewModel.input) { newValue in - showMentionSuggestionsIfNeeded(in: newValue) + withAnimation { + showMentionSuggestionsIfNeeded(in: newValue) + } } .toolbar { if !viewModel.navActions.isEmpty { @@ -86,14 +88,24 @@ private extension ChatView { if !listOfGroupParticipants.isEmpty { let components = text.components(separatedBy: " ") if let lastComponent = components.last, - lastComponent.first == "@" { - let mentionPart = String(lastComponent.dropFirst()).lowercased() - if mentionPart.isEmpty { - suggestingUsers = listOfGroupParticipants - } else { -// suggestingNames = listOfGroupParticipants.filter { $0.contains(mentionPart)} - } - print("Mention: \(mentionPart)") + let mention = MessageMentionString(string: lastComponent) { + showMentionSuggestions(using: listOfGroupParticipants, + mention: mention) + } + } + } + + func showMentionSuggestions(using listOfGroupParticipants: [MessagingChatUserDisplayInfo], + mention: MessageMentionString) { + let mentionUsername = mention.mentionWithoutPrefix.lowercased() + if mentionUsername.isEmpty { + suggestingUsers = listOfGroupParticipants + } else { + suggestingUsers = listOfGroupParticipants.filter { + let nameForMention = $0.nameForMention + let isMentionFullyTyped = nameForMention == mentionUsername + let isUsernameContainMention = nameForMention?.contains(mentionUsername) == true + return isUsernameContainMention && !isMentionFullyTyped } } } @@ -212,7 +224,16 @@ private extension ChatView { @ViewBuilder func mentionSuggestionsView() -> some View { - ChatMentionSuggestionsView(suggestingUsers: suggestingUsers) + ChatMentionSuggestionsView(suggestingUsers: suggestingUsers, + selectionCallback: { user in + if let nameForMention = user.nameForMention, + let mention = MessageMentionString.makeMentionFrom(string: nameForMention) { + var userInput = viewModel.input.components(separatedBy: " ").dropLast() + userInput.append(mention.mentionWithPrefix) + viewModel.input = userInput.joined(separator: " ") + suggestingUsers.removeAll() + } + }) .padding(.init(horizontal: 20)) } 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/TextMessageRowView.swift index 6113ec76f..7e4997dad 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/TextMessageRowView.swift @@ -110,7 +110,7 @@ private extension TextMessageRowView { } func detectUsernamesIn(string: String, users: [String]) -> [NSTextCheckingResult] { - let pattern = "@([\\w.]+)" + let pattern = "\(MessageMentionString.messageMentionPrefix)([\\w.]+)" let regex = try! NSRegularExpression(pattern: pattern, options: []) let nsText = string as NSString diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessageMentionString.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessageMentionString.swift new file mode 100644 index 000000000..b2852fcdc --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessageMentionString.swift @@ -0,0 +1,33 @@ +// +// MessageMentionString.swift +// domains-manager-ios +// +// Created by Oleg Kuplin on 26.02.2024. +// + +import Foundation + +struct MessageMentionString { + + static let messageMentionPrefix = "@" + + let mentionWithPrefix: String + let mentionWithoutPrefix: String + + init?(string: String) { + guard string.first == MessageMentionString.messageMentionPrefix.first else { return nil } + + self.mentionWithPrefix = string + self.mentionWithoutPrefix = String(string.dropFirst()) + } + + static func makeMentionFrom(string: String) -> MessageMentionString? { + if let mention = MessageMentionString(string: string) { // Check if already mention first + return mention + } + + let mentionString = messageMentionPrefix + string + return MessageMentionString(string: mentionString) + } + +} diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift index 746c84756..a86518870 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/MessagingChatUserDisplayInfo.swift @@ -28,4 +28,8 @@ struct MessagingChatUserDisplayInfo: Hashable, Codable { func getETHWallet() -> String { wallet.ethChecksumAddress() } + + var nameForMention: String? { + anyDomainName + } } From fb2edb3bd68b93185be84c3e97d729f07f3bceeb Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 14:03:21 +0700 Subject: [PATCH 05/18] Refactoring --- .../Messaging/Chat/ChatView/ChatView.swift | 51 ++++--------------- .../Chat/ChatView/ChatViewModel.swift | 46 ++++++++++++++++- 2 files changed, 54 insertions(+), 43 deletions(-) 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 dec8c04ee..18447d5ec 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 @@ -12,7 +12,6 @@ struct ChatView: View, ViewAnalyticsLogger { @EnvironmentObject var navigationState: NavigationStateManager @StateObject var viewModel: ChatViewModel @FocusState var focused: Bool - @State private var suggestingUsers: [MessagingChatUserDisplayInfo] = [] var analyticsName: Analytics.ViewName { .chatDialog } var additionalAppearAnalyticParameters: Analytics.EventParameters { [:] } @@ -38,9 +37,9 @@ struct ChatView: View, ViewAnalyticsLogger { focused = keyboardFocused } } - .onChange(of: viewModel.input) { newValue in + .onChange(of: viewModel.input) { _ in withAnimation { - showMentionSuggestionsIfNeeded(in: newValue) + viewModel.showMentionSuggestionsIfNeeded() } } .toolbar { @@ -82,34 +81,10 @@ private extension ChatView { } } } - - func showMentionSuggestionsIfNeeded(in text: String) { - let listOfGroupParticipants = viewModel.listOfGroupParticipants - if !listOfGroupParticipants.isEmpty { - let components = text.components(separatedBy: " ") - if let lastComponent = components.last, - let mention = MessageMentionString(string: lastComponent) { - showMentionSuggestions(using: listOfGroupParticipants, - mention: mention) - } - } - } - - func showMentionSuggestions(using listOfGroupParticipants: [MessagingChatUserDisplayInfo], - mention: MessageMentionString) { - let mentionUsername = mention.mentionWithoutPrefix.lowercased() - if mentionUsername.isEmpty { - suggestingUsers = listOfGroupParticipants - } else { - suggestingUsers = listOfGroupParticipants.filter { - let nameForMention = $0.nameForMention - let isMentionFullyTyped = nameForMention == mentionUsername - let isUsernameContainMention = nameForMention?.contains(mentionUsername) == true - return isUsernameContainMention && !isMentionFullyTyped - } - } - } - +} + +// MARK: - Views +private extension ChatView { @ViewBuilder func chatContentView() -> some View { ScrollViewReader { proxy in @@ -205,7 +180,7 @@ private extension ChatView { @ViewBuilder func chatInputView() -> some View { VStack { - if !suggestingUsers.isEmpty { + if !viewModel.suggestingUsers.isEmpty { mentionSuggestionsView() } messageInputView() @@ -224,16 +199,8 @@ private extension ChatView { @ViewBuilder func mentionSuggestionsView() -> some View { - ChatMentionSuggestionsView(suggestingUsers: suggestingUsers, - selectionCallback: { user in - if let nameForMention = user.nameForMention, - let mention = MessageMentionString.makeMentionFrom(string: nameForMention) { - var userInput = viewModel.input.components(separatedBy: " ").dropLast() - userInput.append(mention.mentionWithPrefix) - viewModel.input = userInput.joined(separator: " ") - suggestingUsers.removeAll() - } - }) + ChatMentionSuggestionsView(suggestingUsers: viewModel.suggestingUsers, + selectionCallback: viewModel.didSelectMentionSuggestion) .padding(.init(horizontal: 20)) } 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 aa3ac15e7..d06779845 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 @@ -30,6 +30,8 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { @Published private(set) var placeholder: String = "" @Published private(set) var navActions: [ChatView.NavAction] = [] @Published private(set) var titleType: ChatNavTitleView.TitleType = .walletAddress("") + @Published private(set) var suggestingUsers: [MessagingChatUserDisplayInfo] = [] + @Published var input: String = "" @Published var keyboardFocused: Bool = false @Published var error: Error? @@ -104,7 +106,7 @@ extension ChatViewModel { Task { if case .existingChat(let chat) = conversationState, case .private(let details) = chat.type { - try? await setUser(details.otherUser, in: chat, blocked: true) + _ = (try? await setUser(details.otherUser, in: chat, blocked: true)) } } } @@ -171,10 +173,52 @@ extension ChatViewModel { func handleExternalLinkPressed(_ url: URL, by sender: MessagingChatSender) { verifyAndHandleExternalLink(url, by: sender) } + + func showMentionSuggestionsIfNeeded() { + let listOfGroupParticipants = listOfGroupParticipants + if !listOfGroupParticipants.isEmpty { + let components = input.components(separatedBy: " ") + if let lastComponent = components.last, + let mention = MessageMentionString(string: lastComponent) { + showMentionSuggestions(using: listOfGroupParticipants, + mention: mention) + } + } + } + + func didSelectMentionSuggestion(user: MessagingChatUserDisplayInfo) { + if let nameForMention = user.nameForMention, + let mention = MessageMentionString.makeMentionFrom(string: nameForMention) { + replaceCurrentInputWithSelectedMention(mention) + } + } } // MARK: - Private methods private extension ChatViewModel { + func showMentionSuggestions(using listOfGroupParticipants: [MessagingChatUserDisplayInfo], + mention: MessageMentionString) { + let mentionUsername = mention.mentionWithoutPrefix.lowercased() + if mentionUsername.isEmpty { + suggestingUsers = listOfGroupParticipants + } else { + suggestingUsers = listOfGroupParticipants.filter { + let nameForMention = $0.nameForMention + let isMentionFullyTyped = nameForMention == mentionUsername + let isUsernameContainMention = nameForMention?.contains(mentionUsername) == true + return isUsernameContainMention && !isMentionFullyTyped + } + } + } + + func replaceCurrentInputWithSelectedMention(_ mention: MessageMentionString) { + let separator = " " + var userInput = input.components(separatedBy: separator).dropLast() + userInput.append(mention.mentionWithPrefix) + input = userInput.joined(separator: separator) + suggestingUsers.removeAll() + } + func verifyAndHandleExternalLink(_ url: URL, by sender: MessagingChatSender) { if let domainName = parseMentionDomainNameFrom(url: url) { handleMentionPressedTo(domainName: domainName) From 9a56f2057ecbdd9739b18aa91e1899af3f4d72d0 Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 26 Feb 2024 15:22:08 +0700 Subject: [PATCH 06/18] Added new message type: Reply. --- .../project.pbxproj | 6 + .../ChatListRows/ChatListChatRowView.swift | 8 +- .../MessagingChatMessageDisplayInfo.swift | 2 +- .../MessagingChatMessageDisplayType.swift | 5 +- ...ngChatMessageReactionTypeDisplayInfo.swift | 1 - ...agingChatMessageReplyTypeDisplayInfo.swift | 13 ++ .../MessagingService/MessagingService.swift | 14 ++ .../Push/PushEntitiesTransformer.swift | 186 ++++++++++-------- .../Push/PushEnvironment.swift | 14 ++ .../Files/MessagingFilesService.swift | 20 +- .../PushMessagingAPIService.swift | 7 + .../XMTPMessagingAPIService.swift | 2 +- .../CoreDataMessagingStorageService.swift | 94 ++++++--- 13 files changed, 258 insertions(+), 114 deletions(-) create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Entities/Messages/MessagingChatMessageReplyTypeDisplayInfo.swift diff --git a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj index f99677609..236a441d3 100644 --- a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj +++ b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj @@ -2254,6 +2254,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 */; }; @@ -3599,6 +3601,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 +5442,7 @@ C603D7992A56B11800CEC696 /* MessagingChatMessageUnknownTypeDisplayInfo.swift */, C6157AFD2A7108B400081574 /* MessagingChatMessageRemoteContentTypeDisplayInfo.swift */, C623967B2A288A8A00363F60 /* MessagingChatMessageDisplayType.swift */, + C6FAED882B8C717100CC1844 /* MessagingChatMessageReplyTypeDisplayInfo.swift */, C631DFA62B7B530800040221 /* MessagingChatMessageReactionTypeDisplayInfo.swift */, C62396802A288A9400363F60 /* MessagingChatMessageDisplayInfo.swift */, C62396852A288A9E00363F60 /* MessagingChatMessage.swift */, @@ -8643,6 +8647,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 */, @@ -10007,6 +10012,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/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift index 69b3d8d9a..944cc7f6d 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 @@ -121,7 +121,11 @@ private extension ChatListChatRowView { } func lastMessageTextFrom(message: MessagingChatMessageDisplayInfo) -> String { - switch message.type { + lastMessageTextFrom(messageType: message.type) + } + + func lastMessageTextFrom(messageType: MessagingChatMessageDisplayType) -> String { + switch messageType { case .text(let description): return description.text case .imageBase64, .imageData: @@ -132,6 +136,8 @@ private extension ChatListChatRowView { return String.Constants.messagingRemoteContent.localized() case .reaction(let info): return info.content + case .reply(let info): + return lastMessageTextFrom(messageType: info.contentType) } } 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..b4887cc46 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 { 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..452ac949f 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,8 @@ enum MessagingChatMessageDisplayType: Hashable { return "RemoteContent" case .reaction: return "Reaction" + case .reply: + return "Reply" } } } 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.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..64b8051d1 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 @@ -43,6 +43,20 @@ enum PushEnvironment { let reference: String } + 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 { let content: String let info: [String : AnyCodable] 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 f844bbc83..91805a30d 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 @@ -683,6 +683,9 @@ private extension PushMessagingAPIService { return try getPushMessageContentFrom(displayType: .imageBase64(imageBase64TypeDetails)) case .reaction(let details): return details.content + case .reply(let info): + let replyType = info.contentType + return try getPushMessageContentFrom(displayType: replyType) case .unknown, .remoteContent: throw PushMessagingAPIServiceError.unsupportedType } @@ -692,6 +695,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 } @@ -705,6 +710,8 @@ private extension PushMessagingAPIService { return .reaction case .imageBase64, .imageData: return .image + case .reply: + return .reply case .unknown, .remoteContent: throw PushMessagingAPIServiceError.unsupportedType } 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 5fd770588..551a3e18b 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 @@ -330,7 +330,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..0e1619e99 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 } } } @@ -675,34 +678,43 @@ private extension CoreDataMessagingStorageService { return decrypted } - + let decryptedContent = getDecryptedContent() + let genericMessageDetails = coreDataMessage.genericMessageDetails + return getMessageDisplayTypeFor(coreDataMessageType: coreDataMessageType, + decryptedContent: decryptedContent, + genericMessageDetails: genericMessageDetails) + } + + 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 +722,15 @@ private extension CoreDataMessagingStorageService { name: details.name, size: details.size) return .unknown(unknownDisplayInfo) + case .reply: + guard let decryptedContent, + let decryptedData = CoreDataMessageReplyDetails.objectFromJSONString(decryptedContent), + let displayType = getMessageDisplayTypeFor(coreDataMessageType: decryptedData.contentMessageType, + decryptedContent: decryptedData.content, + genericMessageDetails: nil) else { return nil } + let reactionDisplayInfo = MessagingChatMessageReplyTypeDisplayInfo(contentType: displayType, + messageId: decryptedData.messageId) + return .reply(reactionDisplayInfo) } } @@ -723,30 +744,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(messageType) + 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 +1027,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 } From 435c04aab1ea09d3265cbc953121c261e5db91a9 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 09:21:18 +0700 Subject: [PATCH 07/18] Added UI and connected functionality to reply --- .../project.pbxproj | 28 ++++- .../Entities/MockEntitiesFabric.swift | 2 +- .../Extensions/Extension-String+Preview.swift | 1 + .../Modules/Messaging/Chat/Chat.swift | 1 + .../Chat/ChatView/ChatReplyInfoView.swift | 101 ++++++++++++++++++ .../Messaging/Chat/ChatView/ChatView.swift | 10 +- .../Chat/ChatView/ChatViewModel.swift | 44 ++++++-- .../MessageActionReplyButtonView.swift | 27 +++++ .../ChatView/Messages/MessageRowView.swift | 15 ++- .../{ => Rows}/ImageMessageRowView.swift | 12 ++- .../RemoteContentMessageRowView.swift | 0 .../{ => Rows}/TextMessageRowView.swift | 11 +- .../{ => Rows}/UnknownMessageRowView.swift | 5 +- .../ChatListRows/ChatListChatRowView.swift | 19 +--- .../MessagingChatMessageDisplayType.swift | 20 ++++ .../PushMessagingAPIService.swift | 7 +- .../Localization/en.lproj/Localizable.strings | 1 + 17 files changed, 256 insertions(+), 48 deletions(-) create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/ChatReplyInfoView.swift create mode 100644 unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionReplyButtonView.swift rename unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/{ => Rows}/ImageMessageRowView.swift (80%) rename unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/{ => Rows}/RemoteContentMessageRowView.swift (100%) rename unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/{ => Rows}/TextMessageRowView.swift (92%) rename unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/{ => Rows}/UnknownMessageRowView.swift (96%) diff --git a/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj b/unstoppable-ios-app/domains-manager-ios.xcodeproj/project.pbxproj index 0332a3dea..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 */; }; @@ -3587,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 = ""; }; @@ -6211,6 +6217,7 @@ isa = PBXGroup; children = ( C684A0232B85990600B751A5 /* MessageActionBlockUserButtonView.swift */, + C6F7D9CE2B8D6EFC00764708 /* MessageActionReplyButtonView.swift */, ); path = "Message Action Buttons"; sourceTree = ""; @@ -6305,6 +6312,7 @@ children = ( C630E4A82B7F4959008F3269 /* ChatViewModel.swift */, C630E4A52B7F4918008F3269 /* ChatView.swift */, + C6F7D9D22B8D766500764708 /* ChatReplyInfoView.swift */, C6FAED822B8C5C1200CC1844 /* ChatMentionSuggestionsView.swift */, C6FAED7F2B8C5B4C00CC1844 /* ChatMentionSuggestionRowView.swift */, C6D8FF2E2B8307E70094A21E /* ChatNavTitleView.swift */, @@ -7318,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; @@ -7575,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 = ( @@ -8441,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 */, @@ -8775,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 */, @@ -9358,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 */, @@ -9498,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 */, 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..2440fb21a 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 @@ -31,6 +31,7 @@ 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 @@ -63,6 +64,9 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { self?.scrollToBottom() } }.store(in: &cancellables) + $messageToReply.sink { [weak self] _ in + self?.setIfUserCanSendAttachments() + }.store(in: &cancellables) chatState = .loading setupTitle() setupPlaceholder() @@ -167,6 +171,8 @@ extension ChatViewModel { } case .sendReaction(let content, let toMessage): sendReactionMessage(content, toMessage: toMessage) + case .reply(let message): + messageToReply = message } } @@ -192,6 +198,15 @@ extension ChatViewModel { replaceCurrentInputWithSelectedMention(mention) } } + + func didTapJumpToReplyButton() { + scrollToMessage = nil + scrollToMessage = messageToReply + } + + func didTapRemoveReplyButton() { + messageToReply = nil + } } // MARK: - Private methods @@ -357,12 +372,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 } } @@ -911,7 +932,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,6 +947,17 @@ 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]) @@ -985,7 +1017,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/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..eba1ccf2b --- /dev/null +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Message Action Buttons/MessageActionReplyButtonView.swift @@ -0,0 +1,27 @@ +// +// 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 { + 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..304f73a4b 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 @@ -63,21 +63,20 @@ private extension MessageRowView { func messageContentView() -> some View { switch message.type { case .text(let info): - TextMessageRowView(info: info, - sender: sender, + TextMessageRowView(message: message, + info: info, isFailed: isFailedMessage) case .imageData(let info): - ImageMessageRowView(image: info.image, - sender: sender) + ImageMessageRowView(message: message, + image: info.image) case .imageBase64(let info): - ImageMessageRowView(image: info.image, - sender: sender) + ImageMessageRowView(message: message, + image: info.image) case .remoteContent: RemoteContentMessageRowView(sender: sender) case .unknown(let info): UnknownMessageRowView(message: message, - info: info, - sender: sender) + info: info) default: Text("Hello world") } 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 80% 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..43fa75ec8 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,9 @@ struct ImageMessageRowView: View { } .clipShape(RoundedRectangle(cornerRadius: 12)) .contextMenu { + if !sender.isThisUser { + MessageActionReplyButtonView(message: message) + } if let image = self.image { Button { viewModel.handleChatMessageAction(.saveImage(image)) @@ -45,7 +49,7 @@ struct ImageMessageRowView: View { MessageActionBlockUserButtonView(sender: sender) } } preview: { - ImageMessageRowView(image: image, sender: sender) + ImageMessageRowView(message: message, image: image) } } } @@ -73,5 +77,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 100% 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 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 92% 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..1100e7461 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,8 +11,9 @@ struct TextMessageRowView: View { @EnvironmentObject var viewModel: ChatViewModel + let message: MessagingChatMessageDisplayInfo let info: MessagingChatMessageTextTypeDisplayInfo - let sender: MessagingChatSender + var sender: MessagingChatSender { message.senderType } let isFailed: Bool @Environment(\.openURL) private var openURL @@ -28,12 +29,16 @@ struct TextMessageRowView: View { return .discarded }) .contextMenu { + if !sender.isThisUser { + 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) @@ -130,7 +135,7 @@ private extension TextMessageRowView { } #Preview { - TextMessageRowView(info: .init(text: "Hello world"), - sender: MockEntitiesFabric.Messaging.chatSenderFor(isThisUser: false), + TextMessageRowView(message: MockEntitiesFabric.Messaging.createTextMessage(text: "Hello world", isThisUser: false), + info: .init(text: "Hello world"), isFailed: true) } 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 96% 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..66582d4bd 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 { @@ -32,6 +32,7 @@ struct UnknownMessageRowView: View { .displayError($error) .contextMenu { if !sender.isThisUser { + MessageActionReplyButtonView(message: message) MessageActionBlockUserButtonView(sender: sender) } } @@ -108,5 +109,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 38ba9d9a6..ffad68eb1 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 @@ -135,24 +135,7 @@ private extension ChatListChatRowView { } func lastMessageTextFrom(message: MessagingChatMessageDisplayInfo) -> String { - lastMessageTextFrom(messageType: message.type) - } - - func lastMessageTextFrom(messageType: MessagingChatMessageDisplayType) -> String { - switch messageType { - 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 lastMessageTextFrom(messageType: info.contentType) - } + message.type.getContentDescriptionText() } @ViewBuilder 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 452ac949f..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 @@ -33,3 +33,23 @@ indirect enum MessagingChatMessageDisplayType: Hashable { } } } + +// 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/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift b/unstoppable-ios-app/domains-manager-ios/Services/MessagingService/SubServices/MessagingAPI/PushMessagingAPIService.swift index c8f51184f..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 @@ -683,7 +683,11 @@ private extension PushMessagingAPIService { return details.content case .reply(let info): let replyType = info.contentType - return try getPushMessageContentFrom(displayType: replyType) + if case .text = replyType { + return try await getPushMessageContentFrom(displayType: replyType, + by: user) + } + throw PushMessagingAPIServiceError.canReplyOnlyWithText case .unknown, .remoteContent: throw PushMessagingAPIServiceError.unsupportedType } @@ -752,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/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 From ab8c4589b3ed4fc5a37e53679428faeb9c1702e6 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 09:30:07 +0700 Subject: [PATCH 08/18] Improved reply UX --- .../Modules/Messaging/Chat/ChatView/ChatViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) 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 2440fb21a..f57a3c5fb 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 @@ -173,6 +173,7 @@ extension ChatViewModel { sendReactionMessage(content, toMessage: toMessage) case .reply(let message): messageToReply = message + keyboardFocused = true } } @@ -961,6 +962,7 @@ private extension ChatViewModel { func sendMessageOfType(_ type: MessagingChatMessageDisplayType) { logAnalytic(event: .willSendMessage, parameters: [.messageType: type.analyticName]) + self.messageToReply = nil Task { do { var newMessage: MessagingChatMessageDisplayInfo From d96f048d4608df45ba98aee793f11dd5b1889a7a Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 09:50:04 +0700 Subject: [PATCH 09/18] Added swipe to reply feature --- .../ChatView/Messages/MessageRowView.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) 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 304f73a4b..b46e1a96d 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,6 +48,7 @@ struct MessageRowView: View { } .frame(maxWidth: .infinity) .background(Color.clear) + .modifier(SwipeToReplyGestureModifier(message: message)) } } @@ -266,6 +267,75 @@ 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 + private let offsetToStartReply: CGFloat = 50 + + func body(content: Content) -> some View { + if message.senderType.isThisUser { + content + } else { + content + .animation(.default, value: offset) + .offset(x: offset) + .simultaneousGesture( + DragGesture() + .onChanged { gesture in + let translation = gesture.translation.width + calculateOffsetFor(xTranslation: translation) + } + .onEnded { _ in + didFinishSwipe() + } + ) + } + } + + 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 { + offset = -(frictionlessOffset + (absXTranslation - frictionlessOffset) / 3) + } + } + + private func didFinishSwipe() { + if isSwipeOffsetEnoughToReply() { + didSwipeToReply() + } + resetOffset() + } + + private func isSwipeOffsetEnoughToReply() -> Bool { + abs(offset) > offsetToStartReply + } + + private func didSwipeToReply() { + viewModel.handleChatMessageAction(.reply(message)) + Vibration.success.vibrate() + } + + private func resetOffset() { + withAnimation { + offset = .zero + } + } + } +} + #Preview { let reactions = MockEntitiesFabric.Reactions.reactionsToTest let message = MockEntitiesFabric.Messaging.createTextMessage(text: "Hello @oleg.x, here's the link: https://google.com", From d92457e3a6607e5b529439a242a736f34fdec538 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 10:11:34 +0700 Subject: [PATCH 10/18] Updated haptic feedback logic Increased required offset Fixed attachments actions visibility when replying --- .../Chat/ChatView/ChatViewModel.swift | 4 +- .../ChatView/Messages/MessageRowView.swift | 39 +++++++++++++++---- .../Messages/Rows/ImageMessageRowView.swift | 2 +- 3 files changed, 36 insertions(+), 9 deletions(-) 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 f57a3c5fb..ff83a5d16 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 @@ -65,7 +65,9 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { } }.store(in: &cancellables) $messageToReply.sink { [weak self] _ in - self?.setIfUserCanSendAttachments() + DispatchQueue.main.async { + self?.setIfUserCanSendAttachments() + } }.store(in: &cancellables) chatState = .loading setupTitle() 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 b46e1a96d..098defab4 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 @@ -275,14 +275,20 @@ private extension MessageRowView { let message: MessagingChatMessageDisplayInfo @State private var offset: CGFloat = 0 - private let offsetToStartReply: CGFloat = 50 + @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 { content } else { - content - .animation(.default, value: offset) + HStack(spacing: 10) { + content + replyIndicatorView() + Spacer() + } + .animation(.linear, value: offset) .offset(x: offset) .simultaneousGesture( DragGesture() @@ -297,6 +303,23 @@ private extension MessageRowView { } } + @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 @@ -308,6 +331,10 @@ private extension MessageRowView { if absXTranslation <= frictionlessOffset { offset = -absXTranslation } else { + if !didNotifyWithHapticForCurrentSwipeSession { + Vibration.success.vibrate() + didNotifyWithHapticForCurrentSwipeSession = true + } offset = -(frictionlessOffset + (absXTranslation - frictionlessOffset) / 3) } } @@ -317,6 +344,7 @@ private extension MessageRowView { didSwipeToReply() } resetOffset() + didNotifyWithHapticForCurrentSwipeSession = false } private func isSwipeOffsetEnoughToReply() -> Bool { @@ -325,13 +353,10 @@ private extension MessageRowView { private func didSwipeToReply() { viewModel.handleChatMessageAction(.reply(message)) - Vibration.success.vibrate() } private func resetOffset() { - withAnimation { - offset = .zero - } + offset = .zero } } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift index 43fa75ec8..b715c39bc 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift @@ -58,7 +58,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) From 49f79c66ce4933d2a81a750ce958b2ca28a0ac2a Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 10:24:14 +0700 Subject: [PATCH 11/18] Ensure reply featrue is available --- .../Messaging/Chat/ChatView/ChatViewModel.swift | 2 +- .../MessageActionBlockUserButtonView.swift | 10 ++++++---- .../MessageActionReplyButtonView.swift | 11 +++++++---- .../Chat/ChatView/Messages/MessageRowView.swift | 2 +- .../ChatView/Messages/Rows/ImageMessageRowView.swift | 4 +--- .../Messages/Rows/RemoteContentMessageRowView.swift | 4 +--- .../ChatView/Messages/Rows/TextMessageRowView.swift | 6 +----- .../Messages/Rows/UnknownMessageRowView.swift | 6 ++---- .../Modules/Messaging/ChatsList/ChatListView.swift | 6 ++++-- 9 files changed, 24 insertions(+), 27 deletions(-) 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 ff83a5d16..94a3a9e9d 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 @@ -37,6 +37,7 @@ final class ChatViewModel: ObservableObject, ViewAnalyticsLogger { @Published var keyboardFocused: Bool = false @Published var error: Error? var isGroupChatMessage: Bool { conversationState.isGroupConversation } + var isAbleToReply: Bool { isGroupChatMessage } var analyticsName: Analytics.ViewName { .chatDialog } @@ -203,7 +204,6 @@ extension ChatViewModel { } func didTapJumpToReplyButton() { - scrollToMessage = nil scrollToMessage = messageToReply } 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 index eba1ccf2b..0717dfc10 100644 --- 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 @@ -14,10 +14,13 @@ struct MessageActionReplyButtonView: View { let message: MessagingChatMessageDisplayInfo var body: some View { - Button { - viewModel.handleChatMessageAction(.reply(message)) - } label: { - Label(String.Constants.reply.localized(), systemImage: "arrowshape.turn.up.left.fill") + if viewModel.isAbleToReply, + !message.senderType.isThisUser { + Button { + viewModel.handleChatMessageAction(.reply(message)) + } label: { + Label(String.Constants.reply.localized(), systemImage: "arrowshape.turn.up.left.fill") + } } } } 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 098defab4..9ebac4556 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 @@ -280,7 +280,7 @@ private extension MessageRowView { private var progressToStartReply: CGFloat { abs(offset) / offsetToStartReply } func body(content: Content) -> some View { - if message.senderType.isThisUser { + if message.senderType.isThisUser || !viewModel.isAbleToReply { content } else { HStack(spacing: 10) { diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift index b715c39bc..bc80803a1 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/ImageMessageRowView.swift @@ -33,9 +33,7 @@ struct ImageMessageRowView: View { } .clipShape(RoundedRectangle(cornerRadius: 12)) .contextMenu { - if !sender.isThisUser { - MessageActionReplyButtonView(message: message) - } + MessageActionReplyButtonView(message: message) if let image = self.image { Button { viewModel.handleChatMessageAction(.saveImage(image)) diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/RemoteContentMessageRowView.swift b/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/Rows/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/Rows/TextMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift index 1100e7461..23cc59fc7 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift @@ -29,16 +29,12 @@ struct TextMessageRowView: View { return .discarded }) .contextMenu { - if !sender.isThisUser { - MessageActionReplyButtonView(message: message) - } + 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) diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift index 66582d4bd..2ac3cfdfa 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/UnknownMessageRowView.swift @@ -31,10 +31,8 @@ struct UnknownMessageRowView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .displayError($error) .contextMenu { - if !sender.isThisUser { - MessageActionReplyButtonView(message: message) - MessageActionBlockUserButtonView(sender: sender) - } + MessageActionReplyButtonView(message: message) + MessageActionBlockUserButtonView(sender: sender) } } } 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 From 92b0d03e2067b5db649755ea4ff61e15ae9f4ebb Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 11:05:53 +0700 Subject: [PATCH 12/18] Implemented UI for reply message --- .../ChatView/Messages/MessageRowView.swift | 28 +++++++--- .../Messages/Rows/TextMessageRowView.swift | 52 +++++++++++++++++-- .../MessagingChatMessageDisplayInfo.swift | 4 ++ 3 files changed, 74 insertions(+), 10 deletions(-) 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 9ebac4556..be75636a4 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 @@ -55,18 +55,25 @@ struct MessageRowView: View { // 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 { + contentViewFor(messageType: message.type, + referenceMessageId: nil) + } + + @ViewBuilder + func contentViewFor(messageType: MessagingChatMessageDisplayType, + referenceMessageId: String?) -> some View { + switch messageType { case .text(let info): - TextMessageRowView(message: message, - info: info, - isFailed: isFailedMessage) + TextMessageRowView(message: message, + info: info, + referenceMessageId: referenceMessageId) case .imageData(let info): ImageMessageRowView(message: message, image: info.image) @@ -78,6 +85,9 @@ private extension MessageRowView { case .unknown(let info): UnknownMessageRowView(message: message, info: info) + case .reply(let info): + contentViewFor(messageType: info.contentType, + referenceMessageId: info.messageId) default: Text("Hello world") } @@ -363,11 +373,17 @@ private extension MessageRowView { #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/Rows/TextMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift index 23cc59fc7..c90fc1a02 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift @@ -13,12 +13,15 @@ struct TextMessageRowView: View { let message: MessagingChatMessageDisplayInfo let info: MessagingChatMessageTextTypeDisplayInfo + let referenceMessageId: String? var sender: MessagingChatSender { message.senderType } - let isFailed: Bool @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) @@ -46,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 @@ -130,8 +133,49 @@ private extension TextMessageRowView { } +// MARK: - Private methods +private extension TextMessageRowView { + @ViewBuilder + func replyReferenceView() -> some View { + if let referenceMessageId { + Button { + + } 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("Hello there") + .font(.currentFont(size: 14, weight: .semibold)) + Text("Hello there") + .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(message: MockEntitiesFabric.Messaging.createTextMessage(text: "Hello world", isThisUser: false), info: .init(text: "Hello world"), - isFailed: true) + referenceMessageId: nil) } 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 b4887cc46..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 @@ -43,6 +43,10 @@ struct MessagingChatMessageDisplayInfo: Hashable { // MARK: - Open methods extension MessagingChatMessageDisplayInfo { + var isFailedMessage: Bool { + deliveryState == .failedToSend + } + enum DeliveryState: Int { case delivered, sending, failedToSend } From e45e4e47477cb28d6aca801b19e394c6e0802974 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 11:34:07 +0700 Subject: [PATCH 13/18] Load more messages to find reference --- .../Chat/ChatView/ChatViewModel.swift | 70 ++++++++++++++++++- .../ChatView/Messages/MessageRowView.swift | 4 +- .../Messages/Rows/TextMessageRowView.swift | 10 +-- 3 files changed, 76 insertions(+), 8 deletions(-) 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 94a3a9e9d..9ea8fb1dc 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 @@ -45,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, @@ -95,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) } } @@ -210,10 +212,35 @@ extension ChatViewModel { 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() @@ -442,6 +469,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 messagingService.getMessagesForChat(chat, + before: lastMessage, + cachedOnly: false, + limit: fetchLimit) + 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 { 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 be75636a4..5598527c4 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 @@ -88,8 +88,8 @@ private extension MessageRowView { case .reply(let info): contentViewFor(messageType: info.contentType, referenceMessageId: info.messageId) - default: - Text("Hello world") + case .reaction(let info): + Text(info.content) } } diff --git a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift index c90fc1a02..f5ba59a88 100644 --- a/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift +++ b/unstoppable-ios-app/domains-manager-ios/Modules/Messaging/Chat/ChatView/Messages/Rows/TextMessageRowView.swift @@ -137,9 +137,11 @@ private extension TextMessageRowView { private extension TextMessageRowView { @ViewBuilder func replyReferenceView() -> some View { - if let referenceMessageId { + if let referenceMessageId, + let message = viewModel.getReferenceMessageWithId(referenceMessageId) { Button { - + UDVibration.buttonTap.vibrate() + viewModel.didTapJumpToMessage(message) } label: { HStack(spacing: 2) { Line(direction: .vertical) @@ -150,9 +152,9 @@ private extension TextMessageRowView { .offset(x: -6) .frame(height: 30) VStack(alignment: .leading) { - Text("Hello there") + Text(message.senderType.userDisplayInfo.displayName) .font(.currentFont(size: 14, weight: .semibold)) - Text("Hello there") + Text(message.type.getContentDescriptionText()) .font(.currentFont(size: 14)) } .lineLimit(1) From 976ca267b7d42289dafca951e7cf1b4a3786dd03 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 11:59:10 +0700 Subject: [PATCH 14/18] Fixed compile error --- .../Chat/ChatView/ChatViewModel.swift | 15 +++-- .../ChatView/Messages/MessageRowView.swift | 59 +++++++++++-------- 2 files changed, 40 insertions(+), 34 deletions(-) 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 9ea8fb1dc..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 @@ -458,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 @@ -480,10 +479,10 @@ private extension ChatViewModel { guard let lastMessage = getLatestMessageToLoadMore() else { return } do { - let newMessages = try await messagingService.getMessagesForChat(chat, - before: lastMessage, - cachedOnly: false, - limit: fetchLimit) + + let newMessages = try await createTaskAndLoadMoreMessagesIn(chat: chat, + beforeMessage: lastMessage) + await addMessages(newMessages, scrollToBottom: false) } catch { break } } 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 5598527c4..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 @@ -62,34 +62,41 @@ private extension MessageRowView { @ViewBuilder func messageContentView() -> some View { - contentViewFor(messageType: message.type, - referenceMessageId: nil) + MessageRowContentView(message: message, + messageType: message.type, + referenceMessageId: nil) } - @ViewBuilder - func contentViewFor(messageType: MessagingChatMessageDisplayType, - referenceMessageId: String?) -> 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: sender) - case .unknown(let info): - UnknownMessageRowView(message: message, - info: info) - case .reply(let info): - contentViewFor(messageType: info.contentType, - referenceMessageId: info.messageId) - case .reaction(let info): - Text(info.content) + 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) + } } } From 4ebdd8e898cd1138c19e5461a000bc6cb645235d Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 12:26:09 +0700 Subject: [PATCH 15/18] Show chat holders details only if not joined --- .../Messaging/ChatsList/ChatListRows/ChatListChatRowView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 ffad68eb1..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 From 57f18b1cf464da32abd39ab9f63c0e1ae5b6fdf0 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 13:23:10 +0700 Subject: [PATCH 16/18] Fixed core data saving of reply messages --- .../Storage/CoreDataMessagingStorageService.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 0e1619e99..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 @@ -674,7 +674,7 @@ 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 } @@ -685,6 +685,10 @@ private extension CoreDataMessagingStorageService { genericMessageDetails: genericMessageDetails) } + func decryptMessageContent(_ content: String) throws -> String { + try decrypterService.decryptText(content) + } + func getMessageDisplayTypeFor(coreDataMessageType: CoreDataMessageTypeWrapper, decryptedContent: String?, genericMessageDetails: [String : Any]?) -> MessagingChatMessageDisplayType? { @@ -725,8 +729,9 @@ private extension CoreDataMessagingStorageService { case .reply: guard let decryptedContent, let decryptedData = CoreDataMessageReplyDetails.objectFromJSONString(decryptedContent), + let decryptedDataContent = try? decryptMessageContent(decryptedData.content), let displayType = getMessageDisplayTypeFor(coreDataMessageType: decryptedData.contentMessageType, - decryptedContent: decryptedData.content, + decryptedContent: decryptedDataContent, genericMessageDetails: nil) else { return nil } let reactionDisplayInfo = MessagingChatMessageReplyTypeDisplayInfo(contentType: displayType, messageId: decryptedData.messageId) @@ -772,7 +777,7 @@ private extension CoreDataMessagingStorageService { return try decrypterService.encryptText(content) case .reply(let info): let messageContent = try getEncryptedContentToSaveToCoreDataMessage(from: info.contentType) - let contentMessageType = CoreDataMessageTypeWrapper.valueFor(messageType) + let contentMessageType = CoreDataMessageTypeWrapper.valueFor(info.contentType) let content = try CoreDataMessageReplyDetails(content: messageContent, contentMessageType: contentMessageType, messageId: info.messageId).jsonStringThrowing() From c393173ed83fd92ed710860016366345da2e2ef8 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 13:31:59 +0700 Subject: [PATCH 17/18] Fixed parsing of message reactions details --- .../Services/MessagingService/Push/PushEnvironment.swift | 5 +++++ 1 file changed, 5 insertions(+) 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 64b8051d1..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,11 @@ enum PushEnvironment { struct PushMessageReactionContent: Codable { let content: String let reference: String + + enum CodingKeys: String, CodingKey { + case content + case reference = "refrence" + } } struct PushMessageReplyContent: Codable { From 7002d777428d36f54d948b41e806f98bdedb0d26 Mon Sep 17 00:00:00 2001 From: Oleg Date: Tue, 27 Feb 2024 13:50:24 +0700 Subject: [PATCH 18/18] Fixed issue when last message wasn't loaded --- .../Services/MessagingService/MessagingService+Chats.swift | 1 + 1 file changed, 1 insertion(+) 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 {