Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Media gallery - support for files and voice messages #3605

Merged
merged 4 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -29,4 +29,9 @@ extension View {
}
}
}

@ViewBuilder
func mediaGalleryTimelineAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
}
}
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,53 @@ 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(footer: SeparatorMediaEventsTimelineView(group: group)) {
stefanceriu marked this conversation as resolved.
Show resolved Hide resolved
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))
}
}
}
}
}
.scaleEffect(.init(width: -1, height: 1))
}

@ViewBuilder
private var filesContent: some View {
LazyVStack(alignment: .center, spacing: 16) {
ForEach(context.viewState.groups) { group in
Section(footer: SeparatorMediaEventsTimelineView(group: group)) {
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))
}
}
}
}
}
}
}

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 +126,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,81 @@
//
// 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._bgBubbleIncoming)
.opacity(0.3)
}
}

struct ImageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = TimelineViewModel.mock

static var previews: some View {
ScrollView {
VStack(spacing: 20.0) {
ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
}
}
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.fixed(width: 390, height: 1200))
stefanceriu marked this conversation as resolved.
Show resolved Hide resolved
}

private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> ImageRoomTimelineItem {
ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: "Bob"),
content: .init(filename: "image.jpg",
caption: caption,
imageInfo: .mockImage,
thumbnailInfo: .mockThumbnail,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
contentType: .jpeg),
properties: .init(isEdited: isEdited))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// 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)
// Couldn't figure out how to flip it where it's used instead.
.scaleEffect(.init(width: -1, height: -1))
}
}

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: []))
.scaleEffect(.init(width: -1, height: -1))
}
}
Loading
Loading