mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 01:03:01 +00:00
6bd8423675
* implemented checkSourceSafetyIfNeeded in the timeline media preview * added a subtitle to the spinner when scanning and an animated drag gesture for the error and loading states * pr suggestions * pr suggestions part 2
404 lines
17 KiB
Swift
404 lines
17 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-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 Compound
|
|
import SwiftUI
|
|
|
|
struct MediaEventsTimelineScreen: View {
|
|
@Bindable var context: MediaEventsTimelineScreenViewModel.Context
|
|
@State private var sheetHeight = CGFloat.zero
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.background(.compound.bgCanvasDefault)
|
|
// Doesn't play well with the transformed scrollView
|
|
.toolbarBackground(.visible, for: .navigationBar)
|
|
.toolbar { toolbar }
|
|
.environmentObject(context.viewState.activeTimelineContext)
|
|
.environment(\.timelineContext, context.viewState.activeTimelineContext)
|
|
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
|
|
.sheet(item: $context.mediaPreviewSheetViewModel) { sheet in
|
|
if let mediaItem = sheet.state.currentItem.mediaItem {
|
|
TimelineMediaPreviewDetailsView(item: mediaItem,
|
|
context: sheet.context,
|
|
preferredColorScheme: nil,
|
|
sheetHeight: $sheetHeight)
|
|
.presentationDetents([.height(sheetHeight)])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The scale effects do the following:
|
|
/// * flip the scrollView vertically to keep the items
|
|
/// at the bottom and have pagination working properly
|
|
/// * flip the grid vertically to counteract the scroll view
|
|
/// but also horizontally to preserve the correct item order
|
|
/// * flip the items on both axes have them render correctly
|
|
@ViewBuilder
|
|
private var mainContent: some View {
|
|
if context.viewState.shouldShowEmptyState {
|
|
emptyState
|
|
} else {
|
|
scrollView
|
|
// Remove the glass effect of iOS 26+
|
|
// A flipped table view will always trigger it
|
|
// since the nav bar thinks is always at the bottom.
|
|
.backportScrollEdgeEffectHidden()
|
|
}
|
|
}
|
|
|
|
private var scrollView: some View {
|
|
ScrollView {
|
|
Group {
|
|
switch context.viewState.screenMode {
|
|
case .media:
|
|
mediaContent
|
|
case .files:
|
|
filesContent
|
|
}
|
|
|
|
header
|
|
}
|
|
}
|
|
.scaleEffect(.init(width: 1, height: -1))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var mediaContent: some View {
|
|
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
|
|
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
|
|
ForEach(context.viewState.groups) { group in
|
|
Section {
|
|
ForEach(group.items) { item in
|
|
Button {
|
|
tappedItem(item)
|
|
} label: {
|
|
viewForTimelineItem(item, screenMode: .media)
|
|
.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
|
|
SeparatorMediaEventsTimelineView(group: group)
|
|
.scaleEffect(.init(width: -1, height: -1))
|
|
}
|
|
}
|
|
}
|
|
.scaleEffect(.init(width: -1, height: 1))
|
|
}
|
|
|
|
private var filesContent: some View {
|
|
LazyVStack(alignment: .center, spacing: 16) {
|
|
ForEach(context.viewState.groups) { group in
|
|
Section {
|
|
ForEach(group.items) { item in
|
|
VStack(spacing: 20) {
|
|
Divider()
|
|
.accessibilityHidden(true)
|
|
|
|
Button {
|
|
tappedItem(item)
|
|
} label: {
|
|
viewForTimelineItem(item, screenMode: .files)
|
|
.scaleEffect(CGSize(width: 1, height: -1))
|
|
}
|
|
.accessibilityRepresentation {
|
|
viewForTimelineItem(item, screenMode: .files)
|
|
}
|
|
.accessibleLongPress(named: L10n.actionOpenContextMenu) {
|
|
context.send(viewAction: .longPressedItem(item: item))
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
} footer: {
|
|
// Use a footer as the header because the scrollView is flipped
|
|
SeparatorMediaEventsTimelineView(group: group)
|
|
.scaleEffect(.init(width: 1, height: -1))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
|
|
LazyVStack(spacing: 0) {
|
|
ProgressView()
|
|
.padding()
|
|
.opacity(context.viewState.isBackPaginating ? 1 : 0)
|
|
.scaleEffect(.init(width: 1, height: -1)) // Make sure it spins the right way around 🙃
|
|
|
|
Rectangle()
|
|
.frame(height: 1)
|
|
.foregroundStyle(.compound.bgCanvasDefault)
|
|
.onAppear {
|
|
context.send(viewAction: .oldestItemDidAppear)
|
|
}
|
|
.onDisappear {
|
|
context.send(viewAction: .oldestItemDidDisappear)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The mode check makes sure that stale items from the other mode's layout are never rendered.
|
|
@ViewBuilder
|
|
private func viewForTimelineItem(_ item: RoomTimelineItemViewState, screenMode: MediaEventsTimelineScreenMode) -> some View {
|
|
switch item.type {
|
|
case .image(let timelineItem) where screenMode == .media:
|
|
ImageMediaEventsTimelineView(timelineItem: timelineItem)
|
|
case .video(let timelineItem) where screenMode == .media:
|
|
VideoMediaEventsTimelineView(timelineItem: timelineItem)
|
|
case .file(let timelineItem) where screenMode == .files:
|
|
FileMediaEventsTimelineView(timelineItem: timelineItem)
|
|
case .audio(let timelineItem) where screenMode == .files:
|
|
AudioMediaEventsTimelineView(timelineItem: timelineItem)
|
|
case .voice(let timelineItem) where screenMode == .files:
|
|
let defaultPlayerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItem.id), title: L10n.commonVoiceMessage, duration: 0)
|
|
let playerState = context.viewState.activeTimelineContext.viewState.audioPlayerStateProvider?(timelineItem.id) ?? defaultPlayerState
|
|
VoiceMessageMediaEventsTimelineView(timelineItem: timelineItem, playerState: playerState)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
FullscreenDialog(topPadding: UIConstants.iconTopPaddingToNavigationBar, background: .gradient) {
|
|
VStack(spacing: 16) {
|
|
switch context.viewState.screenMode {
|
|
case .media:
|
|
emptyMedia
|
|
case .files:
|
|
emptyFiles
|
|
}
|
|
}
|
|
.padding(16)
|
|
} bottomContent: { EmptyView() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyMedia: some View {
|
|
BigIcon(icon: \.image)
|
|
|
|
Text(L10n.screenMediaBrowserMediaEmptyStateTitle)
|
|
.foregroundColor(.compound.textPrimary)
|
|
.font(.compound.headingMDBold)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(L10n.screenMediaBrowserMediaEmptyStateSubtitle)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.font(.compound.bodyMD)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyFiles: some View {
|
|
BigIcon(icon: \.document)
|
|
|
|
Text(L10n.screenMediaBrowserFilesEmptyStateTitle)
|
|
.foregroundColor(.compound.textPrimary)
|
|
.font(.compound.headingMDBold)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(L10n.screenMediaBrowserFilesEmptyStateSubtitle)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.font(.compound.bodyMD)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbar: some ToolbarContent {
|
|
ToolbarItem(placement: .principal) {
|
|
if #available(iOS 26, *) {
|
|
screenModePicker
|
|
} else {
|
|
screenModePicker
|
|
.frame(idealWidth: .greatestFiniteMagnitude)
|
|
}
|
|
}
|
|
|
|
if #available(iOS 26, *) {
|
|
ToolbarSpacer()
|
|
} else {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
// Reserve the space trailing space to match the back button.
|
|
CompoundIcon(\.search).hidden()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var screenModePicker: some View {
|
|
Picker("", selection: Binding(get: { context.viewState.screenMode },
|
|
set: { context.send(viewAction: .changeScreenMode($0)) })) {
|
|
Text(L10n.screenMediaBrowserListModeMedia)
|
|
.padding()
|
|
.tag(MediaEventsTimelineScreenMode.media)
|
|
Text(L10n.screenMediaBrowserListModeFiles)
|
|
.padding()
|
|
.tag(MediaEventsTimelineScreenMode.files)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
}
|
|
|
|
func tappedItem(_ item: RoomTimelineItemViewState) {
|
|
context.send(viewAction: .tappedItem(item: item))
|
|
}
|
|
}
|
|
|
|
extension TimelineMediaPreviewViewModel: Identifiable {
|
|
var id: UUID {
|
|
instanceID
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let mediaViewModel = makeViewModel(screenMode: .media)
|
|
static let filesViewModel = makeViewModel(screenMode: .files)
|
|
static let emptyMediaViewModel = makeViewModel(empty: true, screenMode: .media)
|
|
static let emptyFilesViewModel = makeViewModel(empty: true, screenMode: .files)
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
MediaEventsTimelineScreen(context: mediaViewModel.context)
|
|
}
|
|
.previewDisplayName("Media")
|
|
|
|
ElementNavigationStack {
|
|
MediaEventsTimelineScreen(context: filesViewModel.context)
|
|
}
|
|
.previewDisplayName("Files")
|
|
|
|
ElementNavigationStack {
|
|
MediaEventsTimelineScreen(context: emptyMediaViewModel.context)
|
|
}
|
|
.previewDisplayName("Empty Media")
|
|
|
|
ElementNavigationStack {
|
|
MediaEventsTimelineScreen(context: emptyFilesViewModel.context)
|
|
}
|
|
.previewDisplayName("Empty Files")
|
|
}
|
|
|
|
private static func makeViewModel(empty: Bool = false,
|
|
screenMode: MediaEventsTimelineScreenMode) -> MediaEventsTimelineScreenViewModel {
|
|
MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .media),
|
|
filesTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .files),
|
|
initialScreenMode: screenMode,
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
appMediator: AppMediatorMock())
|
|
}
|
|
|
|
private static func makeTimelineViewModel(empty: Bool, screenMode: MediaEventsTimelineScreenMode) -> TimelineViewModel {
|
|
let timelineController = if empty {
|
|
TimelineControllerMock.emptyMediaGallery
|
|
} else {
|
|
makeTimelineController(screenMode: screenMode)
|
|
}
|
|
|
|
let appSettings = AppSettings.volatile()
|
|
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
|
timelineController: timelineController,
|
|
userSession: UserSessionMock(.init(contentScannerService: contentScannerService)),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
appMediator: AppMediatorMock(.init()),
|
|
appSettings: appSettings,
|
|
analyticsService: AnalyticsServiceMock(.init()),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
}
|
|
|
|
// MARK: Content scanning
|
|
|
|
/// A content scanner that reports the dedicated mock sources as being scanned/unsafe and everything else as safe.
|
|
private static let contentScannerService = {
|
|
let contentScannerService = ContentScannerServiceMock()
|
|
contentScannerService.scanResultFromSourceClosure = { source in
|
|
switch source.url {
|
|
case .mockMXCScanning: nil
|
|
case .mockMXCUnsafe: false
|
|
default: true
|
|
}
|
|
}
|
|
contentScannerService.loadScanResultFromSourceClosure = { source in
|
|
switch source.url {
|
|
case .mockMXCScanning:
|
|
// Never resolve so that the scanning state remains visible.
|
|
try? await Task.sleep(for: .seconds(3600))
|
|
return .failure(.failedScanning)
|
|
case .mockMXCUnsafe:
|
|
return .success(false)
|
|
default:
|
|
return .success(true)
|
|
}
|
|
}
|
|
return contentScannerService
|
|
}()
|
|
|
|
/// The regular gallery items followed by one that is being scanned and an unsafe one.
|
|
private static func makeTimelineController(screenMode: MediaEventsTimelineScreenMode) -> TimelineControllerMock {
|
|
var timelineItems: [RoomTimelineItemProtocol] = (0..<5).reduce([]) { partialResult, _ in
|
|
partialResult + [TimelineFixtures.separator] + TimelineFixtures.mediaChunk
|
|
}
|
|
|
|
switch screenMode {
|
|
case .media:
|
|
timelineItems.append(contentsOf: [makeImageItem(url: .mockMXCScanning), makeImageItem(url: .mockMXCUnsafe)])
|
|
case .files:
|
|
timelineItems.append(contentsOf: [makeFileItem(url: .mockMXCScanning), makeFileItem(url: .mockMXCUnsafe)])
|
|
}
|
|
|
|
return TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: timelineItems))
|
|
}
|
|
|
|
private static func makeImageItem(url: URL) -> ImageRoomTimelineItem {
|
|
guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpg") else {
|
|
fatalError("Invalid mock media source URL")
|
|
}
|
|
|
|
return ImageRoomTimelineItem(id: .randomEvent,
|
|
timestamp: .mock,
|
|
isOutgoing: false,
|
|
isEditable: false,
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: "@bob:matrix.org"),
|
|
content: .init(filename: "image.jpg",
|
|
imageInfo: .init(source: mediaSource, width: 2730, height: 2048, mimeType: "image/jpg", fileSize: nil),
|
|
thumbnailInfo: nil,
|
|
blurhash: "KpE4oyayR5|GbHb];3j@of"))
|
|
}
|
|
|
|
private static func makeFileItem(url: URL) -> FileRoomTimelineItem {
|
|
guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: nil) else {
|
|
fatalError("Invalid mock media source URL")
|
|
}
|
|
|
|
return FileRoomTimelineItem(id: .randomEvent,
|
|
timestamp: .mock,
|
|
isOutgoing: false,
|
|
isEditable: false,
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: "@bob:matrix.org"),
|
|
content: .init(filename: "important-document.pdf",
|
|
caption: nil,
|
|
formattedCaption: nil,
|
|
source: mediaSource,
|
|
fileSize: 3 * 1024 * 1024,
|
|
thumbnailSource: nil,
|
|
contentType: nil))
|
|
}
|
|
}
|