Adopt the new send queue interfaces

This commit is contained in:
Stefan Ceriu
2026-09-18 15:39:52 +03:00
parent 690f2be5f4
commit e51d869fdc
9 changed files with 308 additions and 19 deletions
@@ -12506,6 +12506,110 @@ nonisolated class TimelineControllerMock: TimelineControllerProtocol, @unchecked
return sendHandleForReturnValue
}
}
//MARK: - pendingSendTarget
private let pendingSendTargetForCallsCountLock = NSLock()
private nonisolated(unsafe) var pendingSendTargetForUnderlyingCallsCount = 0
var pendingSendTargetForCallsCount: Int {
get { pendingSendTargetForCallsCountLock.withLock { pendingSendTargetForUnderlyingCallsCount } }
set { pendingSendTargetForCallsCountLock.withLock { pendingSendTargetForUnderlyingCallsCount = newValue } }
}
var pendingSendTargetForCalled: Bool {
return pendingSendTargetForCallsCount > 0
}
private let pendingSendTargetForReceivedItemIDLock = NSLock()
private nonisolated(unsafe) var pendingSendTargetForUnderlyingReceivedItemID: TimelineItemIdentifier?
var pendingSendTargetForReceivedItemID: TimelineItemIdentifier? {
get { pendingSendTargetForReceivedItemIDLock.withLock { pendingSendTargetForUnderlyingReceivedItemID } }
set { pendingSendTargetForReceivedItemIDLock.withLock { pendingSendTargetForUnderlyingReceivedItemID = newValue } }
}
private let pendingSendTargetForReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var pendingSendTargetForUnderlyingReceivedInvocations: [TimelineItemIdentifier] = []
var pendingSendTargetForReceivedInvocations: [TimelineItemIdentifier] {
get { pendingSendTargetForReceivedInvocationsLock.withLock { pendingSendTargetForUnderlyingReceivedInvocations } }
set { pendingSendTargetForReceivedInvocationsLock.withLock { pendingSendTargetForUnderlyingReceivedInvocations = newValue } }
}
private let pendingSendTargetForReturnValueLock = NSLock()
private nonisolated(unsafe) var pendingSendTargetForUnderlyingReturnValue: SendTarget?
var pendingSendTargetForReturnValue: SendTarget? {
get { pendingSendTargetForReturnValueLock.withLock { pendingSendTargetForUnderlyingReturnValue } }
set { pendingSendTargetForReturnValueLock.withLock { pendingSendTargetForUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var pendingSendTargetForClosure: ((TimelineItemIdentifier) -> SendTarget?)?
func pendingSendTarget(for itemID: TimelineItemIdentifier) -> SendTarget? {
pendingSendTargetForCallsCountLock.withLock { pendingSendTargetForUnderlyingCallsCount += 1 }
pendingSendTargetForReceivedItemID = itemID
pendingSendTargetForReceivedInvocationsLock.withLock { pendingSendTargetForUnderlyingReceivedInvocations.append(itemID) }
if let pendingSendTargetForClosure = pendingSendTargetForClosure {
return pendingSendTargetForClosure(itemID)
} else {
return pendingSendTargetForReturnValue
}
}
//MARK: - retrySend
private let retrySendTargetCallsCountLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingCallsCount = 0
var retrySendTargetCallsCount: Int {
get { retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount } }
set { retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount = newValue } }
}
var retrySendTargetCalled: Bool {
return retrySendTargetCallsCount > 0
}
private let retrySendTargetReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingReceivedArguments: (itemID: TimelineItemIdentifier, target: SendTarget)?
var retrySendTargetReceivedArguments: (itemID: TimelineItemIdentifier, target: SendTarget)? {
get { retrySendTargetReceivedArgumentsLock.withLock { retrySendTargetUnderlyingReceivedArguments } }
set { retrySendTargetReceivedArgumentsLock.withLock { retrySendTargetUnderlyingReceivedArguments = newValue } }
}
private let retrySendTargetReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingReceivedInvocations: [(itemID: TimelineItemIdentifier, target: SendTarget)] = []
var retrySendTargetReceivedInvocations: [(itemID: TimelineItemIdentifier, target: SendTarget)] {
get { retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations } }
set { retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations = newValue } }
}
nonisolated(unsafe) var retrySendTargetClosure: ((TimelineItemIdentifier, SendTarget) async -> Void)?
@concurrent func retrySend(_ itemID: TimelineItemIdentifier, target: SendTarget) async {
retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount += 1 }
retrySendTargetReceivedArguments = (itemID: itemID, target: target)
retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations.append((itemID: itemID, target: target)) }
await retrySendTargetClosure?(itemID, target)
}
//MARK: - abortSend
private let abortSendTargetCallsCountLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingCallsCount = 0
var abortSendTargetCallsCount: Int {
get { abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount } }
set { abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount = newValue } }
}
var abortSendTargetCalled: Bool {
return abortSendTargetCallsCount > 0
}
private let abortSendTargetReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingReceivedArguments: (itemID: TimelineItemIdentifier, target: SendTarget)?
var abortSendTargetReceivedArguments: (itemID: TimelineItemIdentifier, target: SendTarget)? {
get { abortSendTargetReceivedArgumentsLock.withLock { abortSendTargetUnderlyingReceivedArguments } }
set { abortSendTargetReceivedArgumentsLock.withLock { abortSendTargetUnderlyingReceivedArguments = newValue } }
}
private let abortSendTargetReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingReceivedInvocations: [(itemID: TimelineItemIdentifier, target: SendTarget)] = []
var abortSendTargetReceivedInvocations: [(itemID: TimelineItemIdentifier, target: SendTarget)] {
get { abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations } }
set { abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations = newValue } }
}
nonisolated(unsafe) var abortSendTargetClosure: ((TimelineItemIdentifier, SendTarget) async -> Void)?
@concurrent func abortSend(_ itemID: TimelineItemIdentifier, target: SendTarget) async {
abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount += 1 }
abortSendTargetReceivedArguments = (itemID: itemID, target: target)
abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations.append((itemID: itemID, target: target)) }
await abortSendTargetClosure?(itemID, target)
}
//MARK: - eventTimestamp
private let eventTimestampForCallsCountLock = NSLock()
@@ -13292,6 +13396,90 @@ nonisolated class TimelineProxyMock: TimelineProxyProtocol, @unchecked Sendable
return redactReasonReturnValue
}
}
//MARK: - retrySend
private let retrySendTargetCallsCountLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingCallsCount = 0
var retrySendTargetCallsCount: Int {
get { retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount } }
set { retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount = newValue } }
}
var retrySendTargetCalled: Bool {
return retrySendTargetCallsCount > 0
}
private let retrySendTargetReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)?
var retrySendTargetReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)? {
get { retrySendTargetReceivedArgumentsLock.withLock { retrySendTargetUnderlyingReceivedArguments } }
set { retrySendTargetReceivedArgumentsLock.withLock { retrySendTargetUnderlyingReceivedArguments = newValue } }
}
private let retrySendTargetReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)] = []
var retrySendTargetReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)] {
get { retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations } }
set { retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations = newValue } }
}
private let retrySendTargetReturnValueLock = NSLock()
private nonisolated(unsafe) var retrySendTargetUnderlyingReturnValue: Result<Bool, TimelineProxyError>!
var retrySendTargetReturnValue: Result<Bool, TimelineProxyError>! {
get { retrySendTargetReturnValueLock.withLock { retrySendTargetUnderlyingReturnValue } }
set { retrySendTargetReturnValueLock.withLock { retrySendTargetUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var retrySendTargetClosure: ((TimelineItemIdentifier.EventOrTransactionID, SendTarget) async -> Result<Bool, TimelineProxyError>)?
@concurrent func retrySend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget) async -> Result<Bool, TimelineProxyError> {
retrySendTargetCallsCountLock.withLock { retrySendTargetUnderlyingCallsCount += 1 }
retrySendTargetReceivedArguments = (eventOrTransactionID: eventOrTransactionID, target: target)
retrySendTargetReceivedInvocationsLock.withLock { retrySendTargetUnderlyingReceivedInvocations.append((eventOrTransactionID: eventOrTransactionID, target: target)) }
if let retrySendTargetClosure = retrySendTargetClosure {
return await retrySendTargetClosure(eventOrTransactionID, target)
} else {
return retrySendTargetReturnValue
}
}
//MARK: - abortSend
private let abortSendTargetCallsCountLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingCallsCount = 0
var abortSendTargetCallsCount: Int {
get { abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount } }
set { abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount = newValue } }
}
var abortSendTargetCalled: Bool {
return abortSendTargetCallsCount > 0
}
private let abortSendTargetReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)?
var abortSendTargetReceivedArguments: (eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)? {
get { abortSendTargetReceivedArgumentsLock.withLock { abortSendTargetUnderlyingReceivedArguments } }
set { abortSendTargetReceivedArgumentsLock.withLock { abortSendTargetUnderlyingReceivedArguments = newValue } }
}
private let abortSendTargetReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)] = []
var abortSendTargetReceivedInvocations: [(eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget)] {
get { abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations } }
set { abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations = newValue } }
}
private let abortSendTargetReturnValueLock = NSLock()
private nonisolated(unsafe) var abortSendTargetUnderlyingReturnValue: Result<Bool, TimelineProxyError>!
var abortSendTargetReturnValue: Result<Bool, TimelineProxyError>! {
get { abortSendTargetReturnValueLock.withLock { abortSendTargetUnderlyingReturnValue } }
set { abortSendTargetReturnValueLock.withLock { abortSendTargetUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var abortSendTargetClosure: ((TimelineItemIdentifier.EventOrTransactionID, SendTarget) async -> Result<Bool, TimelineProxyError>)?
@concurrent func abortSend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget) async -> Result<Bool, TimelineProxyError> {
abortSendTargetCallsCountLock.withLock { abortSendTargetUnderlyingCallsCount += 1 }
abortSendTargetReceivedArguments = (eventOrTransactionID: eventOrTransactionID, target: target)
abortSendTargetReceivedInvocationsLock.withLock { abortSendTargetUnderlyingReceivedInvocations.append((eventOrTransactionID: eventOrTransactionID, target: target)) }
if let abortSendTargetClosure = abortSendTargetClosure {
return await abortSendTargetClosure(eventOrTransactionID, target)
} else {
return abortSendTargetReturnValue
}
}
//MARK: - pin
private let pinEventIDCallsCountLock = NSLock()
@@ -264,7 +264,7 @@ struct TimelineRedactConfirmationInfo: Identifiable {
enum TimelineAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed(reason: String?, sendHandle: SendHandleProxy?)
case sendingFailed(reason: String?, itemID: TimelineItemIdentifier, target: SendTarget?)
case encryptionAuthenticity(String)
case encryptionForwarder(String)
case inviteAgain
@@ -758,8 +758,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
if case let .sendingFailed(.unknown(reason)) = eventTimelineItem.properties.deliveryStatus {
// A missing send handle only costs the retry/remove actions, the reason is still worth showing.
displayAlert(.sendingFailed(reason: reason, sendHandle: timelineController.sendHandle(for: itemID)))
// A missing target only costs the retry/remove actions, the reason is still worth showing.
displayAlert(.sendingFailed(reason: reason, itemID: itemID, target: timelineController.pendingSendTarget(for: 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).")
@@ -776,13 +776,12 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
}
private func retrySending(_ sendHandle: SendHandleProxy) {
Task {
if case .failure(let error) = await sendHandle.resend() {
MXLog.error("Failed retrying to send \(sendHandle.itemID): \(error)")
displayErrorToast(L10n.errorUnknown)
}
}
private func retrySending(_ itemID: TimelineItemIdentifier, target: SendTarget) {
Task { await timelineController.retrySend(itemID, target: target) }
}
private func abortSending(_ itemID: TimelineItemIdentifier, target: SendTarget) {
Task { await timelineController.abortSend(itemID, target: target) }
}
private func slashCommand(message: String) -> SlashCommand? {
@@ -1125,15 +1124,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 sendHandle):
case .sendingFailed(let reason, let itemID, let target):
state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed,
message: reason,
primaryButton: .init(title: sendHandle == nil ? L10n.actionOk : L10n.actionCancel, role: .cancel, action: nil),
verticalButtons: sendHandle.map { sendHandle in
[.init(title: L10n.actionRetry) { [weak self] in self?.retrySending(sendHandle) },
.init(title: L10n.actionRemoveMessage, role: .destructive) { [weak self] in
self?.timelineInteractionHandler.redact(sendHandle.itemID, reason: nil)
primaryButton: .init(title: target == nil ? L10n.actionOk : L10n.actionCancel, role: .cancel, action: nil),
verticalButtons: target.map { target in
[.init(title: L10n.actionRetry) { [weak self] in self?.retrySending(itemID, target: target) },
.init(title: target == .event ? L10n.actionRemoveMessage : L10n.actionDiscard, role: .destructive) { [weak self] in
self?.abortSending(itemID, target: target)
}]
})
case .encryptionAuthenticity(let message):
@@ -288,6 +288,53 @@ class TimelineController: TimelineControllerProtocol {
return nil
}
func pendingSendTarget(for itemID: TimelineItemIdentifier) -> SendTarget? {
for timelineItemProxy in activeTimelineItemProvider.itemProxies {
switch timelineItemProxy {
case .event(let item):
if item.id == itemID {
return item.pendingSend?.target
}
default:
continue
}
}
return nil
}
func retrySend(_ itemID: TimelineItemIdentifier, target: SendTarget) async {
guard let eventOrTransactionID = itemID.eventOrTransactionID else {
MXLog.error("Cannot retry sending on \(itemID).")
return
}
MXLog.info("Retry \(target) send in \(roomID)")
switch await activeTimeline.retrySend(eventOrTransactionID, target: target) {
case .success(let hadPendingSend):
MXLog.info("Finished retrying send, had one pending: \(hadPendingSend)")
case .failure(let error):
MXLog.error("Failed retrying send with error: \(error)")
}
}
func abortSend(_ itemID: TimelineItemIdentifier, target: SendTarget) async {
guard let eventOrTransactionID = itemID.eventOrTransactionID else {
MXLog.error("Cannot abort sending on \(itemID).")
return
}
MXLog.info("Abort \(target) send in \(roomID)")
switch await activeTimeline.abortSend(eventOrTransactionID, target: target) {
case .success(let hadPendingSend):
MXLog.info("Finished aborting send, had one pending: \(hadPendingSend)")
case .failure(let error):
MXLog.error("Failed aborting send with error: \(error)")
}
}
// MARK: - Sending
func sendMessage(_ message: String,
@@ -96,6 +96,12 @@ protocol TimelineControllerProtocol: Sendable {
func sendHandle(for itemID: TimelineItemIdentifier) -> SendHandleProxy?
func pendingSendTarget(for itemID: TimelineItemIdentifier) -> SendTarget?
func retrySend(_ itemID: TimelineItemIdentifier, target: SendTarget) async
func abortSend(_ itemID: TimelineItemIdentifier, target: SendTarget) async
func eventTimestamp(for itemID: TimelineItemIdentifier) -> Date?
// MARK: - Sending
@@ -84,11 +84,11 @@ final nonisolated class EventTimelineItemProxy: Sendable {
}
var deliveryStatus: TimelineItemDeliveryStatus? {
guard let localSendState = item.localSendState else {
guard let pendingSend else {
return nil
}
switch localSendState {
switch pendingSend.state {
case .sendingFailed(let error, _):
switch error {
case .identityViolations(let users):
@@ -107,6 +107,19 @@ final nonisolated class EventTimelineItemProxy: Sendable {
}
}
/// The item's own pending send or, when it has already gone out, our pending edit or redaction of it.
var pendingSend: (target: SendTarget, state: EventSendState)? {
if let state = item.localSendState {
(.event, state)
} else if let state = item.editSendState {
(.edit, state)
} else if let state = item.redactionSendState {
(.redaction, state)
} else {
nil
}
}
var canBeRepliedTo: Bool {
item.canBeRepliedTo
}
@@ -213,6 +213,36 @@ final class TimelineProxy: TimelineProxyProtocol {
}
}
func retrySend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget) async -> Result<Bool, TimelineProxyError> {
MXLog.info("Retrying \(target) send on timeline item: \(eventOrTransactionID)")
do {
let hadPendingSend = try await timeline.retrySend(itemId: eventOrTransactionID.rustValue, target: target)
MXLog.info("Retried \(target) send on timeline item: \(eventOrTransactionID), had one pending: \(hadPendingSend)")
return .success(hadPendingSend)
} catch {
MXLog.error("Failed retrying \(target) send on timeline item: \(eventOrTransactionID) with error: \(error)")
return .failure(.sdkError(error))
}
}
func abortSend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, target: SendTarget) async -> Result<Bool, TimelineProxyError> {
MXLog.info("Aborting \(target) send on timeline item: \(eventOrTransactionID)")
do {
let hadPendingSend = try await timeline.abortSend(itemId: eventOrTransactionID.rustValue, target: target)
MXLog.info("Aborted \(target) send on timeline item: \(eventOrTransactionID), had one pending: \(hadPendingSend)")
return .success(hadPendingSend)
} catch {
MXLog.error("Failed aborting \(target) send on timeline item: \(eventOrTransactionID) with error: \(error)")
return .failure(.sdkError(error))
}
}
func getLoadedReplyDetails(eventID: String) async -> Result<InReplyToDetails, TimelineProxyError> {
do {
return try await .success(timeline.loadReplyDetails(eventIdStr: eventID))
@@ -99,6 +99,12 @@ protocol TimelineProxyProtocol: Sendable {
func redact(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID,
reason: String?) async -> Result<Void, TimelineProxyError>
func retrySend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID,
target: SendTarget) async -> Result<Bool, TimelineProxyError>
func abortSend(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID,
target: SendTarget) async -> Result<Bool, TimelineProxyError>
func pin(eventID: String) async -> Result<Bool, TimelineProxyError>
func unpin(eventID: String) async -> Result<Bool, TimelineProxyError>
@@ -549,7 +549,7 @@ final class TimelineViewModelTests {
// Given a room with a message that failed to send for a known reason
let items = [TextRoomTimelineItem(eventID: "t1", sendFailure: .unknown(reason: "M_TOO_LARGE"))]
let timelineController = TimelineControllerMock(.init(timelineItems: items))
timelineController.sendHandleForReturnValue = .mock
timelineController.pendingSendTargetForReturnValue = .event
let viewModel = makeViewModel(timelineController: timelineController)
viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id))