From 1aa9b29eb52fd825c3fe4d281449c6b292f5f7d4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 6 Aug 2026 18:34:49 +0300 Subject: [PATCH] Offer Retry and Remove actions with a reason when tapping a failed message Tapping the red (!) on a message that failed to send used to show a bare "Sending failed" alert with only an OK button. Now the alert includes the failure reason from the SDK (e.g. the HTTP error that caused it) and offers Retry sending, Remove message and Cancel actions, wired to the send handle's resend and to redacting the local echo respectively. --- .../Screens/Timeline/TimelineModels.swift | 2 +- .../Screens/Timeline/TimelineViewModel.swift | 32 ++++++++++++++++--- .../View/ItemMenu/TimelineItemMenu.swift | 2 +- .../Timeline/View/Style/TimelineStyler.swift | 2 +- .../Services/Timeline/TimelineItemProxy.swift | 6 ++-- 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index ca85a6cbb..7d327029b 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -222,7 +222,7 @@ struct ReadReceiptSummaryInfo: Identifiable { enum TimelineAlertInfoType: Hashable { case audioRecodingPermissionError case pollEndConfirmation(String) - case sendingFailed + case sendingFailed(reason: String?, itemID: TimelineItemIdentifier) case encryptionAuthenticity(String) case encryptionForwarder(String) case inviteAgain diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 76e5a83aa..f9528d0db 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -738,8 +738,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { fatalError("Only events can have send info.") } - if case .sendingFailed(.unknown) = eventTimelineItem.properties.deliveryStatus { - displayAlert(.sendingFailed) + if case let .sendingFailed(.unknown(reason)) = eventTimelineItem.properties.deliveryStatus { + displayAlert(.sendingFailed(reason: reason, itemID: itemID)) } else if case let .sendingFailed(.verifiedUser(failure)) = eventTimelineItem.properties.deliveryStatus { guard let sendHandle = timelineController.sendHandle(for: itemID) else { MXLog.error("Cannot find send handle for \(itemID).") @@ -755,6 +755,27 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { displayAlert(.encryptionAuthenticity(authenticityMessage)) } } + + private func retrySending(itemID: TimelineItemIdentifier) { + guard let sendHandle = timelineController.sendHandle(for: itemID) else { + MXLog.error("Cannot find send handle for \(itemID).") + return + } + + Task { + if case .failure(let error) = await sendHandle.resend() { + MXLog.error("Failed retrying to send \(itemID): \(error)") + } + } + } + + private func removeFailedMessage(itemID: TimelineItemIdentifier) { + guard case let .event(_, eventOrTransactionID) = itemID else { + fatalError("Only events can fail to send.") + } + + Task { await timelineController.redact(eventOrTransactionID) } + } private func slashCommand(message: String) -> SlashCommand? { for command in SlashCommand.allCases where message.starts(with: command.rawValue) { @@ -1103,10 +1124,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { message: L10n.commonPollEndConfirmation, primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), secondaryButton: .init(title: L10n.actionOk) { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) }) - case .sendingFailed: + case .sendingFailed(let reason, let itemID): state.bindings.alertInfo = .init(id: type, title: L10n.commonSendingFailed, - primaryButton: .init(title: L10n.actionOk, action: nil)) + message: reason, + primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), + verticalButtons: [.init(title: L10n.actionRetry) { [weak self] in self?.retrySending(itemID: itemID) }, + .init(title: L10n.actionRemoveMessage, role: .destructive) { [weak self] in self?.removeFailedMessage(itemID: itemID) }]) case .encryptionAuthenticity(let message): state.bindings.alertInfo = .init(id: type, title: message, diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift index d16cb49b1..d9a418832 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift @@ -262,7 +262,7 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview { static let (backupItem, _) = makeActions(authenticity: .notGuaranteed(color: .gray)) static let (unsignedItem, _) = makeActions(authenticity: .unsignedDevice(color: .red)) static let (unencryptedItem, _) = makeActions(authenticity: .sentInClear(color: .red)) - static let (unknownFailureItem, _) = makeActions(deliveryStatus: .sendingFailed(.unknown)) + static let (unknownFailureItem, _) = makeActions(deliveryStatus: .sendingFailed(.unknown(reason: nil))) static let (identityChangedItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [ "@alice:matrix.org" ])))) diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift index e2d16acee..d1feef6c7 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineStyler.swift @@ -92,7 +92,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview { static let failed: TextRoomTimelineItem = { var result = base - result.properties.deliveryStatus = .sendingFailed(.unknown) + result.properties.deliveryStatus = .sendingFailed(.unknown(reason: nil)) return result }() diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index c897ecd22..69a744b44 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -65,7 +65,7 @@ nonisolated enum TimelineItemSendFailure: Hashable { } case verifiedUser(VerifiedUser) - case unknown + case unknown(reason: String?) } /// A light wrapper around event timeline items returned from Rust. @@ -95,8 +95,10 @@ final nonisolated class EventTimelineItemProxy: Sendable { return .sendingFailed(.verifiedUser(.changedIdentity(users: users))) case .insecureDevices(let userDeviceMap): return .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: userDeviceMap))) + case .genericApiError(let message): + return .sendingFailed(.unknown(reason: message)) default: - return .sendingFailed(.unknown) + return .sendingFailed(.unknown(reason: nil)) } case .notSentYet: return .sending