mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
MediaTimelinePreviewDetails can now be opened by long pressing in the media timeline (#4474)
* presenting media actions from the media timeline * implemented view in timeline and fixed an issue that made dark mode being when trying to delete the file * pr suggestions
This commit is contained in:
+13
-2
@@ -11,11 +11,21 @@ import SwiftUI
|
||||
struct TimelineMediaPreviewDetailsView: View {
|
||||
let item: TimelineMediaPreviewItem.Media
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
var preferredColorScheme: ColorScheme? = .dark
|
||||
|
||||
@Binding var sheetHeight: CGFloat
|
||||
private let topPadding: CGFloat = 19
|
||||
|
||||
var body: some View {
|
||||
if let preferredColorScheme {
|
||||
mainContent
|
||||
.preferredColorScheme(preferredColorScheme)
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
details
|
||||
@@ -29,9 +39,10 @@ struct TimelineMediaPreviewDetailsView: View {
|
||||
.presentationDetents([.height(sheetHeight + topPadding)])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.preferredColorScheme(.dark)
|
||||
.sheet(item: $context.redactConfirmationItem) { item in
|
||||
TimelineMediaPreviewRedactConfirmationView(item: item, context: context)
|
||||
TimelineMediaPreviewRedactConfirmationView(item: item,
|
||||
context: context,
|
||||
preferredColorScheme: preferredColorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -13,11 +13,21 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
|
||||
|
||||
let item: TimelineMediaPreviewItem.Media
|
||||
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
|
||||
|
||||
var preferredColorScheme: ColorScheme? = .dark
|
||||
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
private let topPadding: CGFloat = 19
|
||||
|
||||
var body: some View {
|
||||
if let preferredColorScheme {
|
||||
mainContent
|
||||
.preferredColorScheme(preferredColorScheme)
|
||||
} else {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
@@ -31,7 +41,6 @@ struct TimelineMediaPreviewRedactConfirmationView: View {
|
||||
.presentationDetents([.height(sheetHeight + topPadding)])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(.compound.bgCanvasDefault)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
|
||||
+2
@@ -36,6 +36,7 @@ struct MediaEventsTimelineScreenViewState: BindableState {
|
||||
struct MediaEventsTimelineScreenViewStateBindings {
|
||||
var screenMode: MediaEventsTimelineScreenMode
|
||||
var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
|
||||
var mediaPreviewSheetViewModel: TimelineMediaPreviewViewModel?
|
||||
}
|
||||
|
||||
enum MediaEventsTimelineScreenViewAction {
|
||||
@@ -43,4 +44,5 @@ enum MediaEventsTimelineScreenViewAction {
|
||||
case oldestItemDidAppear
|
||||
case oldestItemDidDisappear
|
||||
case tappedItem(item: RoomTimelineItemViewState)
|
||||
case longPressedItem(item: RoomTimelineItemViewState)
|
||||
}
|
||||
|
||||
+33
-2
@@ -75,9 +75,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
.store(in: &cancellables)
|
||||
|
||||
mediaTimelineViewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .displayMediaPreview(let mediaPreviewViewModel):
|
||||
self?.displayMediaPreview(mediaPreviewViewModel)
|
||||
displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayMediaDetails(item: let item):
|
||||
displayMediaPreviewSheet(for: item)
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
|
||||
@@ -97,9 +100,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
.store(in: &cancellables)
|
||||
|
||||
filesTimelineViewModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .displayMediaPreview(let mediaPreviewViewModel):
|
||||
self?.displayMediaPreview(mediaPreviewViewModel)
|
||||
displayMediaPreview(mediaPreviewViewModel)
|
||||
case .displayMediaDetails(item: let item):
|
||||
displayMediaPreviewSheet(for: item)
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
|
||||
@@ -127,6 +133,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
isOldestItemVisible = false
|
||||
case .tappedItem(let item):
|
||||
activeTimelineViewModel.context.send(viewAction: .mediaTapped(itemID: item.identifier))
|
||||
case .longPressedItem(let item):
|
||||
activeTimelineViewModel.context.send(viewAction: .displayTimelineItemMenu(itemID: item.identifier))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +145,29 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func displayMediaPreviewSheet(for item: EventBasedMessageTimelineItemProtocol) {
|
||||
let sheetModel = TimelineMediaPreviewViewModel(initialItem: item,
|
||||
timelineViewModel: activeTimelineViewModel,
|
||||
mediaProvider: mediaProvider,
|
||||
photoLibraryManager: PhotoLibraryManager(),
|
||||
userIndicatorController: userIndicatorController,
|
||||
appMediator: appMediator)
|
||||
sheetModel.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .viewInRoomTimeline(let itemID):
|
||||
actionsSubject.send(.viewInRoomTimeline(itemID))
|
||||
case .dismiss:
|
||||
state.bindings.mediaPreviewSheetViewModel = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Triggers a download of the item so that can be shared/saved
|
||||
sheetModel.context.send(viewAction: .updateCurrentItem(sheetModel.state.currentItem))
|
||||
state.bindings.mediaPreviewSheetViewModel = sheetModel
|
||||
}
|
||||
|
||||
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
|
||||
var newGroups = [MediaEventsTimelineGroup]()
|
||||
var currentItems = [RoomTimelineItemViewState]()
|
||||
|
||||
+20
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
|
||||
struct MediaEventsTimelineScreen: View {
|
||||
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
|
||||
@State private var sheetHeight = CGFloat.zero
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
@@ -24,6 +25,15 @@ struct MediaEventsTimelineScreen: View {
|
||||
context.send(viewAction: .changedScreenMode)
|
||||
}
|
||||
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
|
||||
.sheet(item: $context.mediaPreviewSheetViewModel) { sheet in
|
||||
if case let .media(media) = sheet.state.currentItem {
|
||||
TimelineMediaPreviewDetailsView(item: media,
|
||||
context: sheet.context,
|
||||
preferredColorScheme: nil,
|
||||
sheetHeight: $sheetHeight)
|
||||
.presentationDetents([.height(sheetHeight)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The scale effects do the following:
|
||||
@@ -66,6 +76,9 @@ struct MediaEventsTimelineScreen: View {
|
||||
viewForTimelineItem(item)
|
||||
.scaleEffect(CGSize(width: -1, height: -1))
|
||||
}
|
||||
.accessibleLongPress(named: L10n.actionOpenContextMenu) {
|
||||
context.send(viewAction: .longPressedItem(item: item))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
// Use a footer as the header because the scrollView is flipped
|
||||
@@ -96,6 +109,9 @@ struct MediaEventsTimelineScreen: View {
|
||||
.accessibilityRepresentation {
|
||||
viewForTimelineItem(item)
|
||||
}
|
||||
.accessibleLongPress(named: L10n.actionOpenContextMenu) {
|
||||
context.send(viewAction: .longPressedItem(item: item))
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.padding(.horizontal, 16)
|
||||
@@ -230,6 +246,10 @@ struct MediaEventsTimelineScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension TimelineMediaPreviewViewModel: Identifiable {
|
||||
var id: UUID { instanceID }
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
// These other actions will not be handled in this view
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled, .displayRoom:
|
||||
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled, .displayRoom, .displayMediaDetails:
|
||||
// These actions are not handled in this coordinator
|
||||
break
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
roomViewModel.timelineHasScrolled(direction: direction)
|
||||
case .displayRoom(let roomID, let via):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID, via: via))
|
||||
case .viewInRoomTimeline:
|
||||
case .viewInRoomTimeline, .displayMediaDetails:
|
||||
fatalError("The action: \(action) should not be sent to this coordinator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
case .displayResolveSendFailure(let failure, let sendHandle):
|
||||
actionsSubject.send(.presentResolveSendFailure(failure: failure,
|
||||
sendHandle: sendHandle))
|
||||
case .hasScrolled, .displayRoom:
|
||||
case .hasScrolled, .displayRoom, .displayMediaDetails:
|
||||
break
|
||||
case .composer(let action):
|
||||
composerViewModel.process(timelineAction: action)
|
||||
|
||||
@@ -29,6 +29,7 @@ enum TimelineViewModelAction {
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case viewInRoomTimeline(eventID: String)
|
||||
case displayRoom(roomID: String, via: [String])
|
||||
case displayMediaDetails(item: EventBasedMessageTimelineItemProtocol)
|
||||
}
|
||||
|
||||
enum TimelineViewPollAction {
|
||||
|
||||
@@ -476,7 +476,12 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
case .displayMediaUploadPreviewScreen(let mediaURLs):
|
||||
actionsSubject.send(.displayMediaUploadPreviewScreen(mediaURLs: mediaURLs))
|
||||
case .showActionMenu(let actionMenuInfo):
|
||||
self.state.bindings.actionMenuInfo = actionMenuInfo
|
||||
if case .media(.mediaFilesScreen) = timelineController.timelineKind,
|
||||
let item = actionMenuInfo.item as? EventBasedMessageTimelineItemProtocol {
|
||||
actionsSubject.send(.displayMediaDetails(item: item))
|
||||
} else {
|
||||
self.state.bindings.actionMenuInfo = actionMenuInfo
|
||||
}
|
||||
case .showDebugInfo(let debugInfo):
|
||||
state.bindings.debugInfo = debugInfo
|
||||
case .viewInRoomTimeline(let eventID):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func accessibleLongPress(named name: String, action: @escaping () -> Void) -> some View {
|
||||
modifier(AccessibleLongPress(name: name, action: action))
|
||||
}
|
||||
}
|
||||
|
||||
struct AccessibleLongPress: ViewModifier {
|
||||
@Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled
|
||||
let name: String
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if isVoiceOverEnabled {
|
||||
content
|
||||
.accessibilityAction(named: name) {
|
||||
action()
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.longPressWithFeedback {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user