Media gallery - support for files and voice messages (#3605)

* Move the voice message views to where they belong
* Add separate struct for each media events timeline view
* Add support for all the different media gallery message types and get the files section working
This commit is contained in:
Stefan Ceriu
2024-12-12 10:02:10 +02:00
committed by GitHub
parent 606eb0a30a
commit 114255c5ec
30 changed files with 374 additions and 112 deletions
@@ -24,6 +24,8 @@ struct MediaEventsTimelineScreenViewState: BindableState {
var isBackPaginating = false
var groups = [MediaEventsTimelineGroup]()
var activeTimelineContextProvider: (() -> TimelineViewModel.Context)!
var bindings: MediaEventsTimelineScreenViewStateBindings
}
@@ -42,6 +42,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
super.init(initialViewState: .init(bindings: .init(screenMode: screenMode)), mediaProvider: mediaProvider)
state.activeTimelineContextProvider = { [weak self] in
guard let self else { fatalError() }
return activeTimelineViewModel.context
}
mediaTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
guard let self, state.bindings.screenMode == .media else {
return
@@ -12,7 +12,7 @@ struct MediaEventsTimelineScreen: View {
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
var body: some View {
content
mainContent
.navigationBarTitleDisplayMode(.inline)
.background(.compound.bgCanvasDefault)
// Doesn't play well with the transformed scrollView
@@ -31,6 +31,8 @@ struct MediaEventsTimelineScreen: View {
}
}
.timelineMediaQuickLook(viewModel: $context.mediaPreviewViewModel)
.environmentObject(context.viewState.activeTimelineContextProvider())
.environment(\.timelineContext, context.viewState.activeTimelineContextProvider())
}
// The scale effects do the following:
@@ -39,32 +41,16 @@ struct MediaEventsTimelineScreen: View {
// * flip the grid vertically to counteract the scroll view
// but also horizontally to preserve the corect item order
// * flip the items on both axes have them render correctly
@ViewBuilder
private var content: some View {
private var mainContent: some View {
ScrollView {
Group {
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
ForEach(context.viewState.groups) { group in
Section(footer: sectionFooterForGroup(group)) {
ForEach(group.items) { item in
Button {
context.send(viewAction: .tappedItem(item))
} label: {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
viewForTimelineItem(item)
}
.clipped()
.scaleEffect(.init(width: -1, height: -1))
}
}
}
}
switch context.viewState.bindings.screenMode {
case .media:
mediaContent
case .files:
filesContent
}
.scaleEffect(.init(width: -1, height: 1))
header
}
}
@@ -74,6 +60,61 @@ struct MediaEventsTimelineScreen: View {
}
}
@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 {
context.send(viewAction: .tappedItem(item))
} label: {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
viewForTimelineItem(item)
}
.clipped()
.scaleEffect(.init(width: -1, height: -1))
}
}
} 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))
}
@ViewBuilder
private var filesContent: some View {
LazyVStack(alignment: .center, spacing: 16) {
ForEach(context.viewState.groups) { group in
Section {
ForEach(group.items) { item in
viewForTimelineItem(item)
.scaleEffect(.init(width: 1, height: -1))
.onTapGesture {
context.send(viewAction: .tappedItem(item))
}
.accessibilityActions {
Button(L10n.actionShow) {
context.send(viewAction: .tappedItem(item))
}
}
}
} 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) {
@@ -93,71 +134,25 @@ struct MediaEventsTimelineScreen: View {
}
}
@ViewBuilder
func sectionFooterForGroup(_ group: MediaEventsTimelineGroup) -> some View {
Text(group.title)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.frame(alignment: .center)
.scaleEffect(.init(width: -1, height: -1))
.padding(.vertical, 16)
}
@ViewBuilder
func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View {
switch item.type {
case .image(let timelineItem):
#warning("Make this work for gifs")
LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
blurhash: timelineItem.content.blurhash,
size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size,
mediaProvider: context.mediaProvider) {
placeholder
}
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
ImageMediaEventsTimelineView(timelineItem: timelineItem)
case .video(let timelineItem):
if let thumbnailSource = timelineItem.content.thumbnailInfo?.source {
LoadableImage(mediaSource: thumbnailSource,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
blurhash: timelineItem.content.blurhash,
size: timelineItem.content.thumbnailInfo?.size,
mediaProvider: context.mediaProvider) { imageView in
imageView
.overlay { playIcon }
} placeholder: {
placeholder
}
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo)
} else {
playIcon
}
VideoMediaEventsTimelineView(timelineItem: timelineItem)
case .file(let timelineItem):
FileRoomTimelineView(timelineItem: timelineItem)
case .audio(let timelineItem):
AudioRoomTimelineView(timelineItem: timelineItem)
case .voice(let timelineItem):
let defaultPlayerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItem.id), title: L10n.commonVoiceMessage, duration: 0)
let playerState = context.viewState.activeTimelineContextProvider().viewState.audioPlayerStateProvider?(timelineItem.id) ?? defaultPlayerState
VoiceMessageRoomTimelineView(timelineItem: timelineItem, playerState: playerState)
default:
EmptyView()
}
}
private var playIcon: some View {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
}
private var placeholder: some View {
Rectangle()
.foregroundColor(.compound._bgBubbleIncoming)
.opacity(0.3)
}
}
extension View {
/// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio.
@ViewBuilder
func mediaItemAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
}
}
// MARK: - Previews
@@ -0,0 +1,83 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct ImageMediaEventsTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: ImageRoomTimelineItem
var body: some View {
loadableImage
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
}
@ViewBuilder
private var loadableImage: some View {
if timelineItem.content.contentType == .gif {
LoadableImage(mediaSource: timelineItem.content.imageInfo.source,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
blurhash: timelineItem.content.blurhash,
size: timelineItem.content.imageInfo.size,
mediaProvider: context?.mediaProvider) {
placeholder
}
.mediaGalleryTimelineAspectRatio(imageInfo: timelineItem.content.imageInfo)
} else {
LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
blurhash: timelineItem.content.blurhash,
size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size,
mediaProvider: context?.mediaProvider) {
placeholder
}
.mediaGalleryTimelineAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
}
}
private var placeholder: some View {
Rectangle()
.foregroundColor(.compound.bgSubtleSecondary)
.opacity(0.3)
}
}
private extension View {
@ViewBuilder
func mediaGalleryTimelineAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
}
}
struct ImageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
.frame(width: 100, height: 100)
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.background(.black)
}
private static func makeTimelineItem() -> ImageRoomTimelineItem {
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(filename: "image.jpg",
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
contentType: .jpeg))
}
}
@@ -0,0 +1,32 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct SeparatorMediaEventsTimelineView: View {
let group: MediaEventsTimelineGroup
var body: some View {
Text(group.title)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.frame(alignment: .center)
.padding(.vertical, 16)
}
}
struct SeparatorMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Separator")),
timestamp: .mock)
SeparatorMediaEventsTimelineView(group: .init(id: item.id.uniqueID.id,
title: "Group",
items: []))
}
}
@@ -0,0 +1,78 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct VideoMediaEventsTimelineView: View {
@Environment(\.timelineContext) private var context
let timelineItem: VideoRoomTimelineItem
var body: some View {
thumbnail
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonVideo)
}
@ViewBuilder
var thumbnail: some View {
if let thumbnailSource = timelineItem.content.thumbnailInfo?.source {
LoadableImage(mediaSource: thumbnailSource,
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
blurhash: timelineItem.content.blurhash,
size: timelineItem.content.thumbnailInfo?.size,
mediaProvider: context?.mediaProvider) { imageView in
imageView
.overlay { playIcon }
} placeholder: {
placeholder
}
} else {
playIcon
}
}
var playIcon: some View {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
}
var placeholder: some View {
Rectangle()
.foregroundColor(.compound.bgSubtleSecondary)
.opacity(0.3)
}
}
struct VideoMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock
static var previews: some View {
VideoMediaEventsTimelineView(timelineItem: makeTimelineItem())
.frame(width: 100, height: 100)
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.background(.black)
}
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> VideoRoomTimelineItem {
VideoRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(filename: "video.mp4",
videoInfo: .mockVideo,
thumbnailInfo: .mockVideoThumbnail))
}
}