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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 334 additions and 242 deletions

View File

@ -92,7 +92,7 @@ extension JoinedRoomProxyMock {
} }
canUserInviteUserIDReturnValue = .success(configuration.canUserInvite) canUserInviteUserIDReturnValue = .success(configuration.canUserInvite)
canUserRedactOtherUserIDReturnValue = .success(false) canUserRedactOtherUserIDReturnValue = .success(false)
canUserRedactOwnUserIDReturnValue = .success(false) canUserRedactOwnUserIDReturnValue = .success(true)
canUserKickUserIDClosure = { [weak self] userID in canUserKickUserIDClosure = { [weak self] userID in
.success(self?.membersPublisher.value.first { $0.userID == userID }?.role ?? .user != .user) .success(self?.membersPublisher.value.first { $0.userID == userID }?.role ?? .user != .user)
} }

View File

@ -132,13 +132,14 @@ class TimelineMediaPreviewController: QLPreviewController, QLPreviewControllerDa
private struct HeaderView: View { private struct HeaderView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View { var body: some View {
VStack(spacing: 0) { 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) .font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
Text(context.viewState.currentItem?.timestamp.formatted(date: .abbreviated, time: .omitted) ?? "") Text(currentItem.timestamp.formatted(date: .abbreviated, time: .omitted))
.font(.compound.bodyXS) .font(.compound.bodyXS)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
.textCase(.uppercase) .textCase(.uppercase)
@ -148,9 +149,10 @@ private struct HeaderView: View {
private struct CaptionView: View { private struct CaptionView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View { var body: some View {
if let caption = context.viewState.currentItem?.caption { if let caption = currentItem.caption {
Text(caption) Text(caption)
.font(.compound.bodyLG) .font(.compound.bodyLG)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)

View File

@ -14,12 +14,19 @@ enum TimelineMediaPreviewViewModelAction {
struct TimelineMediaPreviewViewState: BindableState { struct TimelineMediaPreviewViewState: BindableState {
var previewItems: [TimelineMediaPreviewItem] 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. /// Wraps a media file and title to be previewed with QuickLook.
class TimelineMediaPreviewItem: NSObject, QLPreviewItem { class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
private let timelineItem: EventBasedMessageTimelineItemProtocol let timelineItem: EventBasedMessageTimelineItemProtocol
var fileHandle: MediaFileHandleProxy? var fileHandle: MediaFileHandleProxy?
init(timelineItem: EventBasedMessageTimelineItemProtocol) { init(timelineItem: EventBasedMessageTimelineItemProtocol) {
@ -159,6 +166,6 @@ class TimelineMediaPreviewItem: NSObject, QLPreviewItem {
} }
enum TimelineMediaPreviewViewAction { enum TimelineMediaPreviewViewAction {
case viewInTimeline case menuAction(TimelineItemMenuAction)
case redact case redactConfirmation
} }

View File

@ -11,6 +11,7 @@ import Foundation
typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction> typealias TimelineMediaPreviewViewModelType = StateStoreViewModel<TimelineMediaPreviewViewState, TimelineMediaPreviewViewAction>
class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
private let timelineViewModel: TimelineViewModelProtocol
private let mediaProvider: MediaProviderProtocol private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
@ -19,26 +20,51 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(previewItems: [EventBasedMessageTimelineItemProtocol], mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) { init(initialItem: EventBasedMessageTimelineItemProtocol,
timelineViewModel: TimelineViewModelProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.timelineViewModel = timelineViewModel
self.mediaProvider = mediaProvider self.mediaProvider = mediaProvider
// We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔 // We might not want to inject this, instead creating a new instance with a custom position and colour scheme 🤔
self.userIndicatorController = userIndicatorController 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) { override func process(viewAction: TimelineMediaPreviewViewAction) {
switch viewAction { switch viewAction {
case .viewInTimeline: case .menuAction(let action):
actionsSubject.send(.viewInTimeline) switch action {
case .redact: case .viewInRoomTimeline:
actionsSubject.send(.viewInTimeline)
case .redact:
state.bindings.isPresentingRedactConfirmation = true
default:
MXLog.error("Received unexpected action: \(action)")
}
case .redactConfirmation:
break // Do it here?? break // Do it here??
} }
} }
func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async {
state.currentItem = previewItem state.currentItem = previewItem
rebuildCurrentItemActions()
if previewItem.fileHandle == nil, let source = previewItem.mediaSource { if previewItem.fileHandle == nil, let source = previewItem.mediaSource {
showDownloadingIndicator(itemID: previewItem.id) showDownloadingIndicator(itemID: previewItem.id)
@ -50,11 +76,26 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
actionsSubject.send(.loadedMediaFile) actionsSubject.send(.loadedMediaFile)
case .failure(let error): case .failure(let error):
MXLog.error("Failed loading media: \(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) { private func showDownloadingIndicator(itemID: TimelineItemIdentifier) {
let indicatorID = makeDownloadIndicatorID(itemID: itemID) let indicatorID = makeDownloadIndicatorID(itemID: itemID)
userIndicatorController.submitIndicator(UserIndicator(id: indicatorID, userIndicatorController.submitIndicator(UserIndicator(id: indicatorID,
@ -69,7 +110,16 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType {
userIndicatorController.retractIndicatorWithId(indicatorID) 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 { private func makeDownloadIndicatorID(itemID: TimelineItemIdentifier) -> String {
"\(TimelineMediaPreviewViewModel.self)-Download-\(itemID.uniqueID.id)" "\(Self.self)-Download-\(itemID.uniqueID.id)"
} }
} }

View File

@ -134,21 +134,22 @@ struct TimelineMediaQuickLook_Previews: PreviewProvider {
} }
static func makeViewModel() -> TimelineMediaPreviewViewModel { static func makeViewModel() -> TimelineMediaPreviewViewModel {
let previewItem = FileRoomTimelineItem(id: .randomEvent, let item = FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock, timestamp: .mock,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
canBeRepliedTo: true, canBeRepliedTo: true,
isThreaded: false, isThreaded: false,
sender: .init(id: "", displayName: "Sally Sanderson"), sender: .init(id: "", displayName: "Sally Sanderson"),
content: .init(filename: "Important document.pdf", content: .init(filename: "Important document.pdf",
caption: "A caption goes right here.", caption: "A caption goes right here.",
source: try? .init(url: .mockMXCFile, mimeType: nil), source: try? .init(url: .mockMXCFile, mimeType: nil),
fileSize: 3 * 1024 * 1024, fileSize: 3 * 1024 * 1024,
thumbnailSource: nil, thumbnailSource: nil,
contentType: .pdf)) contentType: .pdf))
return TimelineMediaPreviewViewModel(previewItems: [previewItem], return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
mediaProvider: MediaProviderMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }

View File

@ -11,7 +11,7 @@ import SwiftUI
struct TimelineMediaPreviewDetailsView: View { struct TimelineMediaPreviewDetailsView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
@State private var isPresentingRedactConfirmation = false private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -19,10 +19,10 @@ struct TimelineMediaPreviewDetailsView: View {
details details
actions actions
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.top, 19) // For the drag indicator .padding(.top, 19) // For the drag indicator
.sheet(isPresented: $isPresentingRedactConfirmation) { .sheet(isPresented: $context.isPresentingRedactConfirmation) {
TimelineMediaPreviewRedactConfirmationView(context: context) TimelineMediaPreviewRedactConfirmationView(context: context)
} }
} }
@ -31,48 +31,42 @@ struct TimelineMediaPreviewDetailsView: View {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
DetailsRow(title: L10n.screenMediaDetailsUploadedBy) { DetailsRow(title: L10n.screenMediaDetailsUploadedBy) {
HStack(spacing: 8) { HStack(spacing: 8) {
if let sender = context.viewState.currentItem?.sender { LoadableAvatarImage(url: currentItem.sender.avatarURL,
LoadableAvatarImage(url: sender.avatarURL, name: currentItem.sender.displayName,
name: sender.displayName, contentID: currentItem.sender.id,
contentID: sender.id, avatarSize: .user(on: .mediaPreviewDetails),
avatarSize: .user(on: .mediaPreviewDetails), mediaProvider: context.mediaProvider)
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 0) { if let displayName = currentItem.sender.displayName {
if let displayName = sender.displayName { Text(displayName)
Text(displayName) .font(.compound.bodyMDSemibold)
.font(.compound.bodyMDSemibold) .foregroundStyle(.compound.decorativeColor(for: currentItem.sender.id).text)
.foregroundStyle(.compound.decorativeColor(for: sender.id).text)
}
Text(sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
} }
} else {
Text(L10n.commonLoading) Text(currentItem.sender.id)
.font(.compound.bodyMD) .font(.compound.bodySM)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textSecondary)
} }
} }
} }
DetailsRow(title: L10n.screenMediaDetailsUploadedOn) { 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) .font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
} }
DetailsRow(title: L10n.screenMediaDetailsFilename) { DetailsRow(title: L10n.screenMediaDetailsFilename) {
Text(context.viewState.currentItem?.filename ?? "") Text(currentItem.filename ?? "")
.font(.compound.bodyMD) .font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary) .foregroundStyle(.compound.textPrimary)
} }
if let contentType = context.viewState.currentItem?.contentType { if let contentType = currentItem.contentType {
DetailsRow(title: L10n.screenMediaDetailsFileFormat) { DetailsRow(title: L10n.screenMediaDetailsFileFormat) {
Group { Group {
if let fileSize = context.viewState.currentItem?.fileSize { if let fileSize = currentItem.fileSize {
Text(contentType) + Text(" ") + Text(UInt(fileSize).formatted(.byteCount(style: .file))) Text(contentType) + Text(" ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else { } else {
Text(contentType) Text(contentType)
@ -88,23 +82,38 @@ struct TimelineMediaPreviewDetailsView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@ViewBuilder
private var actions: some View { private var actions: some View {
VStack(spacing: 0) { if let actions = context.viewState.currentItemActions {
Divider() VStack(spacing: 0) {
.background(Color.compound.bgSubtlePrimary) if !actions.actions.isEmpty {
Divider()
Button { context.send(viewAction: .viewInTimeline) } label: { .background(Color.compound.bgSubtlePrimary)
Label(L10n.actionViewInTimeline, icon: \.visibilityOn) }
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 import UniformTypeIdentifiers
struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview { 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 unknownTypeViewModel = makeViewModel()
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
static var previews: some View { static var previews: some View {
TimelineMediaPreviewDetailsView(context: viewModel.context) TimelineMediaPreviewDetailsView(context: viewModel.context)
.previewDisplayName("Image") .previewDisplayName("Image")
.snapshotPreferences(delay: 0.1)
TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context) TimelineMediaPreviewDetailsView(context: unknownTypeViewModel.context)
.previewDisplayName("Unknown type") .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 { static func makeViewModel(contentType: UTType? = nil, isOutgoing: Bool = false, isPresentedOnRoomScreen: Bool = false) -> TimelineMediaPreviewViewModel {
let previewItems = [ let item = ImageRoomTimelineItem(id: .randomEvent,
ImageRoomTimelineItem(id: .randomEvent, timestamp: .mock,
timestamp: .mock, isOutgoing: isOutgoing,
isOutgoing: false, isEditable: true,
isEditable: true, canBeRepliedTo: true,
canBeRepliedTo: true, isThreaded: false,
isThreaded: false, sender: .init(id: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice",
displayName: "Alice", avatarURL: .mockMXCUserAvatar),
avatarURL: .mockMXCUserAvatar), content: .init(filename: "Amazing Image.jpeg",
content: .init(filename: "Amazing Image.jpeg", imageInfo: .mockImage,
imageInfo: .mockImage, thumbnailInfo: .mockThumbnail,
thumbnailInfo: .mockThumbnail, contentType: contentType))
contentType: contentType))
]
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreen : .mediaFilesScreen)
mediaProvider: MediaProviderMock(configuration: .init()), return TimelineMediaPreviewViewModel(initialItem: item,
userIndicatorController: UserIndicatorControllerMock()) timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind),
viewModel.state.currentItem = viewModel.state.previewItems.first mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
return viewModel
} }
} }

View File

@ -13,6 +13,8 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
@ObservedObject var context: TimelineMediaPreviewViewModel.Context @ObservedObject var context: TimelineMediaPreviewViewModel.Context
private var currentItem: TimelineMediaPreviewItem { context.viewState.currentItem }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -51,51 +53,49 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
@ViewBuilder @ViewBuilder
private var preview: some View { private var preview: some View {
if let currentItem = context.viewState.currentItem { HStack(spacing: 12) {
HStack(spacing: 12) { if let mediaSource = currentItem.thumbnailMediaSource {
if let mediaSource = currentItem.thumbnailMediaSource { Color.clear
Color.clear .scaledFrame(size: 40)
.scaledFrame(size: 40) .background {
.background { LoadableImage(mediaSource: mediaSource,
LoadableImage(mediaSource: mediaSource, mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id),
mediaType: .timelineItem(uniqueID: currentItem.id.uniqueID.id), blurhash: currentItem.blurhash,
blurhash: currentItem.blurhash, mediaProvider: context.mediaProvider) {
mediaProvider: context.mediaProvider) { Color.compound.bgSubtleSecondary
Color.compound.bgSubtleSecondary
}
.aspectRatio(contentMode: .fill)
} }
.clipShape(RoundedRectangle(cornerRadius: 8)) .aspectRatio(contentMode: .fill)
}
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)
} }
.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 { private var buttons: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Button(L10n.actionRemove, role: .destructive) { Button(L10n.actionRemove, role: .destructive) {
context.send(viewAction: .redact) context.send(viewAction: .redactConfirmation)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
@ -124,27 +124,23 @@ struct TimelineMediaPreviewRedactConfirmationView_Previews: PreviewProvider, Tes
} }
static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel { static func makeViewModel(contentType: UTType? = nil) -> TimelineMediaPreviewViewModel {
let previewItems = [ let item = ImageRoomTimelineItem(id: .randomEvent,
ImageRoomTimelineItem(id: .randomEvent, timestamp: .mock,
timestamp: .mock, isOutgoing: false,
isOutgoing: false, isEditable: true,
isEditable: true, canBeRepliedTo: true,
canBeRepliedTo: true, isThreaded: false,
isThreaded: false, sender: .init(id: "@alice:matrix.org",
sender: .init(id: "@alice:matrix.org", displayName: "Alice",
displayName: "Alice", avatarURL: .mockMXCUserAvatar),
avatarURL: .mockMXCUserAvatar), content: .init(filename: "Amazing Image.jpeg",
content: .init(filename: "Amazing Image.jpeg", imageInfo: .mockImage,
imageInfo: .mockImage, thumbnailInfo: .mockThumbnail,
thumbnailInfo: .mockThumbnail, contentType: contentType))
contentType: contentType))
]
let viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, return TimelineMediaPreviewViewModel(initialItem: item,
mediaProvider: MediaProviderMock(configuration: .init()), timelineViewModel: TimelineViewModel.mock,
userIndicatorController: UserIndicatorControllerMock()) mediaProvider: MediaProviderMock(configuration: .init()),
viewModel.state.currentItem = viewModel.state.previewItems.first userIndicatorController: UserIndicatorControllerMock())
return viewModel
} }
} }

View File

@ -144,7 +144,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
return return
} }
let viewModel = TimelineMediaPreviewViewModel(previewItems: [item], let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: activeTimelineViewModel,
mediaProvider: mediaProvider, mediaProvider: mediaProvider,
userIndicatorController: userIndicatorController) userIndicatorController: userIndicatorController)
state.bindings.mediaPreviewViewModel = viewModel state.bindings.mediaPreviewViewModel = viewModel

View File

@ -164,7 +164,7 @@ extension View {
struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
static let timelineViewModel: TimelineViewModel = { static let timelineViewModel: TimelineViewModel = {
let timelineController = MockRoomTimelineController(timelineKind: .media) let timelineController = MockRoomTimelineController(timelineKind: .media(.mediaFilesScreen))
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
timelineController: timelineController, timelineController: timelineController,
mediaProvider: MediaProviderMock(configuration: .init()), mediaProvider: MediaProviderMock(configuration: .init()),

View File

@ -38,7 +38,7 @@ struct PinnedEventsTimelineScreen: View {
isDM: timelineContext.viewState.isEncryptedOneToOneRoom, isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isCreateMediaCaptionsEnabled: timelineContext.viewState.isCreateMediaCaptionsEnabled, isCreateMediaCaptionsEnabled: timelineContext.viewState.isCreateMediaCaptionsEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline, timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider) emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions() .makeActions()
if let actions { if let actions {

View File

@ -76,7 +76,7 @@ struct RoomScreen: View {
isDM: timelineContext.viewState.isEncryptedOneToOneRoom, isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isCreateMediaCaptionsEnabled: timelineContext.viewState.isCreateMediaCaptionsEnabled, isCreateMediaCaptionsEnabled: timelineContext.viewState.isCreateMediaCaptionsEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline, timelineKind: timelineContext.viewState.timelineKind,
emojiProvider: timelineContext.viewState.emojiProvider) emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions() .makeActions()
if let actions { if let actions {

View File

@ -85,7 +85,7 @@ enum TimelineComposerAction {
} }
struct TimelineViewState: BindableState { struct TimelineViewState: BindableState {
let isPinnedEventsTimeline: Bool let timelineKind: TimelineKind
var roomID: String var roomID: String
var members: [String: RoomMemberState] = [:] var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = [] var typingMembers: [String] = []

View File

@ -316,7 +316,7 @@ class TimelineTableViewController: UIViewController {
var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, TimelineUniqueId>() var snapshot = NSDiffableDataSourceSnapshot<TimelineSection, TimelineUniqueId>()
// We don't want to display the typing notification in this timeline // We don't want to display the typing notification in this timeline
if !coordinator.context.viewState.isPinnedEventsTimeline { if coordinator.context.viewState.timelineKind != .pinned {
snapshot.appendSections([.typingIndicator]) snapshot.appendSections([.typingIndicator])
snapshot.appendItems([TimelineUniqueId(id: TimelineTypingIndicatorCell.reuseIdentifier)]) snapshot.appendItems([TimelineUniqueId(id: TimelineTypingIndicatorCell.reuseIdentifier)])
} }

View File

@ -75,7 +75,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
appSettings: appSettings, appSettings: appSettings,
analyticsService: analyticsService) analyticsService: analyticsService)
super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: timelineController.timelineKind == .pinned, super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
roomID: roomProxy.id, roomID: roomProxy.id,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
@ -690,13 +690,13 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} else { } else {
for (index, item) in itemGroup.enumerated() { for (index, item) in itemGroup.enumerated() {
if index == 0 { if index == 0 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .first), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .first),
forKey: item.id.uniqueID) forKey: item.id.uniqueID)
} else if index == itemGroup.count - 1 { } else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .last), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .last),
forKey: item.id.uniqueID) forKey: item.id.uniqueID)
} else { } else {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.isPinnedEventsTimeline ? .single : .middle), timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .middle),
forKey: item.id.uniqueID) forKey: item.id.uniqueID)
} }
} }
@ -868,29 +868,21 @@ private extension RoomInfoProxy {
// MARK: - Mocks // MARK: - Mocks
extension TimelineViewModel { extension TimelineViewModel {
static let mock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), static let mock = mock(timelineKind: .live)
focussedEventID: nil,
timelineController: MockRoomTimelineController(),
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static let pinnedEventsTimelineMock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), static func mock(timelineKind: TimelineKind = .live) -> TimelineViewModel {
focussedEventID: nil, TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
timelineController: MockRoomTimelineController(timelineKind: .pinned), focussedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()), timelineController: MockRoomTimelineController(timelineKind: timelineKind),
mediaPlayerProvider: MediaPlayerProviderMock(), mediaProvider: MediaProviderMock(configuration: .init()),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(), mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController, voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
appMediator: AppMediatorMock.default, userIndicatorController: ServiceLocator.shared.userIndicatorController,
appSettings: ServiceLocator.shared.settings, appMediator: AppMediatorMock.default,
analyticsService: ServiceLocator.shared.analytics, appSettings: ServiceLocator.shared.settings,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
}
} }
extension EnvironmentValues { extension EnvironmentValues {

View File

@ -345,7 +345,7 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
isDM: true, isDM: true,
isViewSourceEnabled: true, isViewSourceEnabled: true,
isCreateMediaCaptionsEnabled: true, isCreateMediaCaptionsEnabled: true,
isPinnedEventsTimeline: false, timelineKind: .live,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
guard let actions = provider.makeActions() else { return nil } guard let actions = provider.makeActions() else { return nil }

View File

@ -82,9 +82,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var switchToDefaultComposer: Bool { var switchToDefaultComposer: Bool {
switch self { switch self {
case .reply, .edit, .addCaption, .editCaption, .editPoll: case .reply, .edit, .addCaption, .editCaption, .editPoll:
return false false
default: default:
return true true
} }
} }
@ -92,9 +92,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var canAppearInFailedEcho: Bool { var canAppearInFailedEcho: Bool {
switch self { switch self {
case .copy, .edit, .redact, .viewSource, .editPoll: case .copy, .edit, .redact, .viewSource, .editPoll:
return true true
default: default:
return false false
} }
} }
@ -102,9 +102,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var canAppearInRedacted: Bool { var canAppearInRedacted: Bool {
switch self { switch self {
case .viewSource, .unpin, .viewInRoomTimeline: case .viewSource, .unpin, .viewInRoomTimeline:
return true true
default: default:
return false false
} }
} }
@ -112,18 +112,27 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var isDestructive: Bool { var isDestructive: Bool {
switch self { switch self {
case .redact, .report, .removeCaption: case .redact, .report, .removeCaption:
return true true
default: default:
return false false
} }
} }
var canAppearInPinnedEventsTimeline: Bool { var canAppearInPinnedEventsTimeline: Bool {
switch self { switch self {
case .viewInRoomTimeline, .pin, .unpin, .forward: case .viewInRoomTimeline, .pin, .unpin, .forward:
return true true
default: default:
return false false
}
}
var canAppearInMediaDetails: Bool {
switch self {
case .viewInRoomTimeline, .redact:
true
default:
false
} }
} }

View File

@ -17,7 +17,7 @@ struct TimelineItemMenuActionProvider {
let isDM: Bool let isDM: Bool
let isViewSourceEnabled: Bool let isViewSourceEnabled: Bool
let isCreateMediaCaptionsEnabled: Bool let isCreateMediaCaptionsEnabled: Bool
let isPinnedEventsTimeline: Bool let timelineKind: TimelineKind
let emojiProvider: EmojiProviderProtocol let emojiProvider: EmojiProviderProtocol
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
@ -38,6 +38,10 @@ struct TimelineItemMenuActionProvider {
var actions: [TimelineItemMenuAction] = [] var actions: [TimelineItemMenuAction] = []
var secondaryActions: [TimelineItemMenuAction] = [] var secondaryActions: [TimelineItemMenuAction] = []
if timelineKind == .pinned || timelineKind == .media(.mediaFilesScreen) {
actions.append(.viewInRoomTimeline)
}
if item.canBeRepliedTo { if item.canBeRepliedTo {
if let messageItem = item as? EventBasedMessageTimelineItemProtocol { if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
@ -99,10 +103,15 @@ struct TimelineItemMenuActionProvider {
secondaryActions.append(.redact) secondaryActions.append(.redact)
} }
if isPinnedEventsTimeline { switch timelineKind {
actions.insert(.viewInRoomTimeline, at: 0) case .pinned:
actions = actions.filter(\.canAppearInPinnedEventsTimeline) actions = actions.filter(\.canAppearInPinnedEventsTimeline)
secondaryActions = secondaryActions.filter(\.canAppearInPinnedEventsTimeline) secondaryActions = secondaryActions.filter(\.canAppearInPinnedEventsTimeline)
case .media:
actions = actions.filter(\.canAppearInMediaDetails)
secondaryActions = secondaryActions.filter(\.canAppearInMediaDetails)
case .live, .detached:
break // viewInRoomTimeline is the only non-room item and was added conditionally.
} }
if item.hasFailedToSend { if item.hasFailedToSend {
@ -114,11 +123,10 @@ struct TimelineItemMenuActionProvider {
actions = actions.filter(\.canAppearInRedacted) actions = actions.filter(\.canAppearInRedacted)
secondaryActions = secondaryActions.filter(\.canAppearInRedacted) secondaryActions = secondaryActions.filter(\.canAppearInRedacted)
} }
let isReactable = timelineKind == .live || timelineKind == .detached ? item.isReactable : false
return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable, return .init(isReactable: isReactable, actions: actions, secondaryActions: secondaryActions, emojiProvider: emojiProvider)
actions: actions,
secondaryActions: secondaryActions,
emojiProvider: emojiProvider)
} }
private func makeEncryptedItemActions(_ encryptedItem: EncryptedRoomTimelineItem) -> TimelineItemMenuActions? { private func makeEncryptedItemActions(_ encryptedItem: EncryptedRoomTimelineItem) -> TimelineItemMenuActions? {

View File

@ -20,7 +20,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom } private var isEncryptedOneToOneRoom: Bool { context.viewState.isEncryptedOneToOneRoom }
private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID } private var isFocussed: Bool { focussedEventID != nil && timelineItem.id.eventID == focussedEventID }
private var isPinned: Bool { private var isPinned: Bool {
guard !context.viewState.isPinnedEventsTimeline, guard context.viewState.timelineKind != .pinned,
let eventID = timelineItem.id.eventID else { let eventID = timelineItem.id.eventID else {
return false return false
} }
@ -110,7 +110,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
} }
// Do not display reactions in the pinned events timeline // Do not display reactions in the pinned events timeline
if !context.viewState.isPinnedEventsTimeline, if context.viewState.timelineKind != .pinned,
!timelineItem.properties.reactions.isEmpty { !timelineItem.properties.reactions.isEmpty {
TimelineReactionsView(context: context, TimelineReactionsView(context: context,
itemID: timelineItem.id, itemID: timelineItem.id,
@ -150,7 +150,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
isDM: context.viewState.isEncryptedOneToOneRoom, isDM: context.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: context.viewState.isViewSourceEnabled, isViewSourceEnabled: context.viewState.isViewSourceEnabled,
isCreateMediaCaptionsEnabled: context.viewState.isCreateMediaCaptionsEnabled, isCreateMediaCaptionsEnabled: context.viewState.isCreateMediaCaptionsEnabled,
isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline, timelineKind: context.viewState.timelineKind,
emojiProvider: context.viewState.emojiProvider) emojiProvider: context.viewState.emojiProvider)
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))

View File

@ -23,7 +23,7 @@ struct TimelineItemStatusView: View {
@ViewBuilder @ViewBuilder
private var mainContent: some View { private var mainContent: some View {
if context.viewState.isPinnedEventsTimeline { if context.viewState.timelineKind == .pinned {
// Do not display any status when is a pinned events timeline // Do not display any status when is a pinned events timeline
EmptyView() EmptyView()
} else if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty { } else if context.viewState.showReadReceipts, !timelineItem.properties.orderedReadReceipts.isEmpty {

View File

@ -12,7 +12,7 @@ struct PollRoomTimelineView: View {
@EnvironmentObject private var context: TimelineViewModel.Context @EnvironmentObject private var context: TimelineViewModel.Context
private var state: PollViewState { private var state: PollViewState {
if context.viewState.isPinnedEventsTimeline { if context.viewState.timelineKind == .pinned {
return .preview return .preview
} else { } else {
return .full(isEditable: timelineItem.isEditable) return .full(isEditable: timelineItem.isEditable)
@ -51,7 +51,7 @@ struct PollRoomTimelineView: View {
struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview { struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock static let viewModel = TimelineViewModel.mock
static let pinnedEventsTimelineViewModel = TimelineViewModel.pinnedEventsTimelineMock static let pinnedEventsTimelineViewModel = TimelineViewModel.mock(timelineKind: .pinned)
static var previews: some View { static var previews: some View {
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false)) PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false))

View File

@ -171,7 +171,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
let timeline = try await TimelineProxy(timeline: room.messageFilteredTimeline(internalIdPrefix: nil, let timeline = try await TimelineProxy(timeline: room.messageFilteredTimeline(internalIdPrefix: nil,
allowedMessageTypes: allowedMessageTypes, allowedMessageTypes: allowedMessageTypes,
dateDividerMode: .monthly), dateDividerMode: .monthly),
kind: .media) kind: .media(.mediaFilesScreen))
await timeline.subscribeForUpdates() await timeline.subscribeForUpdates()
return .success(timeline) return .success(timeline)

View File

@ -9,11 +9,13 @@ import Combine
import Foundation import Foundation
import MatrixRustSDK import MatrixRustSDK
enum TimelineKind { enum TimelineKind: Equatable {
case live case live
case detached case detached
case pinned case pinned
case media
enum MediaPresentation { case roomScreen, mediaFilesScreen }
case media(MediaPresentation)
} }
enum TimelineProxyError: Error { enum TimelineProxyError: Error {

View File

@ -21,9 +21,9 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// Given a fresh view model. // Given a fresh view model.
setupViewModel() setupViewModel()
XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled) XCTAssertFalse(mediaProvider.loadFileFromSourceFilenameCalled)
XCTAssertNil(context.viewState.currentItem) XCTAssertEqual(context.viewState.currentItem, context.viewState.previewItems[0])
// When setting the current item. // When the preview controller sets the current item.
await viewModel.updateCurrentItem(context.viewState.previewItems[0]) await viewModel.updateCurrentItem(context.viewState.previewItems[0])
// Then the view model should load the item and update its view state. // Then the view model should load the item and update its view state.
@ -34,22 +34,21 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
// MARK: - Helpers // MARK: - Helpers
private func setupViewModel() { private func setupViewModel() {
let previewItems = [ let item = ImageRoomTimelineItem(id: .randomEvent,
ImageRoomTimelineItem(id: .randomEvent, timestamp: .mock,
timestamp: .mock, isOutgoing: false,
isOutgoing: false, isEditable: false,
isEditable: false, canBeRepliedTo: true,
canBeRepliedTo: true, isThreaded: false,
isThreaded: false, sender: .init(id: "", displayName: "Sally Sanderson"),
sender: .init(id: "", displayName: "Sally Sanderson"), content: .init(filename: "Amazing image.jpeg",
content: .init(filename: "Amazing image.jpeg", caption: "A caption goes right here.",
caption: "A caption goes right here.", imageInfo: .mockImage,
imageInfo: .mockImage, thumbnailInfo: .mockThumbnail))
thumbnailInfo: .mockThumbnail))
]
mediaProvider = MediaProviderMock(configuration: .init()) mediaProvider = MediaProviderMock(configuration: .init())
viewModel = TimelineMediaPreviewViewModel(previewItems: previewItems, viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock,
mediaProvider: mediaProvider, mediaProvider: mediaProvider,
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }