mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Custom media gallery views for files and voice messages (#3610)
* Extract the timeline item background from the BubbledStyler. * Use different views for files and voices messages, reuse the timeline content but only keep the bubble background instead of the whole TimelineStyler. * Add back max voice message width and add missing accessibility label
This commit is contained in:
+12
-11
@@ -95,16 +95,17 @@ struct MediaEventsTimelineScreen: View {
|
||||
ForEach(context.viewState.groups) { group in
|
||||
Section {
|
||||
ForEach(group.items) { item in
|
||||
viewForTimelineItem(item)
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
.onTapGesture {
|
||||
VStack(spacing: 20) {
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
} label: {
|
||||
viewForTimelineItem(item)
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
}
|
||||
.accessibilityActions {
|
||||
Button(L10n.actionShow) {
|
||||
context.send(viewAction: .tappedItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
} footer: {
|
||||
// Use a footer as the header because the scrollView is flipped
|
||||
@@ -143,13 +144,13 @@ struct MediaEventsTimelineScreen: View {
|
||||
case .video(let timelineItem):
|
||||
VideoMediaEventsTimelineView(timelineItem: timelineItem)
|
||||
case .file(let timelineItem):
|
||||
FileRoomTimelineView(timelineItem: timelineItem)
|
||||
FileMediaEventsTimelineView(timelineItem: timelineItem)
|
||||
case .audio(let timelineItem):
|
||||
AudioRoomTimelineView(timelineItem: timelineItem)
|
||||
AudioMediaEventsTimelineView(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)
|
||||
VoiceMessageMediaEventsTimelineView(timelineItem: timelineItem, playerState: playerState)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// Copyright 2023, 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 AudioMediaEventsTimelineView: View {
|
||||
let timelineItem: AudioRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
MediaFileRoomTimelineContent(timelineItemID: timelineItem.id,
|
||||
filename: timelineItem.content.filename,
|
||||
fileSize: timelineItem.content.fileSize,
|
||||
caption: timelineItem.content.caption,
|
||||
formattedCaption: timelineItem.content.formattedCaption,
|
||||
additionalWhitespaces: timelineItem.additionalWhitespaces(),
|
||||
isAudioFile: true)
|
||||
.accessibilityLabel(L10n.commonAudio)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.bubbleBackground(timelineItem: timelineItem,
|
||||
insets: .init(top: 8, leading: 12, bottom: 8, trailing: 12),
|
||||
color: .compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20) {
|
||||
AudioMediaEventsTimelineView(timelineItem: makeItem(filename: "audio.ogg",
|
||||
fileSize: 2 * 1024 * 1024))
|
||||
|
||||
AudioMediaEventsTimelineView(timelineItem: makeItem(filename: "Best Song Ever.mp3",
|
||||
fileSize: 7 * 1024 * 1024,
|
||||
caption: "This song rocks!"))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static func makeItem(filename: String, fileSize: UInt, caption: String? = nil) -> AudioRoomTimelineItem {
|
||||
.init(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: filename,
|
||||
caption: caption,
|
||||
duration: 300,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
fileSize: fileSize,
|
||||
contentType: nil))
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// 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 FileMediaEventsTimelineView: View {
|
||||
let timelineItem: FileRoomTimelineItem
|
||||
|
||||
var body: some View {
|
||||
MediaFileRoomTimelineContent(timelineItemID: timelineItem.id,
|
||||
filename: timelineItem.content.filename,
|
||||
fileSize: timelineItem.content.fileSize,
|
||||
caption: timelineItem.content.caption,
|
||||
formattedCaption: timelineItem.content.formattedCaption,
|
||||
additionalWhitespaces: timelineItem.additionalWhitespaces())
|
||||
.accessibilityLabel(L10n.commonFile)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.bubbleBackground(timelineItem: timelineItem,
|
||||
insets: .init(top: 8, leading: 12, bottom: 8, trailing: 12),
|
||||
color: .compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct FileMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
FileMediaEventsTimelineView(timelineItem: makeItem(filename: "document.pdf"))
|
||||
|
||||
FileMediaEventsTimelineView(timelineItem: makeItem(filename: "document.pdf",
|
||||
fileSize: 3 * 1024 * 1024))
|
||||
|
||||
FileMediaEventsTimelineView(timelineItem: makeItem(filename: "spreadsheet.xlsx",
|
||||
fileSize: 17 * 1024,
|
||||
caption: "The important figures you asked me to send over."))
|
||||
|
||||
FileMediaEventsTimelineView(timelineItem: makeItem(filename: "document.txt",
|
||||
fileSize: 456,
|
||||
caption: "Plain caption",
|
||||
formattedCaption: "Formatted caption"))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static func makeItem(filename: String,
|
||||
fileSize: UInt? = nil,
|
||||
caption: String? = nil,
|
||||
formattedCaption: AttributedString? = nil) -> FileRoomTimelineItem {
|
||||
.init(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: filename,
|
||||
caption: caption,
|
||||
formattedCaption: formattedCaption,
|
||||
source: nil,
|
||||
fileSize: fileSize,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil))
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// Copyright 2023, 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 VoiceMessageMediaEventsTimelineView: View {
|
||||
let timelineItem: VoiceMessageRoomTimelineItem
|
||||
let playerState: AudioPlayerState
|
||||
|
||||
var body: some View {
|
||||
VoiceMessageRoomTimelineContent(timelineItem: timelineItem,
|
||||
playerState: playerState)
|
||||
.accessibilityLabel(L10n.commonVoiceMessage)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.bubbleBackground(timelineItem: timelineItem,
|
||||
insets: .init(top: 8, leading: 12, bottom: 8, trailing: 12),
|
||||
color: .compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
struct VoiceMessageMediaEventsTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let timelineItemIdentifier = TimelineItemIdentifier.randomEvent
|
||||
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
isThreaded: false,
|
||||
sender: .init(id: "Bob"),
|
||||
content: .init(filename: "audio.ogg",
|
||||
duration: 300,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
fileSize: nil,
|
||||
contentType: nil))
|
||||
|
||||
static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier),
|
||||
title: L10n.commonVoiceMessage,
|
||||
duration: 10.0,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static var previews: some View {
|
||||
body
|
||||
.environmentObject(viewModel.context)
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
VoiceMessageMediaEventsTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func bubbleBackground(timelineItem: EventBasedTimelineItemProtocol,
|
||||
insets: EdgeInsets,
|
||||
color: Color? = nil) -> some View {
|
||||
modifier(TimelineItemBubbleBackgroundModifier(timelineItem: timelineItem,
|
||||
insets: insets,
|
||||
color: color))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineItemBubbleBackgroundModifier: ViewModifier {
|
||||
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
|
||||
|
||||
let timelineItem: EventBasedTimelineItemProtocol
|
||||
let insets: EdgeInsets
|
||||
var color: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(insets)
|
||||
.background(color)
|
||||
.cornerRadius(12, corners: roundedCorners)
|
||||
}
|
||||
|
||||
private var roundedCorners: UIRectCorner {
|
||||
switch timelineGroupStyle {
|
||||
case .single:
|
||||
return .allCorners
|
||||
case .first:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .topRight, .bottomLeft]
|
||||
} else {
|
||||
return [.topLeft, .topRight, .bottomRight]
|
||||
}
|
||||
case .middle:
|
||||
return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight]
|
||||
case .last:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .bottomLeft, .bottomRight]
|
||||
} else {
|
||||
return [.topRight, .bottomLeft, .bottomRight]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,9 +163,9 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
var messageBubble: some View {
|
||||
contentWithReply
|
||||
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, context: context)
|
||||
.bubbleStyle(insets: timelineItem.bubbleInsets,
|
||||
color: timelineItem.bubbleBackgroundColor,
|
||||
corners: roundedCorners)
|
||||
.bubbleBackground(timelineItem: timelineItem,
|
||||
insets: timelineItem.bubbleInsets,
|
||||
color: timelineItem.bubbleBackgroundColor)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -217,40 +217,11 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
timelineItem.isOutgoing ? .trailing : .leading
|
||||
}
|
||||
|
||||
private var roundedCorners: UIRectCorner {
|
||||
switch timelineGroupStyle {
|
||||
case .single:
|
||||
return .allCorners
|
||||
case .first:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .topRight, .bottomLeft]
|
||||
} else {
|
||||
return [.topLeft, .topRight, .bottomRight]
|
||||
}
|
||||
case .middle:
|
||||
return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight]
|
||||
case .last:
|
||||
if timelineItem.isOutgoing {
|
||||
return [.topLeft, .bottomLeft, .bottomRight]
|
||||
} else {
|
||||
return [.topRight, .bottomLeft, .bottomRight]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldShowSenderDetails: Bool {
|
||||
timelineGroupStyle.shouldShowSenderDetails
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func bubbleStyle(insets: EdgeInsets, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View {
|
||||
padding(insets)
|
||||
.background(color)
|
||||
.cornerRadius(cornerRadius, corners: corners)
|
||||
}
|
||||
}
|
||||
|
||||
private extension EventBasedTimelineItemProtocol {
|
||||
var bubbleBackgroundColor: Color? {
|
||||
let defaultColor: Color = isOutgoing ? .compound._bgBubbleOutgoing : .compound._bgBubbleIncoming
|
||||
|
||||
+21
-15
@@ -9,26 +9,33 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRoomTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
private let timelineItem: VoiceMessageRoomTimelineItem
|
||||
private let playerState: AudioPlayerState
|
||||
@State private var resumePlaybackAfterScrubbing = false
|
||||
|
||||
init(timelineItem: VoiceMessageRoomTimelineItem, playerState: AudioPlayerState) {
|
||||
self.timelineItem = timelineItem
|
||||
self.playerState = playerState
|
||||
}
|
||||
let timelineItem: VoiceMessageRoomTimelineItem
|
||||
let playerState: AudioPlayerState
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: { onPlaybackSeek($0) },
|
||||
onScrubbing: { onPlaybackScrubbing($0) })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VoiceMessageRoomTimelineContent(timelineItem: timelineItem,
|
||||
playerState: playerState)
|
||||
.accessibilityLabel(L10n.commonVoiceMessage)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRoomTimelineContent: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
@State private var resumePlaybackAfterScrubbing = false
|
||||
|
||||
let timelineItem: VoiceMessageRoomTimelineItem
|
||||
let playerState: AudioPlayerState
|
||||
|
||||
var body: some View {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: { onPlaybackSeek($0) },
|
||||
onScrubbing: { onPlaybackScrubbing($0) })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() {
|
||||
context?.send(viewAction: .handleAudioPlayerAction(.playPause(itemID: timelineItem.id)))
|
||||
@@ -78,7 +85,6 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static var previews: some View {
|
||||
body.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Bubble")
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
|
||||
Reference in New Issue
Block a user