From db7fc2de9ad9f987bbef8aae77a5073d99b26839 Mon Sep 17 00:00:00 2001 From: Egor Egorov Date: Mon, 30 Sep 2024 13:28:26 +0300 Subject: [PATCH] Retry logic for chat engagement This commit adds retry logic for regular chat engagement: - marking failed messages; - tap actions for undelivered messages; - replacement logic after successful retry; - extends Outgoing message model with `relation` property to keep association with response cards; MOB-3597 --- ...onversations.ChatWithTranscriptModel.swift | 49 +++++++- ...ureConversations.TranscriptModel.GVA.swift | 2 +- .../SecureConversations.TranscriptModel.swift | 5 +- .../Coordinators/Chat/ChatCoordinator.swift | 1 + .../Extensions/UITableView+Extensions.swift | 16 +++ GliaWidgets/Sources/View/Chat/ChatView.swift | 20 ++- .../Chat/Message/VisitorChatMessageView.swift | 35 +++++- .../Chat/ChatViewController.swift | 6 + .../Chat/ChatViewModel+ChoiceCards.swift | 2 +- .../Chat/ChatViewModel+CustomCard.swift | 26 ++-- .../ViewModel/Chat/ChatViewModel+GVA.swift | 7 +- .../Chat/ChatViewModel+ViewModel.swift | 2 + .../ViewModel/Chat/ChatViewModel.Mock.swift | 2 + .../ViewModel/Chat/ChatViewModel.swift | 114 ++++++++++++++++-- .../ViewModel/Chat/Data/ChatItem.swift | 4 +- .../ViewModel/Chat/Data/OutgoingMessage.swift | 18 ++- .../Lib/ChatItem/ChatItem+Equatable.swift | 2 +- .../ChatViewModel/ChatViewModelTests.swift | 5 +- .../Chat/Mocks/ChatItem.Kind.Mock.swift | 2 +- 19 files changed, 277 insertions(+), 41 deletions(-) diff --git a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.ChatWithTranscriptModel.swift b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.ChatWithTranscriptModel.swift index 51092cc67..96b4d5813 100644 --- a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.ChatWithTranscriptModel.swift +++ b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.ChatWithTranscriptModel.swift @@ -117,7 +117,7 @@ extension SecureConversations.ChatWithTranscriptModel { guard let index = section.items .enumerated() .first(where: { - guard case .outgoingMessage(let message) = $0.element.kind else { return false } + guard case .outgoingMessage(let message, _) = $0.element.kind else { return false } return message.payload.messageId == outgoingMessage.payload.messageId })?.offset else { return } @@ -197,7 +197,7 @@ extension SecureConversations.ChatWithTranscriptModel { messageId: ChatMessage.MessageId ) -> Bool { switch chatItem.kind { - case let .outgoingMessage(outgoingMessage): + case let .outgoingMessage(outgoingMessage, _): return outgoingMessage.payload.messageId.rawValue.uppercased() == messageId.uppercased() case let .visitorMessage(message, _): return message.id.uppercased() == messageId.uppercased() @@ -311,6 +311,51 @@ extension SecureConversations.ChatWithTranscriptModel { } // swiftlint:enable function_body_length + // TODO: - This will be covered with unit tests in next PR + static func markMessageAsFailed( + _ outgoingMessage: OutgoingMessage, + in section: Section, + message: String, + action: ActionCallback? + ) { + guard let index = section.items + .enumerated() + .first(where: { _, element in + Self.chatItemMatchesMessageId( + chatItem: element, + messageId: outgoingMessage.payload.messageId.rawValue + ) + })?.offset + else { return } + + let item = ChatItem(kind: .outgoingMessage( + outgoingMessage, + error: message + )) + section.replaceItem(at: index, with: item) + action?(.refreshRows([index], in: section.index, animated: false)) + } + + // TODO: - This will be covered with unit tests in next PR + static func removeMessage( + _ outgoingMessage: OutgoingMessage, + in section: Section, + action: ActionCallback? + ) { + guard let index = section.items + .enumerated() + .first(where: { _, element in + Self.chatItemMatchesMessageId( + chatItem: element, + messageId: outgoingMessage.payload.messageId.rawValue + ) + })?.offset + else { return } + + section.removeItem(at: index) + action?(.deleteRows([index], in: section.index, animated: true)) + } + static private func shouldShowOperatorImage( for row: Int, in section: Section diff --git a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.GVA.swift b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.GVA.swift index 8ae1e0206..3c2c52a76 100644 --- a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.GVA.swift +++ b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.GVA.swift @@ -100,7 +100,7 @@ private extension SecureConversations.TranscriptModel { let outgoingMessage = OutgoingMessage(payload: payload) appendItem( - .init(kind: .outgoingMessage(outgoingMessage)), + .init(kind: .outgoingMessage(outgoingMessage, error: nil)), to: pendingSection, animated: true ) diff --git a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.swift b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.swift index b842f6287..3a29c149f 100644 --- a/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.swift +++ b/GliaWidgets/SecureConversations/ChatTranscript/SecureConversations.TranscriptModel.swift @@ -211,6 +211,9 @@ extension SecureConversations { break case let .gvaButtonTapped(option): gvaOptionAction(for: option)() + case let .retryMessageTapped(message): + // Will be handled in next PR + break } } @@ -265,7 +268,7 @@ extension SecureConversations.TranscriptModel { ) appendItem( - .init(kind: .outgoingMessage(outgoingMessage)), + .init(kind: .outgoingMessage(outgoingMessage, error: nil)), to: pendingSection, animated: true ) diff --git a/GliaWidgets/Sources/Coordinators/Chat/ChatCoordinator.swift b/GliaWidgets/Sources/Coordinators/Chat/ChatCoordinator.swift index 7790a57f3..3d1e7de97 100644 --- a/GliaWidgets/Sources/Coordinators/Chat/ChatCoordinator.swift +++ b/GliaWidgets/Sources/Coordinators/Chat/ChatCoordinator.swift @@ -162,6 +162,7 @@ extension ChatCoordinator { isWindowVisible: isWindowVisible, startAction: startAction, deliveredStatusText: viewFactory.theme.chat.visitorMessageStyle.delivered, + failedToDeliverStatusText: viewFactory.theme.chat.visitorMessageStyle.failedToDeliver, chatType: chatType, environment: .create( with: environment, diff --git a/GliaWidgets/Sources/Extensions/UITableView+Extensions.swift b/GliaWidgets/Sources/Extensions/UITableView+Extensions.swift index a2cde55a7..73fa081a8 100644 --- a/GliaWidgets/Sources/Extensions/UITableView+Extensions.swift +++ b/GliaWidgets/Sources/Extensions/UITableView+Extensions.swift @@ -45,4 +45,20 @@ internal extension UITableView { self.scrollToRow(at: indexPath, at: .bottom, animated: animated) } } + + func deleteRows(_ rows: [Int], in section: Int, animated: Bool) { + let refreshBlock = { + self.beginUpdates() + let indexPaths = rows.map { IndexPath(row: $0, section: section) } + self.deleteRows(at: indexPaths, with: .fade) + self.endUpdates() + } + if animated { + refreshBlock() + } else { + UIView.performWithoutAnimation { + refreshBlock() + } + } + } } diff --git a/GliaWidgets/Sources/View/Chat/ChatView.swift b/GliaWidgets/Sources/View/Chat/ChatView.swift index bb545c84b..27f661142 100644 --- a/GliaWidgets/Sources/View/Chat/ChatView.swift +++ b/GliaWidgets/Sources/View/Chat/ChatView.swift @@ -24,6 +24,7 @@ class ChatView: EngagementView { var linkTapped: ((URL) -> Void)? var selectCustomCardOption: ((HtmlMetadata.Option, MessageRenderer.Message.Identifier) -> Void)? var gvaButtonTapped: ((GvaOption) -> Void)? + var retryMessageTapped: ((OutgoingMessage) -> Void)? let style: ChatStyle let environment: Environment @@ -310,6 +311,10 @@ extension ChatView { } } + func deleteRows(_ rows: [Int], in section: Int, animated: Bool) { + tableView.deleteRows(rows, in: section, animated: animated) + } + func refreshAll() { tableView.reloadData() } @@ -345,8 +350,8 @@ extension ChatView { switch item.kind { case .queueOperator: return .queueOperator(connectView) - case let .outgoingMessage(message): - return outgoingMessageContent(message) + case let .outgoingMessage(message, error): + return outgoingMessageContent(message, error: error) case let .visitorMessage(message, status): return visitorMessageContent(message, status: status) case let .operatorMessage(message, showsImage, imageUrl): @@ -667,7 +672,10 @@ extension ChatView { return .systemMessage(view) } - private func outgoingMessageContent(_ message: OutgoingMessage) -> ChatItemCell.Content { + private func outgoingMessageContent( + _ message: OutgoingMessage, + error: String? + ) -> ChatItemCell.Content { let view = VisitorChatMessageView( with: style.visitorMessageStyle, environment: .create(with: environment) @@ -692,6 +700,12 @@ extension ChatView { ) view.fileTapped = { [weak self] in self?.fileTapped?($0) } view.linkTapped = { [weak self] in self?.linkTapped?($0) } + view.error = error + if error != nil { + view.messageTapped = { [weak self] in + self?.retryMessageTapped?(message) + } + } return .outgoingMessage(view) } diff --git a/GliaWidgets/Sources/View/Chat/Message/VisitorChatMessageView.swift b/GliaWidgets/Sources/View/Chat/Message/VisitorChatMessageView.swift index 68884867a..8f632fb55 100644 --- a/GliaWidgets/Sources/View/Chat/Message/VisitorChatMessageView.swift +++ b/GliaWidgets/Sources/View/Chat/Message/VisitorChatMessageView.swift @@ -6,7 +6,15 @@ class VisitorChatMessageView: ChatMessageView { set { statusLabel.text = newValue } } + var error: String? { + get { return errorLabel.text } + set { errorLabel.text = newValue } + } + + var messageTapped: (() -> Void)? + private let statusLabel = UILabel().makeView() + private let errorLabel = UILabel().makeView() private let contentInsets = UIEdgeInsets(top: 8, left: 88, bottom: 8, right: 16) init( @@ -42,6 +50,13 @@ class VisitorChatMessageView: ChatMessageView { style.accessibility.isFontScalingEnabled, for: statusLabel ) + + errorLabel.font = style.error.font + errorLabel.textColor = UIColor(hex: style.error.color) + setFontScalingEnabled( + style.accessibility.isFontScalingEnabled, + for: errorLabel + ) } override func defineLayout() { @@ -55,6 +70,24 @@ class VisitorChatMessageView: ChatMessageView { addSubview(statusLabel) constraints += statusLabel.topAnchor.constraint(equalTo: contentViews.bottomAnchor, constant: 2) constraints += statusLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -contentInsets.right) - constraints += statusLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -contentInsets.bottom) + + addSubview(errorLabel) + constraints += errorLabel.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 0) + constraints += errorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -contentInsets.right) + constraints += errorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -contentInsets.bottom) + + defineTapGestureRecognizer() + } + + func defineTapGestureRecognizer() { + let tapRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(tapped) + ) + addGestureRecognizer(tapRecognizer) + } + + @objc private func tapped() { + messageTapped?() } } diff --git a/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift b/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift index 74d54fc7e..32c51eb98 100644 --- a/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift +++ b/GliaWidgets/Sources/ViewController/Chat/ChatViewController.swift @@ -102,6 +102,10 @@ final class ChatViewController: EngagementViewController, PopoverPresenter { viewModel.event(.gvaButtonTapped(option)) } + view.retryMessageTapped = { message in + viewModel.event(.retryMessageTapped(message)) + } + var viewModel = viewModel viewModel.action = { [weak self, weak view] action in @@ -131,6 +135,8 @@ final class ChatViewController: EngagementViewController, PopoverPresenter { view?.refreshRows(rows, in: section, animated: animated) case let .refreshSection(section, animated): view?.refreshSection(section, animated: animated) + case let .deleteRows(rows, in: section, animated: animated): + view?.deleteRows(rows, in: section, animated: animated) case .refreshAll: view?.refreshAll() case .scrollToBottom(let animated): diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ChoiceCards.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ChoiceCards.swift index 94e300f28..c8abbdbcc 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ChoiceCards.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ChoiceCards.swift @@ -28,7 +28,7 @@ extension ChatViewModel { } } - private func respond(to choiceCardId: String, with selection: String?) { + func respond(to choiceCardId: String, with selection: String?) { // In the case of upgrading a secure conversation to a live chat, // there's a bug (MSG-483) that sends the welcome message web socket event before the // start engagement event. This means that we display it in the `pendingSection` diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+CustomCard.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+CustomCard.swift index b813184a7..f9aaa9897 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+CustomCard.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+CustomCard.swift @@ -18,7 +18,10 @@ extension ChatViewModel { ) let payload = environment.createSendMessagePayload(option.text, attachment) - let outgoingMessage = OutgoingMessage(payload: payload) + let outgoingMessage = OutgoingMessage( + payload: payload, + relation: .customCard(messageId: messageId) + ) registerReceivedMessage(messageId: payload.messageId.rawValue) @@ -31,7 +34,7 @@ extension ChatViewModel { self.updateCustomCard( messageId: messageId, - selectedOption: option, + selectedOptionValue: option.value, isActive: false ) self.replace( @@ -43,29 +46,32 @@ extension ChatViewModel { self.action?(.scrollToBottom(animated: true)) } - let failure: (CoreSdkClient.SalemoveError) -> Void = { [weak self] error in + let failure: () -> Void = { [weak self] in guard let self = self else { return } self.updateCustomCard( messageId: messageId, - selectedOption: nil, + selectedOptionValue: nil, isActive: true ) - self.engagementAction?(.showAlert(.error(error: error.error))) + self.markMessageAsFailed( + outgoingMessage, + in: self.messagesSection + ) } interactor.send(messagePayload: payload) { result in switch result { case let .success(message): success(message) - case let .failure(error): - failure(error) + case .failure: + failure() } } } - private func updateCustomCard( + func updateCustomCard( messageId: MessageRenderer.Message.Identifier, - selectedOption: HtmlMetadata.Option?, + selectedOptionValue: String?, isActive: Bool ) { guard let index = messagesSection.items @@ -85,7 +91,7 @@ extension ChatViewModel { _ ) = customCardItem.kind else { return } - message.attachment?.selectedOption = selectedOption?.value + message.attachment?.selectedOption = selectedOptionValue let item = ChatItem(kind: .customCard( message, showsImage: showsImage, diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift index 99de9bfe8..3e3cffd73 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+GVA.swift @@ -128,8 +128,11 @@ private extension ChatViewModel { in: self.messagesSection ) } - case let .failure(error): - self.engagementAction?(.showAlert(.error(error: error.error))) + case .failure: + self.markMessageAsFailed( + outgoingMessage, + in: self.messagesSection + ) } } } diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ViewModel.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ViewModel.swift index 11397a2d3..f3bcc2798 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ViewModel.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel+ViewModel.swift @@ -18,6 +18,7 @@ extension ChatViewModel: ViewModel { messageId: MessageRenderer.Message.Identifier ) case gvaButtonTapped(GvaOption) + case retryMessageTapped(OutgoingMessage) } enum Action { @@ -37,6 +38,7 @@ extension ChatViewModel: ViewModel { case refreshRow(Int, in: Int, animated: Bool) case refreshRows([Int], in: Int, animated: Bool) case refreshSection(Int, animated: Bool = false) + case deleteRows([Int], in: Int, animated: Bool) case refreshAll case scrollToBottom(animated: Bool) case updateItemsUserImage(animated: Bool) diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.Mock.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.Mock.swift index cf51b1b5d..47e58d434 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.Mock.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.Mock.swift @@ -11,6 +11,7 @@ extension ChatViewModel { isWindowVisible: ObservableValue = .init(with: true), startAction: StartAction = .startEngagement, deliveredStatusText: String = "Delivered", + failedToDeliverStatusText: String = "Failed", chatType: ChatViewModel.ChatType = .nonAuthenticated, environment: Environment = .mock, maximumUploads: () -> Int = { 2 } @@ -25,6 +26,7 @@ extension ChatViewModel { isWindowVisible: isWindowVisible, startAction: startAction, deliveredStatusText: deliveredStatusText, + failedToDeliverStatusText: failedToDeliverStatusText, chatType: chatType, environment: environment, maximumUploads: maximumUploads diff --git a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift index 57b5064c7..22378cf87 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/ChatViewModel.swift @@ -32,6 +32,7 @@ class ChatViewModel: EngagementViewModel { private let downloader: FileDownloader private let deliveredStatusText: String + private let failedToDeliverStatusText: String private(set) var messageText = "" { didSet { validateMessage() @@ -71,6 +72,7 @@ class ChatViewModel: EngagementViewModel { isWindowVisible: ObservableValue, startAction: StartAction, deliveredStatusText: String, + failedToDeliverStatusText: String, chatType: ChatType, environment: Environment, maximumUploads: () -> Int @@ -106,6 +108,7 @@ class ChatViewModel: EngagementViewModel { self.downloader = FileDownloader(environment: .create(with: environment)) self.deliveredStatusText = deliveredStatusText + self.failedToDeliverStatusText = failedToDeliverStatusText super.init( interactor: interactor, screenShareHandler: screenShareHandler, @@ -198,10 +201,9 @@ class ChatViewModel: EngagementViewModel { pendingMessages.forEach { [weak self] outgoingMessage in self?.interactor.send(messagePayload: outgoingMessage.payload) { [weak self] result in + guard let self else { return } switch result { case let .success(message): - guard let self else { return } - self.replace( outgoingMessage, uploads: [], @@ -210,9 +212,11 @@ class ChatViewModel: EngagementViewModel { ) self.action?(.scrollToBottom(animated: true)) - case let .failure(error): - guard let self else { return } - self.engagementAction?(.showAlert(.error(error: error.error))) + case .failure: + self.markMessageAsFailed( + outgoingMessage, + in: self.messagesSection + ) } } } @@ -276,6 +280,8 @@ extension ChatViewModel { sendSelectedCustomCardOption(option, for: messageId) case .gvaButtonTapped(let option): gvaOptionAction(for: option)() + case .retryMessageTapped(let message): + retryMessageSending(message) } } } @@ -460,11 +466,11 @@ extension ChatViewModel { ) self.action?(.scrollToBottom(animated: true)) } - case let .failure(error): - self.engagementAction?(.showAlert(.error( - error: error.error, - dismissed: endSession - ))) + case .failure: + self.markMessageAsFailed( + outgoingMessage, + in: self.messagesSection + ) } } case .enqueued: @@ -518,6 +524,29 @@ extension ChatViewModel { ) } + func markMessageAsFailed( + _ outgoingMessage: OutgoingMessage, + in section: Section + ) { + SecureConversations.ChatWithTranscriptModel.markMessageAsFailed( + outgoingMessage, + in: section, + message: failedToDeliverStatusText, + action: action + ) + } + + func removeMessage( + _ outgoingMessage: OutgoingMessage, + in section: Section + ) { + SecureConversations.ChatWithTranscriptModel.removeMessage( + outgoingMessage, + in: section, + action: action + ) + } + @discardableResult private func validateMessage() -> Bool { let canSendText = !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -549,7 +578,7 @@ extension ChatViewModel { // processing time. for chatItem in messagesSection.items.reversed() { switch chatItem.kind { - case let .outgoingMessage(outgoingMessage) + case let .outgoingMessage(outgoingMessage, _) where outgoingMessage.payload.messageId.rawValue.uppercased() == message.id.uppercased(): isMatchingOutgoingMessage = true default: @@ -611,7 +640,7 @@ extension ChatViewModel { // avoid message duplication. Currently pending messages // stay in pending section whole session. for pendingMessage in self.pendingSection.items { - if case let .outgoingMessage(outgoingPendingMessage) = pendingMessage.kind, + if case let .outgoingMessage(outgoingPendingMessage, _) = pendingMessage.kind, outgoingPendingMessage.payload.messageId.rawValue.uppercased() == message.id.uppercased() { return } @@ -788,6 +817,67 @@ extension ChatViewModel { } } +// MARK: Message sending retry + +extension ChatViewModel { + // TODO: - This will be covered with unit tests in next PR + private func retryMessageSending(_ outgoingMessage: OutgoingMessage) { + removeMessage( + outgoingMessage, + in: messagesSection + ) + + let item = ChatItem(with: outgoingMessage) + appendItem(item, to: messagesSection, animated: true) + action?(.scrollToBottom(animated: true)) + + interactor.send(messagePayload: outgoingMessage.payload) { [weak self] result in + guard let self else { return } + + switch result { + case let .success(message): + if !self.hasReceivedMessage(messageId: message.id) { + self.registerReceivedMessage(messageId: message.id) + + self.updateSelectedOption(with: outgoingMessage) + + self.replace( + outgoingMessage, + uploads: [], + with: message, + in: self.messagesSection + ) + self.action?(.scrollToBottom(animated: true)) + } + case .failure: + self.markMessageAsFailed( + outgoingMessage, + in: self.messagesSection + ) + } + } + } + + // Updates Response Card or Custom Card selected option + // TODO: - This will be covered with unit tests in next PR + private func updateSelectedOption(with outgoingMessage: OutgoingMessage) { + let selectedOption = outgoingMessage.payload.attachment?.selectedOption + + switch outgoingMessage.relation { + case let .customCard(messageId): + updateCustomCard( + messageId: messageId, + selectedOptionValue: selectedOption, + isActive: false + ) + case let .singleChoice(messageId): + respond(to: messageId.rawValue, with: selectedOption) + case .none: + return + } + } +} + // MARK: General extension ChatViewModel { diff --git a/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift b/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift index 198ec5e49..d75a3978f 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/Data/ChatItem.swift @@ -30,7 +30,7 @@ class ChatItem { init( with message: OutgoingMessage ) { - kind = .outgoingMessage(message) + kind = .outgoingMessage(message, error: nil) } init?( @@ -78,7 +78,7 @@ class ChatItem { extension ChatItem { enum Kind { case queueOperator - case outgoingMessage(OutgoingMessage) + case outgoingMessage(OutgoingMessage, error: String?) case visitorMessage(ChatMessage, status: String?) case operatorMessage(ChatMessage, showsImage: Bool, imageUrl: String?) case choiceCard(ChatMessage, showsImage: Bool, imageUrl: String?, isActive: Bool) diff --git a/GliaWidgets/Sources/ViewModel/Chat/Data/OutgoingMessage.swift b/GliaWidgets/Sources/ViewModel/Chat/Data/OutgoingMessage.swift index 16c235e77..bd0ad29fa 100644 --- a/GliaWidgets/Sources/ViewModel/Chat/Data/OutgoingMessage.swift +++ b/GliaWidgets/Sources/ViewModel/Chat/Data/OutgoingMessage.swift @@ -3,18 +3,32 @@ import GliaCoreSDK class OutgoingMessage: Equatable { let files: [LocalFile] + /// Defines if the message is associated with Single Choice Response card + /// or Custom Response card. Used for reloading an associated card. + let relation: Relation var payload: CoreSdkClient.SendMessagePayload init( payload: CoreSdkClient.SendMessagePayload, - files: [LocalFile] = [] + files: [LocalFile] = [], + relation: Relation = .none ) { self.payload = payload self.files = files + self.relation = relation } static func == (lhs: OutgoingMessage, rhs: OutgoingMessage) -> Bool { lhs.payload == rhs.payload && - lhs.files == rhs.files + lhs.files == rhs.files && + lhs.relation == rhs.relation + } +} + +extension OutgoingMessage { + enum Relation: Equatable { + case none + case singleChoice(messageId: CoreSdkClient.Message.Id) + case customCard(messageId: MessageRenderer.Message.Identifier) } } diff --git a/GliaWidgetsTests/Lib/ChatItem/ChatItem+Equatable.swift b/GliaWidgetsTests/Lib/ChatItem/ChatItem+Equatable.swift index 289362f83..2ef59b5f3 100644 --- a/GliaWidgetsTests/Lib/ChatItem/ChatItem+Equatable.swift +++ b/GliaWidgetsTests/Lib/ChatItem/ChatItem+Equatable.swift @@ -6,7 +6,7 @@ extension ChatItem.Kind: Equatable { case (.queueOperator, .queueOperator): return true - case (.outgoingMessage(let lhsMessage), .outgoingMessage(let rhsMessage)): + case (.outgoingMessage(let lhsMessage, _), .outgoingMessage(let rhsMessage, _)): return lhsMessage.payload.messageId == rhsMessage.payload.messageId case (.visitorMessage(let lhsMessage, _), .visitorMessage(let rhsMessage, _)): diff --git a/GliaWidgetsTests/Sources/ChatViewModel/ChatViewModelTests.swift b/GliaWidgetsTests/Sources/ChatViewModel/ChatViewModelTests.swift index 8a10fab61..bc8d80a78 100644 --- a/GliaWidgetsTests/Sources/ChatViewModel/ChatViewModelTests.swift +++ b/GliaWidgetsTests/Sources/ChatViewModel/ChatViewModelTests.swift @@ -24,7 +24,8 @@ class ChatViewModelTests: XCTestCase { isCustomCardSupported: false, isWindowVisible: .init(with: true), startAction: .none, - deliveredStatusText: "Delivered", + deliveredStatusText: "Delivered", + failedToDeliverStatusText: "Failed", chatType: .nonAuthenticated, environment: .init( fetchFile: { _, _, _ in }, @@ -744,7 +745,7 @@ class ChatViewModelTests: XCTestCase { let outgoingMessage = OutgoingMessage(payload: .mock(messageIdSuffix: messageIdSuffix)) viewModel.pendingSection.append( - .init(kind: .outgoingMessage(outgoingMessage)) + .init(kind: .outgoingMessage(outgoingMessage, error: nil)) ) viewModel.interactorEvent(.receivedMessage(.mock(id: outgoingMessage.payload.messageId.rawValue))) diff --git a/GliaWidgetsTests/ViewModel/Chat/Mocks/ChatItem.Kind.Mock.swift b/GliaWidgetsTests/ViewModel/Chat/Mocks/ChatItem.Kind.Mock.swift index da5b313be..132d4744d 100644 --- a/GliaWidgetsTests/ViewModel/Chat/Mocks/ChatItem.Kind.Mock.swift +++ b/GliaWidgetsTests/ViewModel/Chat/Mocks/ChatItem.Kind.Mock.swift @@ -14,7 +14,7 @@ extension ChatItem.Kind { static func mock(kind: Internal) -> ChatItem.Kind { switch kind { case .queueOperator: return .queueOperator - case .outgoingMessage: return .outgoingMessage(.mock()) + case .outgoingMessage: return .outgoingMessage(.mock(), error: nil) case .visitorMessage: return .visitorMessage(.mock(), status: nil) case .operatorMessage: return .operatorMessage(