mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
a87d65b05a
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.
407 lines
13 KiB
Swift
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
|
|
}
|
|
}
|