Files
letro-ios/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewDataSource.swift
Stefan Ceriu e5a49c568f Adopt room info power levels (#4245)
* Use the newly RoomInfo published PowerLevels; update permissions at the same time as the room info and avoid unnecessary async calls.

* Introduce a RoomInfoProxyProtocol and associated mock and move away from mocked SDK RoomInfos

* Bump the SDK to v25.06.23

* Expose new non-throwing methods for checking one's own user permissions

* Fix the unit and preview tests (except the TimelineMediaPreviewDetails for which simplified snapshots were generated)

* Converge on one single implementation for computing a room's avatar.

* Fix more unit tests

* Fix the TimelineMediaPreviewDetailsView snapshot tests

This reverts commit 9b6c819e611ad2fa3de2c34a4a7aa556fc9faadd.

---------

Co-authored-by: Doug <douglase@element.io>
2025-06-24 17:48:00 +01:00

316 lines
13 KiB
Swift

//
// Copyright 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 Combine
import Foundation
import QuickLook
/// A dedicated data source for QLPreviewController to support timeline updates. This was added to
/// workaround the fact that calling `reloadData` on the controller **always** reloads the current
/// item (even if hasn't changed), so any interaction (zoom, media playback, scroll position) would be
/// lost.
///
/// This data source pads the initial array with 100 spaces before and after, adding any pagination into
/// this fixed space. This removes the need to reload the data and preserves the current item's index
/// in the data.
class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
/// All of the items in the timeline that can be previewed.
private(set) var previewItems: [TimelineMediaPreviewItem.Media]
let previewItemsPaginationPublisher = PassthroughSubject<Void, Never>()
private let initialItem: EventBasedMessageTimelineItemProtocol
/// The index of the initial item inside of `previewItems` that is to be shown.
let initialItemIndex: Int
/// The media item that is currently being previewed.
private(set) var currentItem: TimelineMediaPreviewItem
private var backwardPadding: Int
private var forwardPadding: Int
var paginationState: PaginationState
init(itemViewStates: [RoomTimelineItemViewState],
initialItem: EventBasedMessageTimelineItemProtocol,
initialPadding: Int = 100,
paginationState: PaginationState) {
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
self.initialItem = initialItem
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialItem.id.eventOrTransactionID }) {
initialItemIndex = initialItemArrayIndex + initialPadding
currentItem = .media(previewItems[initialItemArrayIndex])
} else {
// The timeline hasn't loaded the initial item yet, so replace the whatever was loaded with
// the item the user wants to preview.
initialItemIndex = initialPadding
previewItems = [.init(timelineItem: initialItem)]
currentItem = .media(previewItems[0])
}
backwardPadding = initialPadding
forwardPadding = initialPadding
self.paginationState = paginationState
}
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
currentItem = item
}
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates.compactMap { itemViewState in
guard let newItem = TimelineMediaPreviewItem.Media(roomTimelineItemViewState: itemViewState) else { return nil }
// If an item already exists use that instead to preserve the file handle, download error etc.
if let oldItem = previewItems.first(where: { $0.id == newItem.id }) {
oldItem.timelineItem = newItem.timelineItem
return oldItem
}
return newItem
}
var hasPaginated = false
if let range = newItems.map(\.id).firstRange(of: previewItems.map(\.id)) {
let backPaginationCount = range.lowerBound
let forwardPaginationCount = newItems.indices.upperBound - range.upperBound
// Don't worry about negative padding here. Turns out that it just limits
// the displayable items from growing any more, but makes sure that the
// current item doesn't jump around so we don't need to reload anything.
backwardPadding -= backPaginationCount
forwardPadding -= forwardPaginationCount
if backPaginationCount > 0 || forwardPaginationCount > 0 {
hasPaginated = true
}
} else {
// When the timeline is loading items from the store and the initial item is the only
// preview in the array, we don't want to wipe it out, so if the existing items aren't
// found within the new items then let's ignore the update for now. This comes with a
// tradeoff that when a media gets redacted, no more previews will be added to the viewer.
//
// Note for the future if anyone wants to fix the redaction issue: Reloading the data source,
// will also reload the current item resetting any interaction the user has made with it.
// If you ignore the pagination, then the next time they swipe they'll land on a different
// media but this is probably less jarring overall. I hate QLPreviewController!
MXLog.info("Ignoring update: unable to find existing preview items range.")
return
}
previewItems = newItems
if hasPaginated {
previewItemsPaginationPublisher.send()
}
}
// MARK: - QLPreviewControllerDataSource
var firstPreviewItemIndex: Int { backwardPadding }
var lastPreviewItemIndex: Int { backwardPadding + previewItems.count - 1 }
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewItems.count + backwardPadding + forwardPadding
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
let arrayIndex = index - backwardPadding
if index < firstPreviewItemIndex {
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
} else if index > lastPreviewItemIndex {
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
} else {
return previewItems[arrayIndex]
}
}
}
// MARK: - TimelineMediaPreviewItem
enum TimelineMediaPreviewItem: Equatable {
case media(Media)
case loading(Loading)
/// Wraps a media file and title to be previewed with QuickLook.
@Observable class Media: NSObject, QLPreviewItem, Identifiable {
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
var downloadError: Error?
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
self.timelineItem = timelineItem
}
init?(roomTimelineItemViewState: RoomTimelineItemViewState) {
switch roomTimelineItemViewState.type {
case .audio(let audioRoomTimelineItem):
timelineItem = audioRoomTimelineItem
case .file(let fileRoomTimelineItem):
timelineItem = fileRoomTimelineItem
case .image(let imageRoomTimelineItem):
timelineItem = imageRoomTimelineItem
case .video(let videoRoomTimelineItem):
timelineItem = videoRoomTimelineItem
default:
return nil
}
}
// MARK: Identifiable
/// The timeline item's event or transaction ID.
///
/// We're identifying items by this to ensure that all matching is made using only this part of the identifier. This is
/// because the unique ID will be different across timelines so when the initial item comes from a regular timeline and
/// we build a filtered timeline to fetch the other media items, it is impossible to match by the `TimelineItemIdentifier`.
var id: TimelineItemIdentifier.EventOrTransactionID {
guard let id = timelineItem.id.eventOrTransactionID else { fatalError("Virtual items cannot be previewed.") }
return id
}
// MARK: QLPreviewItem
var previewItemURL: URL? {
fileHandle?.url
}
var previewItemTitle: String? {
switch fileHandle?.url {
case .some: filename
case .none: " " // Don't show any background text when the preview is still loading.
}
}
// MARK: Event details
var sender: TimelineItemSender {
timelineItem.sender
}
var timestamp: Date {
timelineItem.timestamp
}
// MARK: Media details
var mediaSource: MediaSourceProxy? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.source
case let fileItem as FileRoomTimelineItem:
fileItem.content.source
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.source
default:
nil
}
}
var thumbnailMediaSource: MediaSourceProxy? {
switch timelineItem {
case let fileItem as FileRoomTimelineItem:
fileItem.content.thumbnailSource
case let imageItem as ImageRoomTimelineItem:
imageItem.content.thumbnailInfo?.source
case let videoItem as VideoRoomTimelineItem:
videoItem.content.thumbnailInfo?.source
default:
nil
}
}
var filename: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.filename
case let fileItem as FileRoomTimelineItem:
fileItem.content.filename
case let imageItem as ImageRoomTimelineItem:
imageItem.content.filename
case let videoItem as VideoRoomTimelineItem:
videoItem.content.filename
default:
nil
}
}
var fileSize: Double? {
previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize
}
private var expectedFileSize: Double? {
let fileSize: UInt? = switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.fileSize
case let fileItem as FileRoomTimelineItem:
fileItem.content.fileSize
case let imageItem as ImageRoomTimelineItem:
imageItem.content.imageInfo.fileSize
case let videoItem as VideoRoomTimelineItem:
videoItem.content.videoInfo.fileSize
default:
nil
}
return fileSize.map(Double.init)
}
var caption: String? {
timelineItem.mediaCaption
}
var contentType: String? {
switch timelineItem {
case let audioItem as AudioRoomTimelineItem:
audioItem.content.contentType?.localizedDescription
case let fileItem as FileRoomTimelineItem:
fileItem.content.contentType?.localizedDescription
case let imageItem as ImageRoomTimelineItem:
imageItem.content.contentType?.localizedDescription
case let videoItem as VideoRoomTimelineItem:
videoItem.content.contentType?.localizedDescription
default:
nil
}
}
var blurhash: String? {
switch timelineItem {
case let imageItem as ImageRoomTimelineItem:
imageItem.content.blurhash
case let videoItem as VideoRoomTimelineItem:
videoItem.content.blurhash
default:
nil
}
}
}
class Loading: NSObject, QLPreviewItem {
static let paginatingBackwards = Loading(state: .paginating(.backwards))
static let paginatingForwards = Loading(state: .paginating(.forwards))
static let timelineStart = Loading(state: .timelineStart)
static let timelineEnd = Loading(state: .timelineEnd)
enum State { case paginating(PaginationDirection), timelineStart, timelineEnd }
let state: State
let previewItemURL: URL? = nil
let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text.
init(state: State) {
self.state = state
}
}
}