From ac4170482183b5553300addb2877145b523f237a Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 16 Jun 2023 11:00:54 +0200 Subject: [PATCH] Retry send and cancel send for failed to send messages (#1088) * is now possible to open the confirmation dialog and pass the transaction id * task completed added some tests * changelog * title visibility set to .visible --- ElementX.xcodeproj/project.pbxproj | 4 ++ .../en.lproj/Localizable.strings | 3 + ElementX/Sources/Generated/Strings.swift | 6 ++ .../Mocks/Generated/GeneratedMocks.swift | 32 ++++++++++ .../Other/Extensions/ConfirmationDialog.swift | 50 +++++++++++++++ .../Screens/RoomScreen/RoomScreenModels.swift | 11 ++++ .../RoomScreen/RoomScreenViewModel.swift | 21 ++++++- .../Screens/RoomScreen/View/RoomScreen.swift | 8 +++ .../Style/TimelineItemBubbledStylerView.swift | 45 +++++++++----- .../TimelineItemStatusView.swift | 3 + .../Sources/Services/Room/RoomProxy.swift | 22 +++++++ .../Services/Room/RoomProxyProtocol.swift | 6 ++ .../RoomTimelineItemProperties.swift | 2 + .../Services/Timeline/TimelineItemProxy.swift | 4 ++ .../RoomTimelineItemFactory.swift | 24 ++++--- .../Sources/RoomScreenViewModelTests.swift | 62 +++++++++++++++++++ changelog.d/979.feature | 1 + 17 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 ElementX/Sources/Other/Extensions/ConfirmationDialog.swift create mode 100644 changelog.d/979.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 68b6a8900..e98f46dfe 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -440,6 +440,7 @@ A6F713461DB62AC06293E7B7 /* FilePreviewScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820637A0F9C2F562FF40CBC8 /* FilePreviewScreenModels.swift */; }; A74438ED16F8683A4B793E6A /* AnalyticsSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCE3FAF40932AC7C7639AC4 /* AnalyticsSettingsScreenViewModel.swift */; }; A7D48E44D485B143AADDB77D /* Strings+Untranslated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A18F6CE4D694D21E4EA9B25 /* Strings+Untranslated.swift */; }; + A7F55D1C2A3B639A009D8E93 /* ConfirmationDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */; }; A7FD7B992E6EE6E5A8429197 /* RoomSummaryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142808B69851451AC32A2CEA /* RoomSummaryDetails.swift */; }; A816F7087C495D85048AC50E /* RoomMemberDetailsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B6E30BB748F3F480F077969 /* RoomMemberDetailsScreenModels.swift */; }; A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; @@ -1121,6 +1122,7 @@ A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleTimelineItem.swift; sourceTree = ""; }; + A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationDialog.swift; sourceTree = ""; }; A861DA5932B128FE1DCB5CE2 /* InviteUsersScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteUsersScreenCoordinator.swift; sourceTree = ""; }; A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationProtocol.swift; sourceTree = ""; }; A9FAFE1C2149E6AC8156ED2B /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; @@ -1928,6 +1930,7 @@ CED722AC2A3B0E7B000F10C8 /* URLSession.swift */, 897DF5E9A70CE05A632FC8AF /* UTType.swift */, E992D7B8BE54B2AB454613AF /* XCUIElement.swift */, + A7F55D1B2A3B639A009D8E93 /* ConfirmationDialog.swift */, ); path = Extensions; sourceTree = ""; @@ -4206,6 +4209,7 @@ B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */, 1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */, DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */, + A7F55D1C2A3B639A009D8E93 /* ConfirmationDialog.swift in Sources */, 5C164551F7D26E24F09083D3 /* StaticLocationScreenViewModelProtocol.swift in Sources */, C58E305C380D3ADDF7912180 /* StickerRoomTimelineItem.swift in Sources */, F32B271F60531BE92C6E62A1 /* StickerRoomTimelineView.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index d29609245..a6121c8a1 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -273,6 +273,8 @@ "screen_room_member_details_unblock_user" = "Unblock user"; "screen_room_member_list_pending_header_title" = "Pending"; "screen_room_member_list_room_members_header_title" = "Room members"; +"screen_room_retry_send_menu_send_again_action" = "Send again"; +"screen_room_retry_send_menu_title" = "Your message failed to send"; "screen_roomlist_a11y_create_message" = "Create a new conversation or room"; "screen_roomlist_main_space_title" = "All Chats"; "screen_server_confirmation_change_server" = "Change account provider"; @@ -387,6 +389,7 @@ "screen_room_details_security_title" = "Security"; "screen_room_details_topic_title" = "Topic"; "screen_room_error_failed_processing_media" = "Failed processing media to upload, please try again."; +"screen_room_retry_send_menu_remove_action" = "Remove"; "screen_session_verification_cancelled_title" = "Verification cancelled"; "screen_session_verification_positive_button_ready" = "Start"; "screen_signout_confirmation_dialog_submit" = "Sign out"; diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 30234c346..1e2c9f17b 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -702,6 +702,12 @@ public enum L10n { public static var screenRoomMemberListPendingHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_pending_header_title") } /// Room members public static var screenRoomMemberListRoomMembersHeaderTitle: String { return L10n.tr("Localizable", "screen_room_member_list_room_members_header_title") } + /// Remove + public static var screenRoomRetrySendMenuRemoveAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_remove_action") } + /// Send again + public static var screenRoomRetrySendMenuSendAgainAction: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_send_again_action") } + /// Your message failed to send + public static var screenRoomRetrySendMenuTitle: String { return L10n.tr("Localizable", "screen_room_retry_send_menu_title") } /// Create a new conversation or room public static var screenRoomlistA11yCreateMessage: String { return L10n.tr("Localizable", "screen_roomlist_a11y_create_message") } /// All Chats diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 99724f8d9..9e7696293 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -685,6 +685,38 @@ class RoomProxyMock: RoomProxyProtocol { return sendFileUrlFileInfoReturnValue } } + //MARK: - retrySend + + var retrySendTransactionIDCallsCount = 0 + var retrySendTransactionIDCalled: Bool { + return retrySendTransactionIDCallsCount > 0 + } + var retrySendTransactionIDReceivedTransactionID: String? + var retrySendTransactionIDReceivedInvocations: [String] = [] + var retrySendTransactionIDClosure: ((String) async -> Void)? + + func retrySend(transactionID: String) async { + retrySendTransactionIDCallsCount += 1 + retrySendTransactionIDReceivedTransactionID = transactionID + retrySendTransactionIDReceivedInvocations.append(transactionID) + await retrySendTransactionIDClosure?(transactionID) + } + //MARK: - cancelSend + + var cancelSendTransactionIDCallsCount = 0 + var cancelSendTransactionIDCalled: Bool { + return cancelSendTransactionIDCallsCount > 0 + } + var cancelSendTransactionIDReceivedTransactionID: String? + var cancelSendTransactionIDReceivedInvocations: [String] = [] + var cancelSendTransactionIDClosure: ((String) async -> Void)? + + func cancelSend(transactionID: String) async { + cancelSendTransactionIDCallsCount += 1 + cancelSendTransactionIDReceivedTransactionID = transactionID + cancelSendTransactionIDReceivedInvocations.append(transactionID) + await cancelSendTransactionIDClosure?(transactionID) + } //MARK: - editMessage var editMessageOriginalCallsCount = 0 diff --git a/ElementX/Sources/Other/Extensions/ConfirmationDialog.swift b/ElementX/Sources/Other/Extensions/ConfirmationDialog.swift new file mode 100644 index 000000000..08fa06a76 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/ConfirmationDialog.swift @@ -0,0 +1,50 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +protocol ConfirmationDialogProtocol { + var title: String { get } +} + +extension View { + func confirmationDialog(item: Binding, + titleVisibility: Visibility = .automatic, + @ViewBuilder actions: (Item) -> Actions) -> some View where Item: ConfirmationDialogProtocol, Actions: View { + let binding = Binding(get: { + item.wrappedValue != nil + }, set: { newValue in + if !newValue { + item.wrappedValue = nil + } + }) + return confirmationDialog(item.wrappedValue?.title ?? "", isPresented: binding, titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions) + } + + func confirmationDialog(item: Binding, + titleVisibility: Visibility = .automatic, + @ViewBuilder actions: (Item) -> Actions, + @ViewBuilder message: (Item) -> Message) -> some View where Item: ConfirmationDialogProtocol, Actions: View, Message: View { + let binding = Binding(get: { + item.wrappedValue != nil + }, set: { newValue in + if !newValue { + item.wrappedValue = nil + } + }) + return confirmationDialog(item.wrappedValue?.title ?? "", isPresented: binding, titleVisibility: titleVisibility, presenting: item.wrappedValue, actions: actions, message: message) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 4d0a6a780..a7cccc9d3 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -70,6 +70,9 @@ enum RoomScreenViewAction { case handlePasteOrDrop(provider: NSItemProvider) case tappedOnUser(userID: String) + + case retrySend(transactionID: String?) + case cancelSend(transactionID: String?) } struct RoomScreenViewState: BindableState { @@ -109,6 +112,8 @@ struct RoomScreenViewStateBindings { var debugInfo: TimelineItemDebugInfo? var actionMenuInfo: TimelineItemActionMenuInfo? + + var sendFailedConfirmationDialogInfo: SendFailedConfirmationDialogInfo? } struct TimelineItemActionMenuInfo: Identifiable { @@ -119,6 +124,12 @@ struct TimelineItemActionMenuInfo: Identifiable { } } +struct SendFailedConfirmationDialogInfo: ConfirmationDialogProtocol { + let title = L10n.screenRoomRetrySendMenuTitle + + let transactionID: String? +} + enum RoomScreenErrorType: Hashable { /// A specific error message shown in an alert. case alert(String) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 187c3e786..4fa5f6ea0 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -105,10 +105,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol handlePasteOrDrop(provider) case .tappedOnUser(userID: let userID): Task { await handleTappedUser(userID: userID) } - case .displayEmojiPicker(let itemID): guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return } callback?(.displayEmojiPicker(itemID: itemID)) + case .retrySend(let transactionID): + Task { await handleRetrySend(transactionID: transactionID) } + case .cancelSend(let transactionID): + Task { await handleCancelSend(transactionID: transactionID) } } } @@ -470,6 +473,22 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } + private func handleRetrySend(transactionID: String?) async { + guard let transactionID else { + return + } + + await roomProxy.retrySend(transactionID: transactionID) + } + + private func handleCancelSend(transactionID: String?) async { + guard let transactionID else { + return + } + + await roomProxy.cancelSend(transactionID: transactionID) + } + private static let loadingIndicatorIdentifier = "RoomScreenLoadingIndicator" private func showLoadingIndicator(with delay: Duration) -> Task { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index ddc548de0..ca693cb50 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -62,6 +62,14 @@ struct RoomScreen: View { context.send(viewAction: .handlePasteOrDrop(provider: provider)) return true } + .confirmationDialog(item: $context.sendFailedConfirmationDialogInfo, titleVisibility: .visible) { item in + Button(L10n.screenRoomRetrySendMenuSendAgainAction) { + context.send(viewAction: .retrySend(transactionID: item.transactionID)) + } + Button(L10n.screenRoomRetrySendMenuRemoveAction, role: .destructive) { + context.send(viewAction: .cancelSend(transactionID: item.transactionID)) + } + } } private var timeline: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index d96cc02e7..901fa6574 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -146,30 +146,47 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder var contentWithTimestamp: some View { - if isTextItem { + if isTextItem || isMediaType { ZStack(alignment: .bottomTrailing) { contentWithReply - localizedSendInfo - } - } else if isMediaType { - ZStack(alignment: .bottomTrailing) { - contentWithReply - localizedSendInfo - .padding(.horizontal, 4) - .padding(.vertical, 2) - .background(Color.element.system) - .cornerRadius(10) - .padding(.trailing, 4) - .padding(.bottom, 4) + interactiveLocalizedSendInfo } } else { HStack(alignment: .bottom, spacing: 4) { contentWithReply - localizedSendInfo + interactiveLocalizedSendInfo } } } + @ViewBuilder + var interactiveLocalizedSendInfo: some View { + if timelineItem.properties.deliveryStatus == .sendingFailed { + backgroundedLocalizedSendInfo + .onTapGesture { + context.sendFailedConfirmationDialogInfo = .init(transactionID: timelineItem.properties.transactionID) + } + } else { + backgroundedLocalizedSendInfo + } + } + + @ViewBuilder + var backgroundedLocalizedSendInfo: some View { + if isMediaType { + localizedSendInfo + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.element.system) + .cornerRadius(10) + .padding(.trailing, 4) + .padding(.bottom, 4) + + } else { + localizedSendInfo + } + } + @ViewBuilder var localizedSendInfo: some View { HStack(spacing: 4) { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift index dbe0a89ed..2f50581e7 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineItemStatusView.swift @@ -69,6 +69,9 @@ struct TimelineItemStatusView: View { .resizable() .foregroundColor(.element.alert) .frame(width: 16, height: 16) + .onTapGesture { + context.sendFailedConfirmationDialogInfo = .init(transactionID: timelineItem.properties.transactionID) + } } // The bubbles handle the failure internally } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 72e228d7c..295df35b3 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -326,6 +326,28 @@ class RoomProxy: RoomProxyProtocol { } } + func retrySend(transactionID: String) async { + sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + self.room.retrySend(txnId: transactionID) + } + } + + func cancelSend(transactionID: String) async { + sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + self.room.cancelSend(txnId: transactionID) + } + } + func editMessage(_ newMessage: String, original eventID: String) async -> Result { sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) defer { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 449a5d0c5..03a0a3ed8 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -101,6 +101,12 @@ protocol RoomProxyProtocol { func sendFile(url: URL, fileInfo: FileInfo) async -> Result + /// Retries sending a failed message given its transaction ID + func retrySend(transactionID: String) async + + /// Cancels a failed message given its transaction ID from the timeline + func cancelSend(transactionID: String) async + func editMessage(_ newMessage: String, original eventID: String) async -> Result func redact(_ eventID: String) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift index ddbe6a044..63e58e4ca 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/RoomTimelineItemProperties.swift @@ -26,4 +26,6 @@ struct RoomTimelineItemProperties: Hashable { var deliveryStatus: TimelineItemDeliveryStatus? /// The read receipts of the item, ordered from newest to oldest var orderedReadReceipts: [ReadReceipt] = [] + /// The original transaction id transmitted by the client + var transactionID: String? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index e8a1d6ae5..5b800c8fa 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -52,6 +52,10 @@ struct EventTimelineItemProxy { var id: String { item.uniqueIdentifier() } + + var transactionID: String? { + item.transactionId() + } var deliveryStatus: TimelineItemDeliveryStatus? { guard let localSendState = item.localSendState() else { diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index f114d47ef..17b016155 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -133,7 +133,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { blurhash: imageInfo.blurhash, properties: RoomTimelineItemProperties(reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, @@ -184,7 +185,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildImageTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -201,7 +203,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildVideoTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -218,7 +221,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildAudioTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -235,7 +239,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildFileTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -252,7 +257,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildNoticeTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -269,7 +275,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func buildEmoteTimelineItem(for eventItemProxy: EventTimelineItemProxy, @@ -286,7 +293,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { properties: RoomTimelineItemProperties(isEdited: messageTimelineItem.isEdited(), reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus, - orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts), + transactionID: eventItemProxy.transactionID)) } private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index cbf01ad11..a5a95d13f 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -248,6 +248,68 @@ class RoomScreenViewModelTests: XCTestCase { XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1) XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob") } + + func testRetrySend() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock) + + // Test + viewModel.context.send(viewAction: .retrySend(transactionID: "test retry send id")) + await Task.yield() + XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 1) + XCTAssert(roomProxyMock.retrySendTransactionIDReceivedInvocations == ["test retry send id"]) + } + + func testRetrySendNoTransactionID() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock) + + // Test + viewModel.context.send(viewAction: .retrySend(transactionID: nil)) + await Task.yield() + XCTAssert(roomProxyMock.retrySendTransactionIDCallsCount == 0) + } + + func testCancelSend() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock) + + // Test + viewModel.context.send(viewAction: .cancelSend(transactionID: "test cancel send id")) + await Task.yield() + XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 1) + XCTAssert(roomProxyMock.cancelSendTransactionIDReceivedInvocations == ["test cancel send id"]) + } + + func testCancelSendNoTransactionID() async { + // Setup + let timelineController = MockRoomTimelineController() + let roomProxyMock = RoomProxyMock(with: .init(displayName: "")) + + let viewModel = RoomScreenViewModel(timelineController: timelineController, + mediaProvider: MockMediaProvider(), + roomProxy: roomProxyMock) + + // Test + viewModel.context.send(viewAction: .cancelSend(transactionID: nil)) + await Task.yield() + XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 0) + } } private extension TextRoomTimelineItem { diff --git a/changelog.d/979.feature b/changelog.d/979.feature new file mode 100644 index 000000000..7871a077c --- /dev/null +++ b/changelog.d/979.feature @@ -0,0 +1 @@ +Failed to send messages can now be either retried or removed by tapping on the error icon/timestamp. \ No newline at end of file