Address review feedback on the send-failure dialog

- Resolve the send handle at tap time and pass it through the alert,
  removing the duplicated lookup and the stale-itemID window between
  tapping the indicator and pressing Retry.
- Route 'Remove message' through the existing timeline item menu redact
  action instead of a bespoke redact path with a fatalError.
- Surface a retry failure to the user with an indicator toast rather
  than only logging it.
- Only set the send-info accessibility identifier when the label has a
  tappable status, so plain timestamps don't create ambiguous matches.
This commit is contained in:
Matthew Hodgson
2026-08-06 20:33:31 +03:00
committed by Stefan Ceriu
parent 404410b8a2
commit 8bc7e7c281
3 changed files with 19 additions and 22 deletions
@@ -222,7 +222,7 @@ struct ReadReceiptSummaryInfo: Identifiable {
enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed(reason: String?, itemID: TimelineItemIdentifier)
case sendingFailed(reason: String?, sendHandle: SendHandleProxy)
case encryptionAuthenticity(String)
case encryptionForwarder(String)
case inviteAgain
@@ -739,7 +739,12 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
if case let .sendingFailed(.unknown(reason)) = eventTimelineItem.properties.deliveryStatus {
displayAlert(.sendingFailed(reason: reason, itemID: itemID))
guard let sendHandle = timelineController.sendHandle(for: itemID) else {
MXLog.error("Cannot find send handle for \(itemID).")
return
}
displayAlert(.sendingFailed(reason: reason, sendHandle: sendHandle))
} 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,27 +760,15 @@ 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
}
private func retrySending(_ sendHandle: SendHandleProxy) {
Task {
if case .failure(let error) = await sendHandle.resend() {
MXLog.error("Failed retrying to send \(itemID): \(error)")
MXLog.error("Failed retrying to send \(sendHandle.itemID): \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
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) {
@@ -1124,13 +1117,15 @@ 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(let reason, let itemID):
case .sendingFailed(let reason, let sendHandle):
state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed,
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) }])
verticalButtons: [.init(title: L10n.actionRetry) { [weak self] in self?.retrySending(sendHandle) },
.init(title: L10n.actionRemoveMessage, role: .destructive) { [weak self] in
self?.timelineInteractionHandler.handleTimelineItemMenuAction(.redact(isMedia: false), itemID: sendHandle.itemID)
}])
case .encryptionAuthenticity(let message):
state.bindings.alertInfo = .init(id: type,
title: message,
@@ -48,7 +48,9 @@ private struct TimelineItemSendInfoModifier: ViewModifier {
TimelineItemSendInfoLabel(sendInfo: sendInfo)
.accessibilityHidden(isAccessibilityHidden)
.contentShape(.rect)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.sendInfo)
// Only the labels with a tappable status get the identifier, so that
// plain timestamps don't produce ambiguous matches in UI tests.
.accessibilityIdentifier(sendInfo.status != nil ? A11yIdentifiers.roomScreen.sendInfo : "")
// Tap gesture to avoid the message being detected as a button by VoiceOver
// (and the action shows a description that is already read to the user).
.onTapGesture {