Files
element-x-ios/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift
T
Stefan Ceriu a87d65b05a Surface failed reactions as a send failure on their message
A reaction that fails to send can now block the room's send queue, but the
timeline only surfaced failures for the item itself, its edit or its
redaction. EventTimelineItemProxy.pendingSend now also falls back to our
first failed reaction, targeting it with SendTarget.reaction(key:), so the
message shows the red exclamation mark and the sending failed alert can
retry or remove it.

In-flight reactions are deliberately ignored so reacting doesn't flash a
sending indicator on the message.
2026-09-30 15:16:13 +03:00

407 lines
13 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
import MatrixRustSDKMocks
/// A light wrapper around timeline items returned from Rust.
nonisolated enum TimelineItemProxy {
case event(EventTimelineItemProxy)
case virtual(MatrixRustSDK.VirtualTimelineItem, uniqueID: TimelineItemIdentifier.UniqueID)
case unknown(MatrixRustSDK.TimelineItem)
init(item: MatrixRustSDK.TimelineItem) {
if let eventItem = item.asEvent() {
self = .event(EventTimelineItemProxy(item: eventItem, uniqueID: .init(rustValue: item.uniqueId())))
} else if let virtualItem = item.asVirtual() {
self = .virtual(virtualItem, uniqueID: .init(rustValue: item.uniqueId()))
} else {
self = .unknown(item)
}
}
var isEvent: Bool {
switch self {
case .event:
return true
default:
return false
}
}
}
/// The delivery status for the item.
nonisolated enum TimelineItemDeliveryStatus: Hashable {
case sending
case sent
case sendingFailed(TimelineItemSendFailure)
var isSendingFailed: Bool {
switch self {
case .sending, .sent: false
case .sendingFailed: true
}
}
}
/// The reason a timeline item failed to send.
nonisolated enum TimelineItemSendFailure: Hashable {
enum VerifiedUser: Hashable {
case hasUnsignedDevice(devices: [String: [String]])
case changedIdentity(users: [String])
var affectedUserIDs: [String] {
switch self {
case .hasUnsignedDevice(let devices): Array(devices.keys)
case .changedIdentity(let users): users
}
}
}
case verifiedUser(VerifiedUser)
case unknown(reason: String?)
}
/// A light wrapper around event timeline items returned from Rust.
///
/// The proxy is shared between the main actor and the background timeline item
/// building, so it's stateless and forwards everything to the underlying
/// (internally synchronised) Rust object.
final nonisolated class EventTimelineItemProxy: Sendable {
let item: MatrixRustSDK.EventTimelineItem
let id: TimelineItemIdentifier
init(item: MatrixRustSDK.EventTimelineItem, uniqueID: TimelineItemIdentifier.UniqueID) {
self.item = item
id = .event(uniqueID: uniqueID, eventOrTransactionID: .init(rustValue: item.eventOrTransactionId))
}
var deliveryStatus: TimelineItemDeliveryStatus? {
guard let pendingSend else {
return nil
}
switch pendingSend.state {
case .sendingFailed(let error, _):
switch error {
case .identityViolations(let users):
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(reason: nil))
}
case .notSentYet:
return .sending
case .sent:
return .sent
}
}
/// The item's own pending send or, when it has already gone out, our pending edit, redaction or failed reaction on 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 if let (key, state) = failedReaction {
(.reaction(key: key), state)
} else {
nil
}
}
/// Only failed reactions are surfaced as they block the send queue, in-flight ones would flash a sending indicator on every tap.
private var failedReaction: (key: String, state: EventSendState)? {
for reaction in item.reactions {
for sender in reaction.senders {
if let state = sender.sendState, case .sendingFailed = state {
return (reaction.key, state)
}
}
}
return nil
}
var canBeRepliedTo: Bool {
item.canBeRepliedTo
}
var content: TimelineItemContent {
item.content
}
var isOwn: Bool {
item.isOwn
}
var isEditable: Bool {
item.isEditable
}
var sender: TimelineItemSender {
TimelineItemSender(senderID: item.sender, senderProfile: item.senderProfile)
}
var forwarder: TimelineItemKeyForwarder? {
guard let forwarderID = item.forwarder, let forwarderProfile = item.forwarderProfile else {
return nil
}
return TimelineItemKeyForwarder(forwarderID: forwarderID, forwarderProfile: forwarderProfile)
}
var timestamp: Date {
Date(timeIntervalSince1970: TimeInterval(item.timestamp / 1000))
}
var debugInfo: TimelineItemDebugInfo {
let debugInfo = item.lazyProvider.debugInfo()
return TimelineItemDebugInfo(model: debugInfo.model, originalJSON: debugInfo.originalJson, latestEditJSON: debugInfo.latestEditJson)
}
var shieldState: ShieldState? {
item.lazyProvider.getShields(strict: false)
}
var sendHandle: SendHandle? {
item.lazyProvider.getSendHandle()
}
var shouldBoost: Bool {
item.lazyProvider.containsOnlyEmojis()
}
var readReceipts: [String: Receipt] {
item.readReceipts
}
var reactions: [Reaction] {
item.reactions
}
}
nonisolated struct TimelineItemDebugInfo: Identifiable, CustomStringConvertible {
let id = UUID()
let model: String
let originalJSON: String?
let latestEditJSON: String?
init(model: String, originalJSON: String?, latestEditJSON: String?) {
self.model = model
self.originalJSON = Self.prettyJsonFormattedString(from: originalJSON)
self.latestEditJSON = Self.prettyJsonFormattedString(from: latestEditJSON)
}
var description: String {
var description = model
if let originalJSON {
description += "\n\n\(originalJSON)"
}
if let latestEditJSON {
description += "\n\n\(latestEditJSON)"
}
return description
}
// MARK: - Private
private static func prettyJsonFormattedString(from string: String?) -> String? {
guard let string,
let data = string.data(using: .utf8),
let jsonDictionary = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers),
let jsonData = try? JSONSerialization.data(withJSONObject: jsonDictionary, options: [.prettyPrinted]) else {
return nil
}
return String(data: jsonData, encoding: .utf8)
}
}
nonisolated struct SendHandleProxy: Hashable {
enum Error: Swift.Error {
case sdkError(Swift.Error)
}
let itemID: TimelineItemIdentifier
let underlyingHandle: SendHandle
func resend() async -> Result<Void, Error> {
do {
try await underlyingHandle.tryResend()
return .success(())
} catch {
return .failure(.sdkError(error))
}
}
// MARK: - Hashable
static func == (lhs: SendHandleProxy, rhs: SendHandleProxy) -> Bool {
lhs.itemID == rhs.itemID
}
func hash(into hasher: inout Hasher) {
hasher.combine(itemID)
}
static var mock: SendHandleProxy {
.init(itemID: .event(uniqueID: .init(UUID().uuidString),
eventOrTransactionID: .eventID(UUID().uuidString)),
underlyingHandle: SendHandleSDKMock())
}
}
nonisolated struct VideoInfoProxy: Hashable {
let source: MediaSourceProxy
private(set) var duration: TimeInterval
// periphery:ignore - used via the synthesized Hashable conformance
private(set) var size: CGSize?
// periphery:ignore - used via the synthesized Hashable conformance
private(set) var aspectRatio: CGFloat?
// periphery:ignore - used via the synthesized Hashable conformance
private(set) var mimeType: String?
private(set) var fileSize: UInt?
init(source: MediaSource, duration: TimeInterval, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.source = MediaSourceProxy(source: source, mimeType: mimeType)
self.duration = duration
let mediaInfo = MediaInfoProxy(width: width, height: height, mimeType: mimeType)
size = mediaInfo.size
aspectRatio = mediaInfo.aspectRatio
self.mimeType = mediaInfo.mimeType
self.fileSize = fileSize
}
// MARK: - Mocks
private init(source: MediaSourceProxy, duration: TimeInterval, size: CGSize?, aspectRatio: CGFloat?, mimeType: String?, fileSize: UInt?) {
self.source = source
self.duration = duration
self.size = size
self.aspectRatio = aspectRatio
self.mimeType = mimeType
self.fileSize = fileSize
}
static var mockVideo: VideoInfoProxy {
guard let mediaSource = try? MediaSourceProxy(url: .mockMXCVideo, mimeType: nil) else {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource,
duration: 100,
size: .init(width: 1920, height: 1080),
aspectRatio: 1.78,
mimeType: nil,
fileSize: 45_167_000)
}
static func mockVideo(duration: TimeInterval) -> VideoInfoProxy {
.init(source: mockVideo.source,
duration: duration,
size: .init(width: 1920, height: 1080),
aspectRatio: 1.78,
mimeType: nil,
fileSize: 45_167_000)
}
}
nonisolated struct ImageInfoProxy: Hashable {
let source: MediaSourceProxy
private(set) var size: CGSize?
private(set) var aspectRatio: CGFloat?
// periphery:ignore - used via the synthesized Hashable conformance
private(set) var mimeType: String?
private(set) var fileSize: UInt?
init?(source: MediaSource?, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
guard let source else {
return nil
}
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize)
}
init(source: MediaSource, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.init(source: .init(source: source, mimeType: mimeType), width: width, height: height, mimeType: mimeType, fileSize: fileSize)
}
init(source: MediaSourceProxy, width: UInt64?, height: UInt64?, mimeType: String?, fileSize: UInt?) {
self.source = source
let mediaInfo = MediaInfoProxy(width: width, height: height, mimeType: mimeType)
size = mediaInfo.size
aspectRatio = mediaInfo.aspectRatio
self.mimeType = mediaInfo.mimeType
self.fileSize = fileSize
}
// MARK: - Mocks
private init(source: MediaSourceProxy, size: CGSize?, aspectRatio: CGFloat?, fileSize: UInt?) {
self.source = source
self.size = size
self.aspectRatio = aspectRatio
mimeType = source.mimeType
self.fileSize = fileSize
}
static var mockImage: ImageInfoProxy {
guard let mediaSource = try? MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpg") else {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 2730, height: 2048), aspectRatio: 4 / 3, fileSize: 717_000)
}
static var mockThumbnail: ImageInfoProxy {
guard let mediaSource = try? MediaSourceProxy(url: .mockMXCImage, mimeType: "image/jpg") else {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 800, height: 600), aspectRatio: 4 / 3, fileSize: 84000)
}
static var mockVideoThumbnail: ImageInfoProxy {
guard let mediaSource = try? MediaSourceProxy(url: .mockMXCVideo, mimeType: "image/jpg") else {
fatalError("Invalid mock media source URL")
}
return .init(source: mediaSource, size: .init(width: 800, height: 450), aspectRatio: 16 / 9, fileSize: 98000)
}
}
private nonisolated struct MediaInfoProxy: Hashable {
private(set) var size: CGSize?
private(set) var mimeType: String?
private(set) var aspectRatio: CGFloat?
init(width: UInt64?, height: UInt64?, mimeType: String?) {
if let width, let height {
size = .init(width: CGFloat(width), height: CGFloat(height))
if width > 0, height > 0 {
aspectRatio = CGFloat(width) / CGFloat(height)
}
}
self.mimeType = mimeType
}
}