Configure the media preview screen based on the event and presentation. (#3604)

* Configure media previews based on the presenting screen.

* Switch on TimelineKind instead of having an isPinnedEventsTimeline Bool.
This commit is contained in:
Doug
2024-12-11 15:40:31 +00:00
committed by GitHub
parent 9856e3e5b4
commit c827ab9165
35 changed files with 334 additions and 242 deletions
@@ -132,13 +132,14 @@ class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDa
private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
VStack(spacing: 0) {
Text(context.viewState.currentItem?.sender.displayName ?? context.viewState.currentItem?.sender.id ?? L10n.commonLoading)
Text(currentItem.sender.displayName ?? currentItem.sender.id)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary)
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .omitted) ?? "")
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary)
.textCase(.uppercase)
@@ -148,9 +149,10 @@ private struct HeaderView: View {
private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
if let caption = context.viewState.currentItem?.caption {
if let caption = currentItem.caption {
Text(caption)
.font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary)
@@ -14,12 +14,19 @@ enum TimelineMediaPreviewViewModelAction {
struct TimelineMediaPreviewViewState: BindableState {
var previewItems: [TimelineMediaPreviewItem]
var currentItem: TimelineMediaPreviewItem?
var currentItem: TimelineMediaPreviewItem
var currentItemActions: TimelineItemMenuActions?
var bindings = TimelineMediaPreviewViewStateBindings()
}
struct TimelineMediaPreviewViewStateBindings {
var isPresentingRedactConfirmation = false
}
/// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
private let timelineItem: EventBasedMessageTimelineItemProtocol
let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy?
init(timelineItem: EventBasedMessageTimelineItemProtocol) {
@@ -159,6 +166,6 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
}
enum TimelineMediaPreviewViewAction {
case viewInTimeline
case redact
case menuAction(TimelineItemMenuAction)
case redactConfirmation
}
@@ -11,6 +11,7 @@ import Foundation
typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction>
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let timelineViewModel: TimelineViewModelProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -19,26 +20,51 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.eraseToAnyPublisher()
}
init(previewItems: [EventBasedMessageTimelineItemProtocol], mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) {
init(initialItem: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineViewModelProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.timelineViewModel = timelineViewModel
self.mediaProvider = mediaProvider
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
self.userIndicatorController = userIndicatorController
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: previewItems.map(TimelineMediaPreviewItem.init)), mediaProvider: mediaProvider)
let currentItem = TimelineMediaPreviewItem(timelineItem: initialItem)
super.init(initialViewState: TimelineMediaPreviewViewState(previewItems: [currentItem],
currentItem: currentItem),
mediaProvider: mediaProvider)
rebuildCurrentItemActions()
timelineViewModel.context.$viewState.map(\.canCurrentUserRedactSelf)
.merge(with: timelineViewModel.context.$viewState.map(\.canCurrentUserRedactOthers))
.sink { [weak self] _ in
self?.rebuildCurrentItemActions()
}
.store(in: &cancellables)
}
override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction {
case .viewInTimeline:
actionsSubject.send(.viewInTimeline)
case .redact:
case .menuAction(let action):
switch action {
case .viewInRoomTimeline:
actionsSubject.send(.viewInTimeline)
case .redact:
state.bindings.isPresentingRedactConfirmation = true
default:
MXLog.error("Received unexpected action: \(action)")
}
case .redactConfirmation:
break // Do it here??
}
}
func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
state.currentItem = previewItem
rebuildCurrentItemActions()
if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
showDownloadingIndicator(itemID: previewItem.id)
@@ -50,11 +76,26 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.send(.loadedMediaFile)
case .failure(let error):
MXLog.error("Failed loading media: \(error)")
#warning("Show the error!")
showDownloadErrorIndicator()
}
}
}
func rebuildCurrentItemActions() {
let timelineContext = timelineViewModel.context
let provider = TimelineItemMenuActionProvider(timelineItem: state.currentItem.timelineItem,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isCreateMediaCaptionsEnabled: timelineContext.viewState.isCreateMediaCaptionsEnabled,
timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider)
state.currentItemActions = provider.makeActions()
}
private func showDownloadingIndicator(itemID: TimelineItemIdentifier) {
let indicatorID = makeDownloadIndicatorID(itemID: itemID)
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID,
@@ -69,7 +110,16 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
userIndicatorController.retractIndicatorWithId(indicatorID)
}
private func showDownloadErrorIndicator() {
// FIXME: Add the correct string and indicator type??
userIndicatorController.submitIndicator(UserIndicator(id: downloadErrorIndicatorID,
type: .modal,
title: L10n.errorUnknown,
iconName: "exclamationmark.circle.fill"))
}
private var downloadErrorIndicatorID: String { "\(Self.self)-DownloadError" }
private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
"\(TimelineMediaPreviewViewModel.self)-Download-\(itemID.uniqueID.id)"
"\(Self.self)-Download-\(itemID.uniqueID.id)"
}
}
@@ -134,21 +134,22 @@ struct TimelineMediaQuickLook_Previews: PreviewProvider {
}
static func makeViewModel() -> TimelineMediaPreviewViewModel {
let previewItem = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: .pdf))
let item = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: .pdf))
return TimelineMediaPreviewViewModel(previewItems: [previewItem],
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
@@ -11,7 +11,7 @@ import SwiftUI
struct TimelineMediaPreviewDetailsView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var isPresentingRedactConfirmation = false
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
ScrollView {
@@ -19,10 +19,10 @@ struct TimelineMediaPreviewDetailsView: View {
details
actions
}
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.top, 19) // For the drag indicator
.sheet(isPresented: $isPresentingRedactConfirmation) {
.sheet(isPresented: $context.isPresentingRedactConfirmation) {
TimelineMediaPreviewRedactConfirmationView(context: context)
}
}
@@ -31,48 +31,42 @@ struct TimelineMediaPreviewDetailsView: View {
VStack(alignment: .leading, spacing: 24) {
DetailsRow(title: L10n.screenMediaDetailsUploadedBy) {
HStack(spacing: 8) {
if let sender = context.viewState.currentItem?.sender {
LoadableAvatarImage(url: sender.avatarURL,
name: sender.displayName,
contentID: sender.id,
avatarSize: .user(on: .mediaPreviewDetails),
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
if let displayName = sender.displayName {
Text(displayName)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: sender.id).text)
}
Text(sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
LoadableAvatarImage(url: currentItem.sender.avatarURL,
name: currentItem.sender.displayName,
contentID: currentItem.sender.id,
avatarSize: .user(on: .mediaPreviewDetails),
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
if let displayName = currentItem.sender.displayName {
Text(displayName)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: currentItem.sender.id).text)
}
} else {
Text(L10n.commonLoading)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
Text(currentItem.sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
}
DetailsRow(title: L10n.screenMediaDetailsUploadedOn) {
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .shortened) ?? "")
Text(currentItem.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
DetailsRow(title: L10n.screenMediaDetailsFilename) {
Text(context.viewState.currentItem?.filename ?? "")
Text(currentItem.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
if let contentType = context.viewState.currentItem?.contentType {
if let contentType = currentItem.contentType {
DetailsRow(title: L10n.screenMediaDetailsFileFormat) {
Group {
if let fileSize = context.viewState.currentItem?.fileSize {
if let fileSize = currentItem.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
@@ -88,23 +82,38 @@ struct TimelineMediaPreviewDetailsView: View {
.padding(.horizontal, 16)
}
@ViewBuilder
private var actions: some View {
VStack(spacing: 0) {
Divider()
.background(Color.compound.bgSubtlePrimary)
Button { context.send(viewAction: .viewInTimeline) } label: {
Label(L10n.actionViewInTimeline, icon: \.visibilityOn)
if let actions = context.viewState.currentItemActions {
VStack(spacing: 0) {
if !actions.actions.isEmpty {
Divider()
.background(Color.compound.bgSubtlePrimary)
}
ForEach(actions.actions, id: \.self) { action in
Button(role: action.isDestructive ? .destructive : nil) {
context.send(viewAction: .menuAction(action))
} label: {
action.label
}
.buttonStyle(.menuSheet)
}
if !actions.secondaryActions.isEmpty {
Divider()
.background(Color.compound.bgSubtlePrimary)
}
ForEach(actions.secondaryActions, id: \.self) { action in
Button(role: action.isDestructive ? .destructive : nil) {
context.send(viewAction: .menuAction(action))
} label: {
action.label
}
.buttonStyle(.menuSheet)
}
}
.buttonStyle(.menuSheet)
Divider()
.background(Color.compound.bgSubtlePrimary)
Button(role: .destructive) { isPresentingRedactConfirmation = true } label: {
Label(L10n.actionRemove, icon: \.delete)
}
.buttonStyle(.menuSheet)
}
}
@@ -130,38 +139,42 @@ struct TimelineMediaPreviewDetailsView: View {
import UniformTypeIdentifiers
struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(contentType: .jpeg)
static let viewModel = makeViewModel(contentType: .jpeg, isOutgoing: true)
static let unknownTypeViewModel = makeViewModel()
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
static var previews: some View {
TimelineMediaPreviewDetailsView(context: viewModel.context)
.previewDisplayName("Image")
.snapshotPreferences(delay: 0.1)
TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type")
.snapshotPreferences(delay: 0.1)
TimelineMediaPreviewDetailsView(context: presentedOnRoomViewModel.context)
.previewDisplayName("Incoming on Room")
.snapshotPreferences(delay: 0.1)
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
let previewItems = [
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
]
static func makeViewModel(contentType: UTType? = nil, isOutgoing: Bool = false, isPresentedOnRoomScreen: Bool = false) -> TimelineMediaPreviewViewModel {
let item = ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: isOutgoing,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
viewModel.state.currentItem = viewModel.state.previewItems.first
return viewModel
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -13,6 +13,8 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View {
ScrollView {
VStack(spacing: 0) {
@@ -51,51 +53,49 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
@ViewBuilder
private var preview: some View {
if let currentItem = context.viewState.currentItem {
HStack(spacing: 12) {
if let mediaSource = currentItem.thumbnailMediaSource {
Color.clear
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id),
blurhash: currentItem.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
}
.aspectRatio(contentMode: .fill)
HStack(spacing: 12) {
if let mediaSource = currentItem.thumbnailMediaSource {
Color.clear
.scaledFrame(size: 40)
.background {
LoadableImage(mediaSource: mediaSource,
mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id),
blurhash: currentItem.blurhash,
mediaProvider: context.mediaProvider) {
Color.compound.bgSubtleSecondary
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 4) {
Text(currentItem.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
if let contentType = currentItem.contentType {
Group {
if let fileSize = currentItem.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
}
}
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
.aspectRatio(contentMode: .fill)
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 4) {
Text(currentItem.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
if let contentType = currentItem.contentType {
Group {
if let fileSize = currentItem.fileSize {
Text(contentType) + Text(" – ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
}
}
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.bottom, 40)
}
private var buttons: some View {
VStack(spacing: 16) {
Button(L10n.actionRemove, role: .destructive) {
context.send(viewAction: .redact)
context.send(viewAction: .redactConfirmation)
}
.buttonStyle(.compound(.primary))
@@ -124,27 +124,23 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
}
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
let previewItems = [
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
]
let item = ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: true,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
viewModel.state.currentItem = viewModel.state.previewItems.first
return viewModel
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}
}