mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
1aa9b29eb5
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.
376 lines
12 KiB
Swift
376 lines
12 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 localSendState = item.localSendState else {
|
|
return nil
|
|
}
|
|
|
|
switch localSendState {
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|