mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
15627e4512
* Only show a spinner when opening the media viewer is slow * Keep a sent local echo's page in the media viewer * Fix the caption visibility. --------- Co-authored-by: Stefan Ceriu <stefan.ceriu@gmail.com> Co-authored-by: Matthew Hodgson <matthew@matrix.org>
478 lines
20 KiB
Swift
478 lines
20 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// 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
|
|
import Synchronization
|
|
|
|
/// 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>()
|
|
|
|
/// 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
|
|
|
|
/// Reaching the end of the timeline changes which placeholder an index shows, so a change
|
|
/// needs to be published too, not only the arrival of new items.
|
|
var paginationState: TimelinePaginationState {
|
|
didSet {
|
|
guard paginationState != oldValue else { return }
|
|
previewItemsPaginationPublisher.send()
|
|
}
|
|
}
|
|
|
|
/// The gallery attachments to include, so that a gallery only contributes the media being browsed.
|
|
private let allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?
|
|
|
|
/// Builds a data source spanning every previewable attachment in the timeline, paginating
|
|
/// as the user swipes past the loaded range. Used when tapping a standalone media message.
|
|
init(itemViewStates: [RoomTimelineItemViewState],
|
|
initialItem: EventBasedMessageTimelineItemProtocol,
|
|
initialPadding: Int = 100,
|
|
paginationState: TimelinePaginationState,
|
|
allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]? = nil) {
|
|
self.allowedGalleryItemTypes = allowedGalleryItemTypes
|
|
|
|
previewItems = itemViewStates.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
|
|
|
let initialPreviewID = TimelineMediaPreviewItem.Media(timelineItem: initialItem).id
|
|
if let initialItemArrayIndex = previewItems.firstIndex(where: { $0.id == initialPreviewID }) {
|
|
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
|
|
}
|
|
|
|
/// Builds a data source scoped to a single gallery's previewable attachments.
|
|
/// Used when the user taps a tile inside a gallery message — paging is local to that
|
|
/// gallery and there's no timeline pagination to drive.
|
|
init(galleryItem: GalleryRoomTimelineItem,
|
|
initialIndex: Int) {
|
|
allowedGalleryItemTypes = nil // All of the gallery's attachments are shown.
|
|
|
|
let media = galleryItem.content.items.map { item in
|
|
TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: item)
|
|
}
|
|
|
|
if media.indices.contains(initialIndex) {
|
|
// We set the entire gallery here up front.
|
|
previewItems = media
|
|
initialItemIndex = initialIndex
|
|
currentItem = .media(media[initialIndex])
|
|
} else {
|
|
// Fall back to a synthetic placeholder for empty galleries — shouldn't happen in practice.
|
|
previewItems = [.init(timelineItem: galleryItem)]
|
|
initialItemIndex = 0
|
|
currentItem = .media(previewItems[0])
|
|
}
|
|
|
|
// And we disable any use of the timeline by configuring the data source as though everything has paginated.
|
|
backwardPadding = 0
|
|
forwardPadding = 0
|
|
paginationState = .init(backward: .endReached, forward: .endReached)
|
|
}
|
|
|
|
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
|
|
currentItem = item
|
|
}
|
|
|
|
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
|
|
let newItems: [TimelineMediaPreviewItem.Media] = itemViewStates
|
|
.flatMap { $0.previewableMedia(allowedGalleryItemTypes: allowedGalleryItemTypes) }
|
|
.map { newItem in
|
|
// 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 || $0.isLocalEcho(of: newItem) }) {
|
|
oldItem.content = newItem.content
|
|
oldItem.id = newItem.id // Make sure a local echo's transaction ID → event ID.
|
|
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 == .endReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
|
|
} else if index > lastPreviewItemIndex {
|
|
return paginationState.forward == .endReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
|
|
} else {
|
|
return previewItems[arrayIndex]
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - TimelineMediaPreviewItem
|
|
|
|
enum TimelineMediaPreviewItem: Equatable {
|
|
case media(Media)
|
|
case contentScan(Scan)
|
|
case loading(Loading)
|
|
|
|
/// The media item this preview is for, regardless of its content scanning state.
|
|
var mediaItem: Media? {
|
|
switch self {
|
|
case .media(let mediaItem): mediaItem
|
|
case .contentScan(let scan): scan.media
|
|
case .loading: nil
|
|
}
|
|
}
|
|
|
|
/// A media item that is being processed by the content scanner, or that failed a scan
|
|
/// and therefore must not be downloaded or previewed.
|
|
struct Scan: Equatable {
|
|
enum State: Equatable {
|
|
case scanning
|
|
case failure(ContentScanningFailure)
|
|
}
|
|
|
|
let media: Media
|
|
let state: State
|
|
}
|
|
|
|
/// Wraps a media file and title to be previewed with QuickLook.
|
|
@Observable class Media: NSObject, QLPreviewItem, Identifiable {
|
|
/// A standalone timeline message, or a single gallery attachment with its parent event.
|
|
fileprivate enum Content {
|
|
case timelineItem(EventBasedMessageTimelineItemProtocol)
|
|
case galleryItem(parent: EventBasedMessageTimelineItemProtocol, item: GalleryItem)
|
|
}
|
|
|
|
fileprivate var content: Content {
|
|
didSet { updatePreviewItemValues() }
|
|
}
|
|
|
|
/// The parent event, so menu actions (redact, reply, …) target the gallery as a whole.
|
|
var timelineItem: EventBasedMessageTimelineItemProtocol {
|
|
switch content {
|
|
case .timelineItem(let timelineItem): timelineItem
|
|
case .galleryItem(let parent, _): parent
|
|
}
|
|
}
|
|
|
|
var fileHandle: MediaFileHandleProxy? {
|
|
didSet { updatePreviewItemValues() }
|
|
}
|
|
|
|
var downloadError: Error?
|
|
|
|
/// A stable identifier that's unique per preview item, even for gallery attachments that would
|
|
/// otherwise share their parent event's ID. Not stable for local echoes however, those will
|
|
/// transition from transaction ID to event ID once sent.
|
|
fileprivate(set) var id: MediaPreviewItemID
|
|
|
|
/// Whether this is the local echo that `sentItem` is the remote echo of. Sending swaps the
|
|
/// transaction ID for an event ID but the unique ID remains stable.
|
|
fileprivate func isLocalEcho(of sentItem: Media) -> Bool {
|
|
guard timelineItem.id.transactionID != nil, sentItem.timelineItem.id.eventID != nil,
|
|
timelineItem.id.uniqueID == sentItem.timelineItem.id.uniqueID else { return false }
|
|
switch (content, sentItem.content) {
|
|
case (.timelineItem, .timelineItem): return true
|
|
case (.galleryItem(_, let item), .galleryItem(_, let sentGalleryItem)): return item.id.mediaIndex == sentGalleryItem.id.mediaIndex
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
|
|
content = .timelineItem(timelineItem)
|
|
id = MediaPreviewItemID(timelineItem: timelineItem)
|
|
}
|
|
|
|
init?(roomTimelineItemViewState: RoomTimelineItemViewState) {
|
|
let timelineItem: EventBasedMessageTimelineItemProtocol
|
|
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
|
|
}
|
|
content = .timelineItem(timelineItem)
|
|
id = MediaPreviewItemID(timelineItem: timelineItem)
|
|
}
|
|
|
|
/// Wraps a single attachment of a gallery message. `.other` items have no media source,
|
|
/// so QuickLook falls back to its default unsupported-item screen.
|
|
init(galleryParent: GalleryRoomTimelineItem, item: GalleryItem) {
|
|
content = .galleryItem(parent: galleryParent, item: item)
|
|
id = .galleryItem(item.id)
|
|
}
|
|
|
|
// MARK: QLPreviewItem
|
|
|
|
private let _previewItemURL = Mutex<URL?>(nil)
|
|
nonisolated var previewItemURL: URL? { // nonisolated as QuickLook can call from any thread (macOS 26).
|
|
_previewItemURL.withLock { $0 }
|
|
}
|
|
|
|
private let _previewItemTitle = Mutex<String?>(" ")
|
|
nonisolated var previewItemTitle: String? { // nonisolated as QuickLook can call from any thread (macOS 26).
|
|
_previewItemTitle.withLock { $0 }
|
|
}
|
|
|
|
private func updatePreviewItemValues() {
|
|
let url = fileHandle?.url
|
|
_previewItemURL.withLock { $0 = url }
|
|
|
|
// Don't show any background text (" ") while the preview is still loading.
|
|
_previewItemTitle.withLock { $0 = url == nil ? " " : filename }
|
|
}
|
|
|
|
// MARK: Event details
|
|
|
|
var sender: TimelineItemSender {
|
|
timelineItem.sender
|
|
}
|
|
|
|
var timestamp: Date {
|
|
timelineItem.timestamp
|
|
}
|
|
|
|
// MARK: Media details
|
|
|
|
/// The kind of media, used to decide how it should be saved.
|
|
enum Kind { case image, video, file }
|
|
|
|
var kind: Kind {
|
|
switch content {
|
|
case .galleryItem(_, let item):
|
|
switch item {
|
|
case .image: .image
|
|
case .video: .video
|
|
case .audio, .file, .other: .file
|
|
}
|
|
case .timelineItem(let timelineItem):
|
|
switch timelineItem {
|
|
case is ImageRoomTimelineItem: .image
|
|
case is VideoRoomTimelineItem: .video
|
|
default: .file
|
|
}
|
|
}
|
|
}
|
|
|
|
var mediaSource: MediaSourceProxy? {
|
|
switch content {
|
|
case .galleryItem(_, let item):
|
|
item.mediaSource
|
|
case .timelineItem(let timelineItem):
|
|
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 content {
|
|
case .galleryItem(_, let item):
|
|
item.thumbnailSource
|
|
case .timelineItem(let timelineItem):
|
|
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 content {
|
|
case .galleryItem(_, let item):
|
|
item.filename
|
|
case .timelineItem(let timelineItem):
|
|
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: UInt? {
|
|
previewItemURL.flatMap { try? FileManager.default.sizeForItem(at: $0) } ?? expectedFileSize
|
|
}
|
|
|
|
private var expectedFileSize: UInt? {
|
|
switch content {
|
|
case .galleryItem(_, let item):
|
|
item.fileSize
|
|
case .timelineItem(let timelineItem):
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
var hasCaption: Bool {
|
|
// No need to special-case gallery items here, captions live on the gallery event itself
|
|
timelineItem.hasMediaCaption
|
|
}
|
|
|
|
var caption: String? {
|
|
timelineItem.mediaCaption
|
|
}
|
|
|
|
var formattedCaption: AttributedString? {
|
|
timelineItem.formattedMediaCaption
|
|
}
|
|
|
|
var contentType: String? {
|
|
switch content {
|
|
case .galleryItem(_, let item):
|
|
item.contentType?.localizedDescription
|
|
case .timelineItem(let timelineItem):
|
|
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 content {
|
|
case .galleryItem(_, let item):
|
|
item.blurhash
|
|
case .timelineItem(let timelineItem):
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension RoomTimelineItemViewState {
|
|
/// The media of this item that can be previewed, flattening a gallery message into its individual
|
|
/// attachments so that they can be browsed alongside the timeline's other media.
|
|
/// - Parameter allowedGalleryItemTypes: Restricts a gallery's attachments to the media being
|
|
/// browsed, as the SDK can only filter the gallery event as a whole.
|
|
func previewableMedia(allowedGalleryItemTypes: [TimelineAllowedGalleryItemType]?) -> [TimelineMediaPreviewItem.Media] {
|
|
guard case .gallery(let galleryItem) = type else {
|
|
return [TimelineMediaPreviewItem.Media(roomTimelineItemViewState: self)].compactMap { $0 }
|
|
}
|
|
|
|
return galleryItem.content.items(matching: allowedGalleryItemTypes).map {
|
|
TimelineMediaPreviewItem.Media(galleryParent: galleryItem, item: $0.item)
|
|
}
|
|
}
|
|
}
|