mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 20:39:24 +00:00
Detect the timeline start/end when swiping through media files. (#3714)
This commit is contained in:
@@ -19,7 +19,7 @@ import QuickLook
|
||||
/// in the data.
|
||||
class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
/// All of the items in the timeline that can be previewed.
|
||||
private(set) var previewItems: [TimelineMediaPreviewItem]
|
||||
private(set) var previewItems: [TimelineMediaPreviewItem.Media]
|
||||
let previewItemsPaginationPublisher = PassthroughSubject<Void, Never>()
|
||||
|
||||
private let initialItem: EventBasedMessageTimelineItemProtocol
|
||||
@@ -27,30 +27,37 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
let initialItemIndex: Int
|
||||
|
||||
/// The media item that is currently being previewed.
|
||||
private(set) var currentItem: TimelineMediaPreviewItem?
|
||||
private(set) var currentItem: TimelineMediaPreviewItem
|
||||
|
||||
private var backwardPadding: Int
|
||||
private var forwardPadding: Int
|
||||
|
||||
init(itemViewStates: [RoomTimelineItemViewState], initialItem: EventBasedMessageTimelineItemProtocol, initialPadding: Int = 100) {
|
||||
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.init)
|
||||
var paginationState: PaginationState
|
||||
|
||||
init(itemViewStates: [RoomTimelineItemViewState],
|
||||
initialItem: EventBasedMessageTimelineItemProtocol,
|
||||
initialPadding: Int = 100,
|
||||
paginationState: PaginationState) {
|
||||
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
|
||||
self.initialItem = initialItem
|
||||
|
||||
let initialItemArrayIndex = previewItems.firstIndex { $0.id == initialItem.id } ?? 0
|
||||
initialItemIndex = initialItemArrayIndex + initialPadding
|
||||
currentItem = previewItems[initialItemArrayIndex]
|
||||
currentItem = .media(previewItems[initialItemArrayIndex])
|
||||
|
||||
backwardPadding = initialPadding
|
||||
forwardPadding = initialPadding
|
||||
|
||||
self.paginationState = paginationState
|
||||
}
|
||||
|
||||
func updateCurrentItem(_ item: TimelineMediaPreviewItem?) {
|
||||
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
|
||||
currentItem = item
|
||||
}
|
||||
|
||||
func updatePreviewItems(itemViewStates: [RoomTimelineItemViewState]) {
|
||||
let newItems: [TimelineMediaPreviewItem] = itemViewStates.compactMap { itemViewState in
|
||||
guard let newItem = TimelineMediaPreviewItem(roomTimelineItemViewState: itemViewState) else { return nil }
|
||||
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 }) {
|
||||
@@ -91,6 +98,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
|
||||
// MARK: - QLPreviewControllerDataSource
|
||||
|
||||
var firstPreviewItemIndex: Int { backwardPadding }
|
||||
var lastPreviewItemIndex: Int { backwardPadding + previewItems.count - 1 }
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
previewItems.count + backwardPadding + forwardPadding
|
||||
}
|
||||
@@ -98,167 +108,183 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> any QLPreviewItem {
|
||||
let arrayIndex = index - backwardPadding
|
||||
|
||||
if arrayIndex >= 0, arrayIndex < previewItems.count {
|
||||
return previewItems[arrayIndex]
|
||||
if index < firstPreviewItemIndex {
|
||||
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginating
|
||||
} else if index > lastPreviewItemIndex {
|
||||
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginating
|
||||
} else {
|
||||
return TimelineMediaPreviewLoadingItem.shared
|
||||
return previewItems[arrayIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TimelineMediaPreviewItem
|
||||
|
||||
/// Wraps a media file and title to be previewed with QuickLook.
|
||||
class TimelineMediaPreviewItem: NSObject, QLPreviewItem, Identifiable {
|
||||
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol
|
||||
var fileHandle: MediaFileHandleProxy?
|
||||
var downloadError: Error?
|
||||
enum TimelineMediaPreviewItem: Equatable {
|
||||
case media(Media)
|
||||
case loading(Loading)
|
||||
|
||||
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
|
||||
|
||||
var id: TimelineItemIdentifier { timelineItem.id }
|
||||
|
||||
// MARK: QLPreviewItem
|
||||
|
||||
var previewItemURL: URL? {
|
||||
// Falling back to a clear image allows the presentation animation to work when
|
||||
// the item is in the event cache and just needs to be loaded from the store.
|
||||
fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png")
|
||||
}
|
||||
|
||||
var previewItemTitle: String? {
|
||||
filename
|
||||
}
|
||||
|
||||
// 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
|
||||
/// Wraps a media file and title to be previewed with QuickLook.
|
||||
class Media: NSObject, QLPreviewItem, Identifiable {
|
||||
fileprivate(set) var timelineItem: EventBasedMessageTimelineItemProtocol
|
||||
var fileHandle: MediaFileHandleProxy?
|
||||
var downloadError: Error?
|
||||
|
||||
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
|
||||
self.timelineItem = timelineItem
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
var id: TimelineItemIdentifier { timelineItem.id }
|
||||
|
||||
// MARK: QLPreviewItem
|
||||
|
||||
var previewItemURL: URL? {
|
||||
// Falling back to a clear image allows the presentation animation to work when
|
||||
// the item is in the event cache and just needs to be loaded from the store.
|
||||
fileHandle?.url ?? Bundle.main.url(forResource: "clear", withExtension: "png")
|
||||
}
|
||||
|
||||
var previewItemTitle: String? {
|
||||
filename
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 paginating = Loading(state: .paginating)
|
||||
static let timelineStart = Loading(state: .timelineStart)
|
||||
static let timelineEnd = Loading(state: .timelineEnd)
|
||||
|
||||
enum State { case paginating, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineMediaPreviewLoadingItem: NSObject, QLPreviewItem {
|
||||
static let shared = TimelineMediaPreviewLoadingItem()
|
||||
|
||||
let previewItemURL: URL? = nil
|
||||
let previewItemTitle: String? = "" // Empty to force QLPreviewController to not show any text.
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct TimelineMediaPreviewViewState: BindableState {
|
||||
var dataSource: TimelineMediaPreviewDataSource
|
||||
|
||||
/// The media item that is currently being previewed.
|
||||
var currentItem: TimelineMediaPreviewItem? { dataSource.currentItem }
|
||||
var currentItem: TimelineMediaPreviewItem { dataSource.currentItem }
|
||||
/// All of the available actions for the current item.
|
||||
var currentItemActions: TimelineItemMenuActions?
|
||||
|
||||
@@ -32,9 +32,9 @@ struct TimelineMediaPreviewViewState: BindableState {
|
||||
|
||||
struct TimelineMediaPreviewViewStateBindings {
|
||||
/// A binding that will present the Details view for the specified item.
|
||||
var mediaDetailsItem: TimelineMediaPreviewItem?
|
||||
var mediaDetailsItem: TimelineMediaPreviewItem.Media?
|
||||
/// A binding that will present a confirmation to redact the specified item.
|
||||
var redactConfirmationItem: TimelineMediaPreviewItem?
|
||||
var redactConfirmationItem: TimelineMediaPreviewItem.Media?
|
||||
/// A binding that will present a document picker to export the specified file.
|
||||
var fileToExport: TimelineMediaPreviewFileExportPicker.File?
|
||||
|
||||
@@ -46,9 +46,10 @@ enum TimelineMediaPreviewAlertType {
|
||||
}
|
||||
|
||||
enum TimelineMediaPreviewViewAction {
|
||||
case updateCurrentItem(TimelineMediaPreviewItem?)
|
||||
case showCurrentItemDetails
|
||||
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem)
|
||||
case redactConfirmation(item: TimelineMediaPreviewItem)
|
||||
case updateCurrentItem(TimelineMediaPreviewItem)
|
||||
case showItemDetails(TimelineMediaPreviewItem.Media)
|
||||
case menuAction(TimelineItemMenuAction, item: TimelineMediaPreviewItem.Media)
|
||||
case redactConfirmation(item: TimelineMediaPreviewItem.Media)
|
||||
case timelineEndReached
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.appMediator = appMediator
|
||||
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineViewModel.context.viewState.timelineState.itemViewStates,
|
||||
initialItem: context.item),
|
||||
let timelineState = timelineViewModel.context.viewState.timelineState
|
||||
|
||||
super.init(initialViewState: TimelineMediaPreviewViewState(dataSource: .init(itemViewStates: timelineState.itemViewStates,
|
||||
initialItem: context.item,
|
||||
paginationState: timelineState.paginationState),
|
||||
transitionNamespace: context.namespace),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
@@ -57,14 +60,19 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
self?.state.dataSource.updatePreviewItems(itemViewStates: itemViewStates)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
timelineViewModel.context.$viewState.map(\.timelineState.paginationState)
|
||||
.removeDuplicates()
|
||||
.weakAssign(to: \.state.dataSource.paginationState, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func process(viewAction: TimelineMediaPreviewViewAction) {
|
||||
switch viewAction {
|
||||
case .updateCurrentItem(let item):
|
||||
Task { await updateCurrentItem(item) }
|
||||
case .showCurrentItemDetails:
|
||||
state.bindings.mediaDetailsItem = state.currentItem
|
||||
case .showItemDetails(let mediaItem):
|
||||
state.bindings.mediaDetailsItem = mediaItem
|
||||
case .menuAction(let action, let item):
|
||||
switch action {
|
||||
case .viewInRoomTimeline:
|
||||
@@ -78,28 +86,32 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
}
|
||||
case .redactConfirmation(let item):
|
||||
redactItem(item)
|
||||
case .timelineEndReached:
|
||||
showTimelineEndIndicator()
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem?) async {
|
||||
previewItem?.downloadError = nil // Clear any existing error.
|
||||
private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
|
||||
if case let .media(item) = previewItem {
|
||||
item.downloadError = nil // Clear any existing error.
|
||||
}
|
||||
state.dataSource.updateCurrentItem(previewItem)
|
||||
rebuildCurrentItemActions()
|
||||
|
||||
if let previewItem {
|
||||
currentItemIDHandler?(previewItem.id)
|
||||
if case let .media(mediaItem) = previewItem {
|
||||
currentItemIDHandler?(mediaItem.id)
|
||||
|
||||
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
|
||||
switch await mediaProvider.loadFileFromSource(source, filename: previewItem.filename) {
|
||||
if mediaItem.fileHandle == nil, let source = mediaItem.mediaSource {
|
||||
switch await mediaProvider.loadFileFromSource(source, filename: mediaItem.filename) {
|
||||
case .success(let handle):
|
||||
previewItem.fileHandle = handle
|
||||
state.fileLoadedPublisher.send(previewItem.id)
|
||||
mediaItem.fileHandle = handle
|
||||
state.fileLoadedPublisher.send(mediaItem.id)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed loading media: \(error)")
|
||||
context.objectWillChange.send() // Manually trigger the SwiftUI view update.
|
||||
previewItem.downloadError = error
|
||||
mediaItem.downloadError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,8 +119,9 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
|
||||
private func rebuildCurrentItemActions() {
|
||||
let timelineContext = timelineViewModel.context
|
||||
state.currentItemActions = if let currentItem = state.currentItem {
|
||||
TimelineItemMenuActionProvider(timelineItem: currentItem.timelineItem,
|
||||
state.currentItemActions = switch state.currentItem {
|
||||
case .media(let mediaItem):
|
||||
TimelineItemMenuActionProvider(timelineItem: mediaItem.timelineItem,
|
||||
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
|
||||
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
|
||||
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
|
||||
@@ -118,13 +131,13 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
timelineKind: timelineContext.viewState.timelineKind,
|
||||
emojiProvider: timelineContext.viewState.emojiProvider)
|
||||
.makeActions()
|
||||
} else {
|
||||
case .loading:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentItem() async {
|
||||
guard let currentItem = state.currentItem, let fileURL = currentItem.fileHandle?.url else {
|
||||
guard case let .media(mediaItem) = state.currentItem, let fileURL = mediaItem.fileHandle?.url else {
|
||||
MXLog.error("Unable to save an item without a URL, the button shouldn't be visible.")
|
||||
return
|
||||
}
|
||||
@@ -133,7 +146,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
|
||||
do {
|
||||
switch currentItem.timelineItem {
|
||||
switch mediaItem.timelineItem {
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
state.bindings.fileToExport = .init(url: fileURL)
|
||||
return // Don't show the indicator.
|
||||
@@ -158,7 +171,7 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
}
|
||||
}
|
||||
|
||||
private func redactItem(_ item: TimelineMediaPreviewItem) {
|
||||
private func redactItem(_ item: TimelineMediaPreviewItem.Media) {
|
||||
timelineViewModel.context.send(viewAction: .handleTimelineItemMenuAction(itemID: item.id, action: .redact))
|
||||
state.bindings.redactConfirmationItem = nil
|
||||
state.bindings.mediaDetailsItem = nil
|
||||
@@ -189,5 +202,11 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
|
||||
iconName: "xmark"))
|
||||
}
|
||||
|
||||
private func showTimelineEndIndicator() {
|
||||
userIndicatorController.submitIndicator(UserIndicator(id: statusIndicatorID,
|
||||
type: .toast,
|
||||
title: L10n.screenMediaDetailsNoMoreMediaToShow))
|
||||
}
|
||||
|
||||
private var statusIndicatorID: String { "\(Self.self)-Status" }
|
||||
}
|
||||
|
||||
+24
-22
@@ -9,7 +9,7 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineMediaPreviewDetailsView: View {
|
||||
let item: TimelineMediaPreviewItem
|
||||
let item: TimelineMediaPreviewItem.Media
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
@@ -132,7 +132,7 @@ struct TimelineMediaPreviewDetailsView: View {
|
||||
}
|
||||
|
||||
private struct ActionButton: View {
|
||||
let item: TimelineMediaPreviewItem
|
||||
let item: TimelineMediaPreviewItem.Media
|
||||
let action: TimelineItemMenuAction
|
||||
let context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
@@ -177,29 +177,31 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
||||
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
|
||||
|
||||
static var previews: some View {
|
||||
// swiftlint:disable force_unwrapping
|
||||
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem!,
|
||||
context: viewModel.context)
|
||||
.previewDisplayName("Image")
|
||||
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
|
||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||
})
|
||||
if case let .media(mediaItem) = viewModel.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context)
|
||||
.previewDisplayName("Image")
|
||||
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
|
||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!,
|
||||
context: loadingViewModel.context)
|
||||
.previewDisplayName("Loading")
|
||||
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
|
||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||
})
|
||||
if case let .media(mediaItem) = loadingViewModel.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context)
|
||||
.previewDisplayName("Loading")
|
||||
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
|
||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!,
|
||||
context: unknownTypeViewModel.context)
|
||||
.previewDisplayName("Unknown type")
|
||||
if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context)
|
||||
.previewDisplayName("Unknown type")
|
||||
}
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!,
|
||||
context: presentedOnRoomViewModel.context)
|
||||
.previewDisplayName("Incoming on Room")
|
||||
// swiftlint:enable force_unwrapping
|
||||
if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context)
|
||||
.previewDisplayName("Incoming on Room")
|
||||
}
|
||||
}
|
||||
|
||||
static func makeViewModel(contentType: UTType? = nil,
|
||||
|
||||
+4
-3
@@ -11,7 +11,7 @@ import SwiftUI
|
||||
struct TimelineMediaPreviewRedactConfirmationView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let item: TimelineMediaPreviewItem
|
||||
let item: TimelineMediaPreviewItem.Media
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
@@ -125,8 +125,9 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
||||
static let viewModel = makeViewModel(contentType: .jpeg)
|
||||
|
||||
static var previews: some View {
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, context: viewModel.context)
|
||||
if case let .media(mediaItem) = viewModel.state.currentItem {
|
||||
TimelineMediaPreviewRedactConfirmationView(item: mediaItem, context: viewModel.context)
|
||||
}
|
||||
}
|
||||
|
||||
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
|
||||
|
||||
@@ -17,7 +17,18 @@ struct TimelineMediaPreviewScreen: View {
|
||||
@State private var isFullScreen = false
|
||||
private var toolbarVisibility: Visibility { isFullScreen ? .hidden : .visible }
|
||||
|
||||
private var currentItem: TimelineMediaPreviewItem? { context.viewState.currentItem }
|
||||
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
|
||||
private var currentItemID: TimelineItemIdentifier? {
|
||||
guard case .media(let mediaItem) = currentItem else { return nil }
|
||||
return mediaItem.id
|
||||
}
|
||||
|
||||
private var shouldShowDownloadIndicator: Bool {
|
||||
switch currentItem {
|
||||
case .media(let mediaItem): mediaItem.fileHandle == nil
|
||||
case .loading(let loadingItem): loadingItem.state == .paginating
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -40,7 +51,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.onDisappear {
|
||||
itemIDHandler?(nil)
|
||||
}
|
||||
.zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace)
|
||||
.zoomTransition(sourceID: currentItemID, in: context.viewState.transitionNamespace)
|
||||
}
|
||||
|
||||
var quickLookPreview: some View {
|
||||
@@ -57,7 +68,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var fullScreenButton: some View {
|
||||
if currentItem != nil {
|
||||
if case .media = currentItem {
|
||||
Button {
|
||||
withAnimation { isFullScreen.toggle() }
|
||||
} label: {
|
||||
@@ -73,7 +84,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadStatusIndicator: some View {
|
||||
if currentItem?.downloadError != nil {
|
||||
if case let .media(mediaItem) = currentItem, mediaItem.downloadError != nil {
|
||||
VStack(spacing: 24) {
|
||||
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
|
||||
.foregroundStyle(.compound.iconCriticalPrimary)
|
||||
@@ -94,7 +105,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 40)
|
||||
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
|
||||
} else if currentItem?.fileHandle == nil {
|
||||
} else if shouldShowDownloadIndicator {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.tint(.compound.iconPrimary)
|
||||
@@ -103,7 +114,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var caption: some View {
|
||||
if let caption = currentItem?.caption, !isFullScreen {
|
||||
if case let .media(mediaItem) = currentItem, let caption = mediaItem.caption, !isFullScreen {
|
||||
Text(caption)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
@@ -133,9 +144,9 @@ struct TimelineMediaPreviewScreen: View {
|
||||
toolbarHeader
|
||||
}
|
||||
|
||||
if currentItem != nil {
|
||||
if case let .media(mediaItem) = currentItem {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
|
||||
Button { context.send(viewAction: .showItemDetails(mediaItem)) } label: {
|
||||
CompoundIcon(\.info)
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
@@ -145,17 +156,18 @@ struct TimelineMediaPreviewScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbarHeader: some View {
|
||||
if let currentItem {
|
||||
switch currentItem {
|
||||
case .media(let mediaItem):
|
||||
VStack(spacing: 0) {
|
||||
Text(currentItem.sender.displayName ?? currentItem.sender.id)
|
||||
Text(mediaItem.sender.displayName ?? mediaItem.sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
|
||||
Text(mediaItem.timestamp.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
} else {
|
||||
case .loading:
|
||||
Text(L10n.commonLoadingMore)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
@@ -215,20 +227,41 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
}
|
||||
|
||||
private func loadCurrentItem() {
|
||||
viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem))
|
||||
if let previewItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media {
|
||||
viewModelContext.send(viewAction: .updateCurrentItem(.media(previewItem)))
|
||||
} else if let loadingItem = previewController.currentPreviewItem as? TimelineMediaPreviewItem.Loading {
|
||||
switch loadingItem.state {
|
||||
case .paginating:
|
||||
viewModelContext.send(viewAction: .updateCurrentItem(.loading(loadingItem)))
|
||||
case .timelineStart:
|
||||
Task { await returnToIndex(viewModelContext.viewState.dataSource.firstPreviewItemIndex) }
|
||||
case .timelineEnd:
|
||||
Task { await returnToIndex(viewModelContext.viewState.dataSource.lastPreviewItemIndex) }
|
||||
}
|
||||
} else {
|
||||
MXLog.error("Unexpected preview item type: \(type(of: previewController.currentPreviewItem))")
|
||||
}
|
||||
}
|
||||
|
||||
private func returnToIndex(_ index: Int) async {
|
||||
// Sleep to fix a bug where the update didn't take effect when the swipe velocity was slow.
|
||||
try? await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
previewController.currentPreviewItemIndex = index
|
||||
viewModelContext.send(viewAction: .timelineEndReached)
|
||||
}
|
||||
|
||||
private func handleUpdatedItems() {
|
||||
if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem {
|
||||
if previewController.currentPreviewItem is TimelineMediaPreviewItem.Loading {
|
||||
let dataSource = viewModelContext.viewState.dataSource
|
||||
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem {
|
||||
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem.Media {
|
||||
previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
|
||||
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
|
||||
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem.Media)?.id == itemID else { return }
|
||||
previewController.refreshCurrentPreviewItem()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user