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.
This commit is contained in:
Matthew Hodgson
2026-08-06 18:34:49 +03:00
committed by Stefan Ceriu
parent 033cb2e038
commit 1aa9b29eb5
5 changed files with 35 additions and 9 deletions
@@ -222,7 +222,7 @@ struct ReadReceiptSummaryInfo: Identifiable {
enum TimelineAlertInfoType: Hashable { enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError case audioRecodingPermissionError
case pollEndConfirmation(String) case pollEndConfirmation(String)
case sendingFailed case sendingFailed(reason: String?, itemID: TimelineItemIdentifier)
case encryptionAuthenticity(String) case encryptionAuthenticity(String)
case encryptionForwarder(String) case encryptionForwarder(String)
case inviteAgain case inviteAgain
@@ -738,8 +738,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
fatalError("Only events can have send info.") fatalError("Only events can have send info.")
} }
if case .sendingFailed(.unknown) = eventTimelineItem.properties.deliveryStatus { if case let .sendingFailed(.unknown(reason)) = eventTimelineItem.properties.deliveryStatus {
displayAlert(.sendingFailed) displayAlert(.sendingFailed(reason: reason, itemID: itemID))
} else if case let .sendingFailed(.verifiedUser(failure)) = eventTimelineItem.properties.deliveryStatus { } else if case let .sendingFailed(.verifiedUser(failure)) = eventTimelineItem.properties.deliveryStatus {
guard let sendHandle = timelineController.sendHandle(for: itemID) else { guard let sendHandle = timelineController.sendHandle(for: itemID) else {
MXLog.error("Cannot find send handle for \(itemID).") MXLog.error("Cannot find send handle for \(itemID).")
@@ -755,6 +755,27 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
displayAlert(.encryptionAuthenticity(authenticityMessage)) 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? { private func slashCommand(message: String) -> SlashCommand? {
for command in SlashCommand.allCases where message.starts(with: command.rawValue) { for command in SlashCommand.allCases where message.starts(with: command.rawValue) {
@@ -1103,10 +1124,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
message: L10n.commonPollEndConfirmation, message: L10n.commonPollEndConfirmation,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk) { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) }) secondaryButton: .init(title: L10n.actionOk) { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) })
case .sendingFailed: case .sendingFailed(let reason, let itemID):
state.bindings.alertInfo = .init(id: type, state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed, 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): case .encryptionAuthenticity(let message):
state.bindings.alertInfo = .init(id: type, state.bindings.alertInfo = .init(id: type,
title: message, title: message,
@@ -262,7 +262,7 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
static let (backupItem, _) = makeActions(authenticity: .notGuaranteed(color: .gray)) static let (backupItem, _) = makeActions(authenticity: .notGuaranteed(color: .gray))
static let (unsignedItem, _) = makeActions(authenticity: .unsignedDevice(color: .red)) static let (unsignedItem, _) = makeActions(authenticity: .unsignedDevice(color: .red))
static let (unencryptedItem, _) = makeActions(authenticity: .sentInClear(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: [ static let (identityChangedItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [
"@alice:matrix.org" "@alice:matrix.org"
])))) ]))))
@@ -92,7 +92,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview {
static let failed: TextRoomTimelineItem = { static let failed: TextRoomTimelineItem = {
var result = base var result = base
result.properties.deliveryStatus = .sendingFailed(.unknown) result.properties.deliveryStatus = .sendingFailed(.unknown(reason: nil))
return result return result
}() }()
@@ -65,7 +65,7 @@ nonisolated enum TimelineItemSendFailure: Hashable {
} }
case verifiedUser(VerifiedUser) case verifiedUser(VerifiedUser)
case unknown case unknown(reason: String?)
} }
/// A light wrapper around event timeline items returned from Rust. /// 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))) return .sendingFailed(.verifiedUser(.changedIdentity(users: users)))
case .insecureDevices(let userDeviceMap): case .insecureDevices(let userDeviceMap):
return .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: userDeviceMap))) return .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: userDeviceMap)))
case .genericApiError(let message):
return .sendingFailed(.unknown(reason: message))
default: default:
return .sendingFailed(.unknown) return .sendingFailed(.unknown(reason: nil))
} }
case .notSentYet: case .notSentYet:
return .sending return .sending