Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the timeline media QuickLook modifier. #3593

Merged
merged 4 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
"common_developer_options" = "Developer options";
"common_device_id" = "Device ID";
"common_direct_chat" = "Direct chat";
"common_downloading" = "Downloading";
"common_edited_suffix" = "(edited)";
"common_editing" = "Editing";
"common_editing_caption" = "Editing caption";
Expand Down Expand Up @@ -389,6 +390,8 @@
"screen_knock_requests_list_title" = "Requests to join";
"screen_media_details_file_format" = "File format";
"screen_media_details_filename" = "File name";
"screen_media_details_redact_confirmation_message" = "This file will be removed from the room and members won’t have access to it.";
"screen_media_details_redact_confirmation_title" = "Delete file?";
"screen_media_details_uploaded_by" = "Uploaded by";
"screen_media_details_uploaded_on" = "Uploaded on";
"screen_media_upload_preview_caption_warning" = "Captions might not be visible to people using older apps.";
Expand Down
6 changes: 6 additions & 0 deletions ElementX/Sources/Generated/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ internal enum L10n {
internal static var commonDeviceId: String { return L10n.tr("Localizable", "common_device_id") }
/// Direct chat
internal static var commonDirectChat: String { return L10n.tr("Localizable", "common_direct_chat") }
/// Downloading
internal static var commonDownloading: String { return L10n.tr("Localizable", "common_downloading") }
/// (edited)
internal static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") }
/// Editing
Expand Down Expand Up @@ -1378,6 +1380,10 @@ internal enum L10n {
internal static var screenMediaDetailsFileFormat: String { return L10n.tr("Localizable", "screen_media_details_file_format") }
/// File name
internal static var screenMediaDetailsFilename: String { return L10n.tr("Localizable", "screen_media_details_filename") }
/// This file will be removed from the room and members won’t have access to it.
internal static var screenMediaDetailsRedactConfirmationMessage: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_message") }
/// Delete file?
internal static var screenMediaDetailsRedactConfirmationTitle: String { return L10n.tr("Localizable", "screen_media_details_redact_confirmation_title") }
/// Uploaded by
internal static var screenMediaDetailsUploadedBy: String { return L10n.tr("Localizable", "screen_media_details_uploaded_by") }
/// Uploaded on
Expand Down
8 changes: 7 additions & 1 deletion ElementX/Sources/Mocks/MediaProviderMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ extension MediaProviderMock {
return .success(data)
}

loadFileFromSourceFilenameReturnValue = .failure(.failedRetrievingFile)
loadFileFromSourceFilenameClosure = { _, _ in
guard let url = Bundle.main.url(forResource: "preview_image", withExtension: "jpg") else {
return .failure(.failedRetrievingFile)
}

return .success(.unmanaged(url: url))
}

loadImageRetryingOnReconnectionSizeClosure = { _, _ in
Task {
Expand Down
3 changes: 3 additions & 0 deletions ElementX/Sources/Other/Avatars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ enum UserAvatarSizeOnScreen {
case knockingUsersBannerStack
case knockingUserBanner
case knockingUserList
case mediaPreviewDetails

var value: CGFloat {
switch self {
Expand Down Expand Up @@ -110,6 +111,8 @@ enum UserAvatarSizeOnScreen {
return 32
case .knockingUserList:
return 52
case .mediaPreviewDetails:
return 32
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion ElementX/Sources/Other/Extensions/Date.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ extension Date {

/// A fixed date used for mocks, previews etc.
static var mock: Date {
Calendar.current.startOfDay(for: .now).addingTimeInterval((9 * 60 * 60) + (41 * 60)) // 9:41 am
DateComponents(calendar: .current, year: 2007, month: 1, day: 9, hour: 9, minute: 41).date ?? .now
pixlwave marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//

import Combine
import Compound
import QuickLook
import SwiftUI

class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDataSource {
private let viewModel: TimelineMediaPreviewViewModel

private var cancellables: Set<AnyCancellable> = []

private let headerHostingController: UIHostingController<HeaderView>
private let captionHostingController: UIHostingController<CaptionView>
private let detailsHostingController: UIHostingController<TimelineMediaPreviewDetailsView>

private var navigationBar: UINavigationBar? { view.subviews.first?.subviews.first { $0 is UINavigationBar } as? UINavigationBar }
private var toolbar: UIToolbar? { view.subviews.first?.subviews.last { $0 is UIToolbar } as? UIToolbar }
private var captionView: UIView { captionHostingController.view }

init(viewModel: TimelineMediaPreviewViewModel) {
self.viewModel = viewModel

headerHostingController = UIHostingController(rootView: HeaderView(context: viewModel.context))
headerHostingController.view.backgroundColor = .clear
captionHostingController = UIHostingController(rootView: CaptionView(context: viewModel.context))
captionHostingController.view.backgroundColor = .clear
detailsHostingController = UIHostingController(rootView: TimelineMediaPreviewDetailsView(context: viewModel.context))
detailsHostingController.view.backgroundColor = .compound.bgCanvasDefault

// let materialView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
// captionHostingController.view.insertMatchedSubview(materialView, at: 0)
pixlwave marked this conversation as resolved.
Show resolved Hide resolved

super.init(nibName: nil, bundle: nil)

view.addSubview(captionView)

// Observation of currentPreviewItem doesn't work, so use the index instead.
publisher(for: \.currentPreviewItemIndex)
.sink { [weak self] _ in
guard let self, let currentPreviewItem = currentPreviewItem as? TimelineMediaPreviewItem else { return }
Task { await self.viewModel.updateCurrentItem(currentPreviewItem) }
}
.store(in: &cancellables)

viewModel.actions
.sink { [weak self] action in
switch action {
case .loadedMediaFile:
self?.refreshCurrentPreviewItem()
case .viewInTimeline:
self?.dismiss(animated: true) // Dismiss the details sheet.
// Errrr, hmmmmm, do something else here.
}
}
.store(in: &cancellables)

dataSource = self
}

@available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()

overrideUserInterfaceStyle = .dark

if let toolbar {
captionView.isHidden = toolbar.alpha == 0

if captionView.constraints.isEmpty {
captionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
captionView.bottomAnchor.constraint(equalTo: toolbar.topAnchor),
captionView.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
captionView.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor)
])
}
}

navigationBar?.topItem?.titleView = headerHostingController.view

if navigationBar?.topItem?.rightBarButtonItems?.count == 1 {
navigationBar?.topItem?.rightBarButtonItems?.append(UIBarButtonItem(image: UIImage(systemSymbol: .infoCircle), style: .plain, target: self, action: #selector(presentMediaDetails)))
}
}

// MARK: QLPreviewControllerDataSource

func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
viewModel.state.previewItems.count
}

func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
viewModel.state.previewItems[index]
}

// MARK: Private

@objc func presentMediaDetails() {
detailsHostingController.overrideUserInterfaceStyle = .dark
detailsHostingController.sheetPresentationController?.detents = [.medium()]
detailsHostingController.sheetPresentationController?.prefersGrabberVisible = true

present(detailsHostingController, animated: true)
}
}

// MARK: - Subviews

private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context

var body: some View {
VStack(spacing: 0) {
Text(context.viewState.currentItem?.sender.displayName ?? context.viewState.currentItem?.sender.id ?? L10n.commonLoading)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .omitted) ?? "")
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
}
}
}

private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context

var body: some View {
if let caption = context.viewState.currentItem?.caption {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
.lineLimit(5)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(16)
.background(.ultraThinMaterial)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//

import QuickLook

enum TimelineMediaPreviewViewModelAction {
case loadedMediaFile
case viewInTimeline
}

struct TimelineMediaPreviewViewState: BindableState {
var previewItems: [TimelineMediaPreviewItem]
var currentItem: TimelineMediaPreviewItem?
}

/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
private let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?

init(timelineItem: EventBasedMessageTimelineItemProtocol) {
self.timelineItem = timelineItem
}

var id: TimelineItemIdentifier { timelineItem.id }

// MARK: QLPreviewItem

var previewItemURL: URL? {
fileHandle?.url
}

var previewItemTitle: 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
}
}

// 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
}
}
}

enum TimelineMediaPreviewViewAction {
case viewInTimeline
case redact
}
Loading
Loading