mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
345 lines
11 KiB
Swift
345 lines
11 KiB
Swift
//
|
|
// Copyright 2022-2024 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
|
|
|
|
/// A light wrapper around timeline items returned from Rust.
|
|
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.
|
|
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.
|
|
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
|
|
}
|
|
|
|
/// A light wrapper around event timeline items returned from Rust.
|
|
class EventTimelineItemProxy {
|
|
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))
|
|
}
|
|
|
|
lazy var deliveryStatus: TimelineItemDeliveryStatus? = {
|
|
guard let localSendState = item.localSendState else {
|
|
return nil
|
|
}
|
|
|
|
switch localSendState {
|
|
case .sendingFailed(let error, let isRecoverable):
|
|
switch error {
|
|
case .identityViolations(let users):
|
|
return .sendingFailed(.verifiedUser(.changedIdentity(users: users)))
|
|
case .insecureDevices(let userDeviceMap):
|
|
return .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: userDeviceMap)))
|
|
default:
|
|
return .sendingFailed(.unknown)
|
|
}
|
|
case .notSentYet:
|
|
return .sending
|
|
case .sent:
|
|
return .sent
|
|
}
|
|
}()
|
|
|
|
lazy var canBeRepliedTo = item.canBeRepliedTo
|
|
|
|
lazy var content = item.content
|
|
|
|
lazy var isOwn = item.isOwn
|
|
|
|
lazy var isEditable = item.isEditable
|
|
|
|
lazy var sender: TimelineItemSender = {
|
|
let profile = item.senderProfile
|
|
|
|
switch profile {
|
|
case let .ready(displayName, isDisplayNameAmbiguous, avatarUrl):
|
|
return .init(id: item.sender,
|
|
displayName: displayName,
|
|
isDisplayNameAmbiguous: isDisplayNameAmbiguous,
|
|
avatarURL: avatarUrl.flatMap(URL.init(string:)))
|
|
default:
|
|
return .init(id: item.sender,
|
|
displayName: nil,
|
|
isDisplayNameAmbiguous: false,
|
|
avatarURL: nil)
|
|
}
|
|
}()
|
|
|
|
lazy var reactions = item.reactions
|
|
|
|
lazy var timestamp = Date(timeIntervalSince1970: TimeInterval(item.timestamp / 1000))
|
|
|
|
lazy var debugInfo: TimelineItemDebugInfo = {
|
|
let debugInfo = item.lazyProvider.debugInfo()
|
|
return TimelineItemDebugInfo(model: debugInfo.model, originalJSON: debugInfo.originalJson, latestEditJSON: debugInfo.latestEditJson)
|
|
}()
|
|
|
|
lazy var shieldState = item.lazyProvider.getShields(strict: false)
|
|
|
|
lazy var sendHandle = item.lazyProvider.getSendHandle()
|
|
|
|
lazy var shouldBoost = item.lazyProvider.containsOnlyEmojis()
|
|
|
|
lazy var readReceipts = item.readReceipts
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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: .init(noPointer: .init()))
|
|
}
|
|
}
|
|
|
|
struct VideoInfoProxy: Hashable {
|
|
let source: MediaSourceProxy
|
|
private(set) var duration: TimeInterval
|
|
private(set) var size: CGSize?
|
|
private(set) var aspectRatio: CGFloat?
|
|
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)
|
|
}
|
|
}
|
|
|
|
struct ImageInfoProxy: Hashable {
|
|
let source: MediaSourceProxy
|
|
private(set) var size: CGSize?
|
|
private(set) var aspectRatio: CGFloat?
|
|
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 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
|
|
}
|
|
}
|