Files
letro-ios/ElementX/Sources/Screens/FilePreviewScreen/View/TimelineMediaPreviewDetailsView.swift
Stefan Ceriu e5a49c568f Adopt room info power levels (#4245)
* Use the newly RoomInfo published PowerLevels; update permissions at the same time as the room info and avoid unnecessary async calls.

* Introduce a RoomInfoProxyProtocol and associated mock and move away from mocked SDK RoomInfos

* Bump the SDK to v25.06.23

* Expose new non-throwing methods for checking one's own user permissions

* Fix the unit and preview tests (except the TimelineMediaPreviewDetails for which simplified snapshots were generated)

* Converge on one single implementation for computing a room's avatar.

* Fix more unit tests

* Fix the TimelineMediaPreviewDetailsView snapshot tests

This reverts commit 9b6c819e611ad2fa3de2c34a4a7aa556fc9faadd.

---------

Co-authored-by: Doug <douglase@element.io>
2025-06-24 17:48:00 +01:00

240 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// Copyright 2024 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 TimelineMediaPreviewDetailsView: View {
let item: TimelineMediaPreviewItem.Media
@ObservedObject var context: TimelineMediaPreviewViewModel.Context
@Binding var sheetHeight: CGFloat
private let topPadding: CGFloat = 19
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
details
actions
}
.frame(maxWidth: .infinity, alignment: .leading)
.readHeight($sheetHeight)
}
.scrollBounceBehavior(.basedOnSize)
.padding(.top, topPadding) // For the drag indicator
.presentationDetents([.height(sheetHeight + topPadding)])
.presentationDragIndicator(.visible)
.presentationBackground(.compound.bgCanvasDefault)
.preferredColorScheme(.dark)
.sheet(item: $context.redactConfirmationItem) { item in
TimelineMediaPreviewRedactConfirmationView(item: item, context: context)
}
}
private var details: some View {
VStack(alignment: .leading, spacing: 24) {
DetailsRow(title: L10n.screenMediaDetailsUploadedBy) {
HStack(spacing: 8) {
LoadableAvatarImage(url: item.sender.avatarURL,
name: item.sender.displayName,
contentID: item.sender.id,
avatarSize: .user(on: .mediaPreviewDetails),
mediaProvider: context.mediaProvider)
VStack(alignment: .leading, spacing: 0) {
if let displayName = item.sender.displayName {
Text(displayName)
.font(.compound.bodyMDSemibold)
.foregroundStyle(.compound.decorativeColor(for: item.sender.id).text)
}
Text(item.sender.id)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
}
DetailsRow(title: L10n.screenMediaDetailsUploadedOn) {
Text(item.timestamp.formatted(date: .abbreviated, time: .shortened))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
DetailsRow(title: L10n.screenMediaDetailsFilename) {
Text(item.filename ?? "")
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
if let contentType = item.contentType {
DetailsRow(title: L10n.screenMediaDetailsFileFormat) {
Group {
if let fileSize = item.fileSize {
Text(contentType) + Text(" ") + Text(UInt(fileSize).formatted(.byteCount(style: .file)))
} else {
Text(contentType)
}
}
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
}
}
}
.padding(.top, 24)
.padding(.bottom, 32)
.padding(.horizontal, 16)
}
@ViewBuilder
private var actions: some View {
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
ActionButton(item: item, action: action, context: context)
}
if !actions.secondaryActions.isEmpty {
Divider()
.background(Color.compound.bgSubtlePrimary)
}
ForEach(actions.secondaryActions, id: \.self) { action in
ActionButton(item: item, action: action, context: context)
}
}
}
}
private struct DetailsRow<Content: View>: View {
let title: String
let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.compound.bodyXS)
.foregroundStyle(.compound.textSecondary)
.textCase(.uppercase)
content()
}
}
}
private struct ActionButton: View {
let item: TimelineMediaPreviewItem.Media
let action: TimelineItemMenuAction
let context: TimelineMediaPreviewViewModel.Context
var body: some View {
if action == .share {
if let itemURL = item.fileHandle?.url {
ShareLink(item: itemURL, message: item.caption.map(Text.init)) {
action.label
}
.buttonStyle(.menuSheet)
}
} else if action == .save {
if item.fileHandle?.url != nil {
button
}
} else {
button
}
}
var button: some View {
Button(role: action.isDestructive ? .destructive : nil) {
context.send(viewAction: .menuAction(action, item: item))
} label: {
action.label
}
.buttonStyle(.menuSheet)
}
}
}
// MARK: - Previews
import UniformTypeIdentifiers
struct TimelineMediaPreviewDetailsView_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(contentType: .jpeg, isOutgoing: true)
static let loadingViewModel = makeViewModel(contentType: .jpeg, isOutgoing: true, isDownloaded: false)
static let unknownTypeViewModel = makeViewModel()
static let presentedOnRoomViewModel = makeViewModel(isPresentedOnRoomScreen: true)
@State static var sheetHeight: CGFloat = .zero
static var previews: some View {
if case let .media(mediaItem) = viewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: viewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Image")
.snapshotPreferences(expect: mediaItem.observe(\.fileHandle).map { $0 != nil }.eraseToStream())
}
if case let .media(mediaItem) = loadingViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: loadingViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Loading")
}
if case let .media(mediaItem) = unknownTypeViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: unknownTypeViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Unknown type")
.snapshotPreferences(expect: mediaItem.observe(\.fileHandle).map { $0 != nil }.eraseToStream())
}
if case let .media(mediaItem) = presentedOnRoomViewModel.state.currentItem {
TimelineMediaPreviewDetailsView(item: mediaItem, context: presentedOnRoomViewModel.context, sheetHeight: $sheetHeight)
.previewDisplayName("Incoming on Room")
.snapshotPreferences(expect: mediaItem.observe(\.fileHandle).map { $0 != nil }.eraseToStream())
}
}
static func makeViewModel(contentType: UTType? = nil,
isOutgoing: Bool = false,
isDownloaded: Bool = true,
isPresentedOnRoomScreen: Bool = false) -> TimelineMediaPreviewViewModel {
let item = ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: isOutgoing,
isEditable: true,
canBeRepliedTo: true,
sender: .init(id: "@alice:matrix.org",
displayName: "Alice",
avatarURL: .mockMXCUserAvatar),
content: .init(filename: "Amazing Image.jpeg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: contentType))
let timelineKind = TimelineKind.media(isPresentedOnRoomScreen ? .roomScreenLive : .mediaFilesScreen)
let timelineController = MockTimelineController(timelineKind: timelineKind)
timelineController.timelineItems = [item]
let viewModel = TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: TimelineViewModel.mock(timelineKind: timelineKind,
timelineController: timelineController),
mediaProvider: MediaProviderMock(configuration: .init()),
photoLibraryManager: PhotoLibraryManagerMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock())
if isDownloaded {
viewModel.context.send(viewAction: .updateCurrentItem(viewModel.state.currentItem))
}
return viewModel
}
}