Skip to content

Commit

Permalink
Media gallery - support for files and voice messages (#3605)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
stefanceriu authored Dec 12, 2024
1 parent 606eb0a commit 114255c
Show file tree
Hide file tree
Showing 30 changed files with 374 additions and 112 deletions.
44 changes: 28 additions & 16 deletions ElementX.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ struct MediaEventsTimelineScreenViewState: BindableState {
var isBackPaginating = false
var groups = [MediaEventsTimelineGroup]()

var activeTimelineContextProvider: (() -> TimelineViewModel.Context)!

var bindings: MediaEventsTimelineScreenViewStateBindings
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
}
}
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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: []))
}
}
Loading

0 comments on commit 114255c

Please sign in to comment.