mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Media Browser: Listen to the timeline in the preview screen (#3707)
* Listen to the timeline to load more items in the media preview screen. * Fix the view model tests. * Fix tests
This commit is contained in:
+6
-4
@@ -177,27 +177,29 @@ struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePrevie
|
||||
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
|
||||
|
||||
static var previews: some View {
|
||||
TimelineMediaPreviewDetailsView(item: viewModel.state.currentItem,
|
||||
// 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
|
||||
})
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem,
|
||||
TimelineMediaPreviewDetailsView(item: loadingViewModel.state.currentItem!,
|
||||
context: loadingViewModel.context)
|
||||
.previewDisplayName("Loading")
|
||||
.snapshotPreferences(expect: loadingViewModel.context.$viewState.map { state in
|
||||
state.currentItemActions?.secondaryActions.contains(.redact) ?? false
|
||||
})
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem,
|
||||
TimelineMediaPreviewDetailsView(item: unknownTypeViewModel.state.currentItem!,
|
||||
context: unknownTypeViewModel.context)
|
||||
.previewDisplayName("Unknown type")
|
||||
|
||||
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem,
|
||||
TimelineMediaPreviewDetailsView(item: presentedOnRoomViewModel.state.currentItem!,
|
||||
context: presentedOnRoomViewModel.context)
|
||||
.previewDisplayName("Incoming on Room")
|
||||
// swiftlint:enable force_unwrapping
|
||||
}
|
||||
|
||||
static func makeViewModel(contentType: UTType? = nil,
|
||||
|
||||
+2
-1
@@ -125,7 +125,8 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
|
||||
static let viewModel = makeViewModel(contentType: .jpeg)
|
||||
|
||||
static var previews: some View {
|
||||
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem, context: viewModel.context)
|
||||
// swiftlint:disable:next force_unwrapping
|
||||
TimelineMediaPreviewRedactConfirmationView(item: viewModel.state.currentItem!, context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -40,7 +40,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.onDisappear {
|
||||
itemIDHandler?(nil)
|
||||
}
|
||||
.zoomTransition(sourceID: currentItem.id, in: context.viewState.transitionNamespace)
|
||||
.zoomTransition(sourceID: currentItem?.id, in: context.viewState.transitionNamespace)
|
||||
}
|
||||
|
||||
var quickLookPreview: some View {
|
||||
@@ -55,22 +55,25 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) { caption }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fullScreenButton: some View {
|
||||
Button {
|
||||
withAnimation { isFullScreen.toggle() }
|
||||
} label: {
|
||||
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
|
||||
.padding(6)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
if currentItem != nil {
|
||||
Button {
|
||||
withAnimation { isFullScreen.toggle() }
|
||||
} label: {
|
||||
CompoundIcon(isFullScreen ? \.collapse : \.expand, size: .xSmall, relativeTo: .compound.bodyLG)
|
||||
.padding(6)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
.padding(.top, 12)
|
||||
.padding(.trailing, 14)
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
.padding(.top, 12)
|
||||
.padding(.trailing, 14)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadStatusIndicator: some View {
|
||||
if currentItem.downloadError != nil {
|
||||
if currentItem?.downloadError != nil {
|
||||
VStack(spacing: 24) {
|
||||
CompoundIcon(\.error, size: .custom(48), relativeTo: .compound.headingLG)
|
||||
.foregroundStyle(.compound.iconCriticalPrimary)
|
||||
@@ -91,7 +94,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 40)
|
||||
.background(.compound.bgSubtlePrimary, in: RoundedRectangle(cornerRadius: 14))
|
||||
} else if currentItem.fileHandle == nil {
|
||||
} else if currentItem?.fileHandle == nil {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.tint(.compound.iconPrimary)
|
||||
@@ -100,7 +103,7 @@ struct TimelineMediaPreviewScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var caption: some View {
|
||||
if let caption = currentItem.caption, !isFullScreen {
|
||||
if let caption = currentItem?.caption, !isFullScreen {
|
||||
Text(caption)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
@@ -130,23 +133,32 @@ struct TimelineMediaPreviewScreen: View {
|
||||
toolbarHeader
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
|
||||
CompoundIcon(\.info)
|
||||
if currentItem != nil {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { context.send(viewAction: .showCurrentItemDetails) } label: {
|
||||
CompoundIcon(\.info)
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
}
|
||||
.tint(.compound.textActionPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbarHeader: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(currentItem.sender.displayName ?? currentItem.sender.id)
|
||||
if let currentItem {
|
||||
VStack(spacing: 0) {
|
||||
Text(currentItem.sender.displayName ?? currentItem.sender.id)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
} else {
|
||||
Text(L10n.commonLoadingMore)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.compound.bodyXS)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,14 +168,11 @@ struct TimelineMediaPreviewScreen: View {
|
||||
private struct QuickLookView: UIViewControllerRepresentable {
|
||||
let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
func makeUIViewController(context: Context) -> PreviewController {
|
||||
let fileLoadedPublisher = viewModelContext.viewState.fileLoadedPublisher.eraseToAnyPublisher()
|
||||
let controller = PreviewController(coordinator: context.coordinator, fileLoadedPublisher: fileLoadedPublisher)
|
||||
controller.currentPreviewItemIndex = viewModelContext.viewState.initialItemIndex
|
||||
return controller
|
||||
func makeUIViewController(context: Context) -> QLPreviewController {
|
||||
context.coordinator.previewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: PreviewController, context: Context) { }
|
||||
func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) { }
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModelContext: viewModelContext)
|
||||
@@ -171,55 +180,57 @@ private struct QuickLookView: UIViewControllerRepresentable {
|
||||
|
||||
// MARK: Coordinator
|
||||
|
||||
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
|
||||
@MainActor class Coordinator {
|
||||
let previewController = QLPreviewController()
|
||||
|
||||
private let viewModelContext: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(viewModelContext: TimelineMediaPreviewViewModel.Context) {
|
||||
self.viewModelContext = viewModelContext
|
||||
}
|
||||
|
||||
func updateCurrentItem(_ item: TimelineMediaPreviewItem) {
|
||||
viewModelContext.send(viewAction: .updateCurrentItem(item))
|
||||
}
|
||||
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
viewModelContext.viewState.previewItems.count
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
viewModelContext.viewState.previewItems[index]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIKit
|
||||
|
||||
class PreviewController: QLPreviewController {
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(coordinator: Coordinator, fileLoadedPublisher: AnyPublisher<TimelineItemIdentifier, Never>) {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
dataSource = coordinator
|
||||
delegate = coordinator
|
||||
|
||||
// Observation of currentPreviewItem doesn't work, so use the index instead.
|
||||
publisher(for: \.currentPreviewItemIndex)
|
||||
previewController.publisher(for: \.currentPreviewItemIndex)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
|
||||
coordinator.updateCurrentItem(currentPreviewItem)
|
||||
// This isn't removing duplicates which may try to download and/or write to disk concurrently????
|
||||
self?.loadCurrentItem()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
fileLoadedPublisher
|
||||
.sink { [weak self] itemID in
|
||||
guard let self, (currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
|
||||
refreshCurrentPreviewItem()
|
||||
viewModelContext.viewState.dataSource.previewItemsPaginationPublisher
|
||||
.sink { [weak self] in
|
||||
self?.handleUpdatedItems()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModelContext.viewState.fileLoadedPublisher
|
||||
.sink { [weak self] itemID in
|
||||
self?.handleFileLoaded(itemID: itemID)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
previewController.dataSource = viewModelContext.viewState.dataSource
|
||||
previewController.currentPreviewItemIndex = viewModelContext.viewState.dataSource.initialItemIndex
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
private func loadCurrentItem() {
|
||||
viewModelContext.send(viewAction: .updateCurrentItem(previewController.currentPreviewItem as? TimelineMediaPreviewItem))
|
||||
}
|
||||
|
||||
private func handleUpdatedItems() {
|
||||
if previewController.currentPreviewItem is TimelineMediaPreviewLoadingItem {
|
||||
let dataSource = viewModelContext.viewState.dataSource
|
||||
if dataSource.previewController(previewController, previewItemAt: previewController.currentPreviewItemIndex) is TimelineMediaPreviewItem {
|
||||
previewController.refreshCurrentPreviewItem() // This will trigger loadCurrentItem automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFileLoaded(itemID: TimelineItemIdentifier) {
|
||||
guard (previewController.currentPreviewItem as? TimelineMediaPreviewItem)?.id == itemID else { return }
|
||||
previewController.refreshCurrentPreviewItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user