mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 20:39:24 +00:00
Content Scanning View (#5831)
* implement a MediaView to delegate at the view level the usage of the content scanner service, to determine how to render the media. * fix for a small design mismatch * fix for greedy error state * updated naming * just making some screenshot that have been affected by a string change on Localazy * pr suggestions + updated strings
This commit is contained in:
@@ -1116,7 +1116,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
extension TimelineViewModel {
|
||||
static let mock = mock(timelineKind: .live)
|
||||
|
||||
static func mock(timelineKind: TimelineKind = .live, timelineController: TimelineControllerMock? = nil, hasPredecessor: Bool = false) -> TimelineViewModel {
|
||||
static func mock(timelineKind: TimelineKind = .live,
|
||||
timelineController: TimelineControllerMock? = nil,
|
||||
hasPredecessor: Bool = false,
|
||||
contentScannerService: ContentScannerServiceProtocol? = nil) -> TimelineViewModel {
|
||||
let clientProxyMock = ClientProxyMock(.init())
|
||||
clientProxyMock.roomSummaryForAliasReturnValue = .mock(id: "!room:matrix.org", name: "Room")
|
||||
clientProxyMock.roomSummaryForIdentifierReturnValue = .mock(id: "!room:matrix.org", name: "Room", canonicalAlias: "#room:matrix.org")
|
||||
@@ -1127,7 +1130,7 @@ extension TimelineViewModel {
|
||||
return TimelineViewModel(roomProxy: roomProxy,
|
||||
focussedEventID: nil,
|
||||
timelineController: timelineController ?? TimelineControllerMock(.init(timelineKind: timelineKind)),
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
|
||||
userSession: UserSessionMock(.init(clientProxy: clientProxyMock, contentScannerService: contentScannerService)),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock(.init()),
|
||||
|
||||
@@ -14,12 +14,15 @@ extension View {
|
||||
/// - isOutgoing: rounds the corners according to the side it shows on, defaults to true
|
||||
/// - insets: defaults to what we use for file timeline items, text uses custom values
|
||||
/// - color: self explanatory, defaults to subtle secondary
|
||||
/// - borderColor: an optional colour for a border around the bubble
|
||||
func bubbleBackground(isOutgoing: Bool = true,
|
||||
insets: EdgeInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12),
|
||||
color: @autoclosure @MainActor () -> Color? = .compound.bgSubtleSecondary) -> some View {
|
||||
color: @autoclosure @MainActor () -> Color? = .compound.bgSubtleSecondary,
|
||||
borderColor: @autoclosure @MainActor () -> Color? = nil) -> some View {
|
||||
modifier(TimelineItemBubbleBackgroundModifier(isOutgoing: isOutgoing,
|
||||
insets: insets,
|
||||
color: color()))
|
||||
color: color(),
|
||||
borderColor: borderColor()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +32,19 @@ private struct TimelineItemBubbleBackgroundModifier: ViewModifier {
|
||||
let isOutgoing: Bool
|
||||
let insets: EdgeInsets
|
||||
var color: Color?
|
||||
var borderColor: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(insets)
|
||||
.background(color)
|
||||
.cornerRadius(12, corners: roundedCorners)
|
||||
.overlay {
|
||||
if let borderColor {
|
||||
RoundedCornerShape(radius: 12, corners: roundedCorners)
|
||||
.stroke(borderColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var roundedCorners: UIRectCorner {
|
||||
|
||||
@@ -18,6 +18,14 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
/// Whether the item's media failed content scanning, in which case the whole bubble adopts
|
||||
/// the critical styling. Reported by the item's `ContentScanningView` through the preference key.
|
||||
@State private var contentScanningFailure: ContentScanningFailure?
|
||||
|
||||
private var hasContentScanningFailure: Bool {
|
||||
contentScanningFailure != nil
|
||||
}
|
||||
|
||||
private var isDM: Bool {
|
||||
context.viewState.isDM
|
||||
}
|
||||
@@ -78,6 +86,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
}
|
||||
.padding(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8))
|
||||
.highlightedTimelineItem(isFocussed)
|
||||
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -179,10 +188,14 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
|
||||
var messageBubble: some View {
|
||||
contentWithReply
|
||||
.timelineItemSendInfo(timelineItem: timelineItem, adjustedDeliveryStatus: adjustedDeliveryStatus, context: context)
|
||||
.timelineItemSendInfo(timelineItem: timelineItem,
|
||||
adjustedDeliveryStatus: adjustedDeliveryStatus,
|
||||
hasContentScanningFailure: hasContentScanningFailure,
|
||||
context: context)
|
||||
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
|
||||
insets: timelineItem.bubbleInsets,
|
||||
color: timelineItem.bubbleBackgroundColor)
|
||||
insets: timelineItem.bubbleInsets(hasContentScanningFailure: hasContentScanningFailure),
|
||||
color: hasContentScanningFailure ? .compound.bgCriticalSubtle : timelineItem.bubbleBackgroundColor,
|
||||
borderColor: hasContentScanningFailure ? .compound.borderCriticalSubtle : nil)
|
||||
}
|
||||
|
||||
var contentWithReply: some View {
|
||||
@@ -248,9 +261,14 @@ private extension EventBasedTimelineItemProtocol {
|
||||
|
||||
/// The insets for the full bubble content.
|
||||
/// Padding affecting just the "send info" should be added inside `TimelineItemSendInfoView`
|
||||
var bubbleInsets: EdgeInsets {
|
||||
func bubbleInsets(hasContentScanningFailure: Bool) -> EdgeInsets {
|
||||
let defaultInsets: EdgeInsets = .init(around: 8)
|
||||
|
||||
// The content scanner failure placeholder is always rendered inset within the critical bubble.
|
||||
if hasContentScanningFailure {
|
||||
return defaultInsets
|
||||
}
|
||||
|
||||
switch self {
|
||||
case is StickerRoomTimelineItem:
|
||||
return .zero
|
||||
@@ -370,6 +388,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
||||
}()
|
||||
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
mockTimeline
|
||||
.previewDisplayName("Mock Timeline")
|
||||
@@ -439,6 +459,26 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
content: .init(body: "Short message"),
|
||||
properties: properties),
|
||||
groupStyle: .single))
|
||||
|
||||
RoomTimelineItemView(viewState: .init(item: FileRoomTimelineItem(id: .randomEvent,
|
||||
timestamp: .mock,
|
||||
isOutgoing: true,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "whoever"),
|
||||
content: .init(filename: "unsafe.pdf",
|
||||
caption: "Replying with an unsafe file.",
|
||||
formattedCaption: nil,
|
||||
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
|
||||
fileSize: 3 * 1024 * 1024,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil),
|
||||
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Short")))))),
|
||||
groupStyle: .single))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
|
||||
@@ -13,9 +13,11 @@ extension View {
|
||||
/// Adds the send info (timestamp along indicators for edits and delivery/encryption issues) for the given timeline item to this view.
|
||||
func timelineItemSendInfo(timelineItem: EventBasedTimelineItemProtocol,
|
||||
adjustedDeliveryStatus: TimelineItemDeliveryStatus?,
|
||||
hasContentScanningFailure: Bool = false,
|
||||
context: TimelineViewModel.Context) -> some View {
|
||||
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
|
||||
adjustedDeliveryStatus: adjustedDeliveryStatus),
|
||||
adjustedDeliveryStatus: adjustedDeliveryStatus,
|
||||
hasContentScanningFailure: hasContentScanningFailure),
|
||||
context: context))
|
||||
}
|
||||
}
|
||||
@@ -148,7 +150,9 @@ private struct TimelineItemSendInfo {
|
||||
}
|
||||
|
||||
private extension TimelineItemSendInfo {
|
||||
init(timelineItem: EventBasedTimelineItemProtocol, adjustedDeliveryStatus: TimelineItemDeliveryStatus?) {
|
||||
init(timelineItem: EventBasedTimelineItemProtocol,
|
||||
adjustedDeliveryStatus: TimelineItemDeliveryStatus?,
|
||||
hasContentScanningFailure: Bool = false) {
|
||||
itemID = timelineItem.id
|
||||
localizedString = timelineItem.localizedSendInfo
|
||||
|
||||
@@ -162,29 +166,35 @@ private extension TimelineItemSendInfo {
|
||||
nil
|
||||
}
|
||||
|
||||
layoutType = switch timelineItem {
|
||||
case is TextBasedRoomTimelineItem:
|
||||
.overlay(capsuleStyle: false)
|
||||
case let liveLocationTimelineItem as LiveLocationRoomTimelineItem:
|
||||
liveLocationTimelineItem.layout
|
||||
case let message as EventBasedMessageTimelineItemProtocol:
|
||||
switch message {
|
||||
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
|
||||
.overlay(capsuleStyle: !message.hasMediaCaption)
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
// swiftlint:disable:next void_function_in_ternary
|
||||
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .horizontal(spacing: 0) // No spacing as the content already contains it.
|
||||
case let locationTimelineItem as LocationRoomTimelineItem:
|
||||
.overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
|
||||
layoutType = if hasContentScanningFailure {
|
||||
// The content scanner failure placeholder replaces the media,
|
||||
// so the send info is laid out like it is for a text message.
|
||||
timelineItem.hasMediaCaption ? .overlay(capsuleStyle: false) : .horizontal()
|
||||
} else {
|
||||
switch timelineItem {
|
||||
case is TextBasedRoomTimelineItem:
|
||||
.overlay(capsuleStyle: false)
|
||||
case let liveLocationTimelineItem as LiveLocationRoomTimelineItem:
|
||||
liveLocationTimelineItem.layout
|
||||
case let message as EventBasedMessageTimelineItemProtocol:
|
||||
switch message {
|
||||
case is ImageRoomTimelineItem, is VideoRoomTimelineItem:
|
||||
.overlay(capsuleStyle: !message.hasMediaCaption)
|
||||
case is AudioRoomTimelineItem, is FileRoomTimelineItem:
|
||||
// swiftlint:disable:next void_function_in_ternary
|
||||
message.hasMediaCaption ? .overlay(capsuleStyle: false) : .horizontal(spacing: 0) // No spacing as the content already contains it.
|
||||
case let locationTimelineItem as LocationRoomTimelineItem:
|
||||
.overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil)
|
||||
default:
|
||||
.horizontal()
|
||||
}
|
||||
case is StickerRoomTimelineItem:
|
||||
.overlay(capsuleStyle: true)
|
||||
case is PollRoomTimelineItem:
|
||||
.vertical(spacing: 16)
|
||||
default:
|
||||
.horizontal()
|
||||
}
|
||||
case is StickerRoomTimelineItem:
|
||||
.overlay(capsuleStyle: true)
|
||||
case is PollRoomTimelineItem:
|
||||
.vertical(spacing: 16)
|
||||
default:
|
||||
.horizontal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ struct TimelineReadReceiptsView: View {
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityHint(L10n.a11yReadReceiptsTapToShowAll)
|
||||
.accessibilityHint(L10n.a11yReadReceiptsTapToShowAllIos)
|
||||
}
|
||||
|
||||
private var remaining: Int {
|
||||
|
||||
+22
-2
@@ -21,7 +21,9 @@ struct AudioRoomTimelineView: View {
|
||||
formattedCaption: timelineItem.content.formattedCaption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
shouldBoost: timelineItem.shouldBoost,
|
||||
isAudioFile: true) {
|
||||
isAudioFile: true,
|
||||
contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.content.source) {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
.accessibilityLabel(L10n.commonAudio)
|
||||
@@ -31,6 +33,8 @@ struct AudioRoomTimelineView: View {
|
||||
|
||||
struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20) {
|
||||
@@ -42,6 +46,22 @@ struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
caption: "This song rocks!"))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
AudioRoomTimelineView(timelineItem: makeItem(filename: "scanning.ogg",
|
||||
fileSize: 2 * 1024 * 1024,
|
||||
caption: "The audio is being scanned."))
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
|
||||
AudioRoomTimelineView(timelineItem: makeItem(filename: "unsafe.ogg",
|
||||
fileSize: 2 * 1024 * 1024,
|
||||
caption: "The audio is not safe."))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
static func makeItem(filename: String, fileSize: UInt, caption: String? = nil) -> AudioRoomTimelineItem {
|
||||
@@ -55,7 +75,7 @@ struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
caption: caption,
|
||||
duration: 300,
|
||||
waveform: nil,
|
||||
source: nil,
|
||||
source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil),
|
||||
fileSize: fileSize,
|
||||
contentType: nil))
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// Copyright 2026 Element Creations 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
|
||||
|
||||
/// The placeholder shown in place of a media item when the content scanner wasn't able to
|
||||
/// validate it. Preview and download of the content are disabled.
|
||||
///
|
||||
/// This view only renders the failure title and message - the critical background is applied
|
||||
/// to the whole message bubble by `TimelineItemBubbledStylerView`, so that any caption and
|
||||
/// reply preview are included in the critical area too.
|
||||
struct ContentScanningFailureView: View {
|
||||
let failure: ContentScanningFailure
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
CompoundIcon(\.error, size: .medium, relativeTo: .compound.bodyMDSemibold)
|
||||
.foregroundStyle(.compound.iconCriticalPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.foregroundStyle(.compound.textCriticalPrimary)
|
||||
|
||||
Text(message)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch failure {
|
||||
case .notSafe: L10n.contentScannerUnsafeTitle
|
||||
case .notFound: L10n.contentScannerNotFoundTitle
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch failure {
|
||||
case .notSafe: L10n.contentScannerUnsafeMessage
|
||||
case .notFound: L10n.contentScannerNotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct ContentScanningFailureView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 16) {
|
||||
ContentScanningFailureView(failure: .notSafe)
|
||||
ContentScanningFailureView(failure: .notFound)
|
||||
}
|
||||
.frame(width: 260)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
+48
-15
@@ -20,7 +20,9 @@ struct FileRoomTimelineView: View {
|
||||
caption: timelineItem.content.caption,
|
||||
formattedCaption: timelineItem.content.formattedCaption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
shouldBoost: timelineItem.shouldBoost) {
|
||||
shouldBoost: timelineItem.shouldBoost,
|
||||
contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.content.source) {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
.accessibilityLabel(L10n.commonFile)
|
||||
@@ -38,6 +40,8 @@ struct MediaFileRoomTimelineContent: View {
|
||||
var trailingReservedSize: CGSize = .zero
|
||||
var shouldBoost = false
|
||||
var isAudioFile = false
|
||||
var contentScannerService: ContentScannerServiceProtocol?
|
||||
var mediaSource: MediaSourceProxy?
|
||||
|
||||
private var fileDescription: String {
|
||||
var fileDescription = "\(filename.validatedFileExtension.uppercased())"
|
||||
@@ -55,13 +59,18 @@ struct MediaFileRoomTimelineContent: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let onMediaTap {
|
||||
filePreview
|
||||
.onTapGesture {
|
||||
onMediaTap()
|
||||
}
|
||||
} else {
|
||||
filePreview
|
||||
ContentScanningView(contentScannerService: contentScannerService,
|
||||
mediaSource: mediaSource) {
|
||||
if let onMediaTap {
|
||||
filePreview(isScanning: false)
|
||||
.onTapGesture(perform: onMediaTap)
|
||||
} else {
|
||||
filePreview(isScanning: false)
|
||||
}
|
||||
} scanningContent: {
|
||||
filePreview(isScanning: true)
|
||||
} unsafeContent: { failure in
|
||||
ContentScanningFailureView(failure: failure)
|
||||
}
|
||||
|
||||
if let formattedCaption {
|
||||
@@ -76,7 +85,7 @@ struct MediaFileRoomTimelineContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
var filePreview: some View {
|
||||
func filePreview(isScanning: Bool) -> some View {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(filename)
|
||||
@@ -90,11 +99,18 @@ struct MediaFileRoomTimelineContent: View {
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.lineLimit(2)
|
||||
} icon: {
|
||||
CompoundIcon(icon, size: .medium, relativeTo: .body)
|
||||
.foregroundColor(.compound.iconPrimary)
|
||||
.scaledPadding(6)
|
||||
.background(.compound.iconOnSolidPrimary,
|
||||
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
Group {
|
||||
if isScanning {
|
||||
ProgressView()
|
||||
.scaledFrame(size: CompoundIcon.Size.medium.value, relativeTo: .body)
|
||||
} else {
|
||||
CompoundIcon(icon, size: .medium, relativeTo: .body)
|
||||
.foregroundColor(.compound.iconPrimary)
|
||||
}
|
||||
}
|
||||
.scaledPadding(6)
|
||||
.background(.compound.iconOnSolidPrimary,
|
||||
in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
}
|
||||
.labelStyle(.custom(spacing: 8, alignment: .center))
|
||||
.padding(.horizontal, 4) // Add to the styler's padding of 8, as we use the default insets for the caption.
|
||||
@@ -105,6 +121,8 @@ struct MediaFileRoomTimelineContent: View {
|
||||
|
||||
struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
@@ -126,6 +144,21 @@ struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
formattedCaption: "Formatted caption"))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
|
||||
VStack(spacing: 20.0) {
|
||||
FileRoomTimelineView(timelineItem: makeItem(filename: "scanning.pdf",
|
||||
fileSize: 3 * 1024 * 1024,
|
||||
caption: "The file is being scanned."))
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
|
||||
FileRoomTimelineView(timelineItem: makeItem(filename: "unsafe.pdf",
|
||||
fileSize: 3 * 1024 * 1024,
|
||||
caption: "The file is not safe."))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
static func makeItem(filename: String,
|
||||
@@ -141,7 +174,7 @@ struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
content: .init(filename: filename,
|
||||
caption: caption,
|
||||
formattedCaption: formattedCaption,
|
||||
source: nil,
|
||||
source: try? MediaSourceProxy(url: .mockMXCFile, mimeType: nil),
|
||||
fileSize: fileSize,
|
||||
thumbnailSource: nil,
|
||||
contentType: nil))
|
||||
|
||||
+50
-19
@@ -13,33 +13,51 @@ struct ImageRoomTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
let timelineItem: ImageRoomTimelineItem
|
||||
|
||||
@State private var contentScanningFailure: ContentScanningFailure?
|
||||
|
||||
var hasMediaCaption: Bool {
|
||||
timelineItem.content.caption != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
loadableImage
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonImage)
|
||||
// This clip shape is distinct from the one in the styler as that one
|
||||
// operates on the entire message so wouldn't round the bottom corners.
|
||||
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
|
||||
if let attributedCaption = timelineItem.content.formattedCaption {
|
||||
FormattedBodyText(attributedString: attributedCaption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
} else if let caption = timelineItem.content.caption {
|
||||
FormattedBodyText(text: caption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
||||
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.content.imageInfo.source) {
|
||||
loadableImage
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonImage)
|
||||
// This clip shape is distinct from the one in the styler as that one
|
||||
// operates on the entire message so wouldn't round the bottom corners.
|
||||
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
} scanningContent: {
|
||||
placeholder
|
||||
.overlay { ProgressView() }
|
||||
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
|
||||
} unsafeContent: { failure in
|
||||
ContentScanningFailureView(failure: failure)
|
||||
}
|
||||
|
||||
caption
|
||||
}
|
||||
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var caption: some View {
|
||||
if let attributedCaption = timelineItem.content.formattedCaption {
|
||||
FormattedBodyText(attributedString: attributedCaption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
} else if let caption = timelineItem.content.caption {
|
||||
FormattedBodyText(text: caption,
|
||||
trailingReservedSize: timelineItem.trailingReservedSize,
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +93,8 @@ struct ImageRoomTimelineView: View {
|
||||
|
||||
struct ImageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
ScrollView {
|
||||
@@ -93,6 +113,17 @@ struct ImageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
.previewLayout(.fixed(width: 390, height: 1200))
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(spacing: 20.0) {
|
||||
ImageRoomTimelineView(timelineItem: makeTimelineItem())
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
ImageRoomTimelineView(timelineItem: makeTimelineItem(caption: "This is an unsafe image."))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> ImageRoomTimelineItem {
|
||||
|
||||
+49
-28
@@ -15,18 +15,27 @@ struct StickerRoomTimelineView: View {
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
LoadableImage(mediaSource: timelineItem.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID),
|
||||
blurhash: timelineItem.blurhash,
|
||||
size: timelineItem.imageInfo.size,
|
||||
mediaProvider: context?.mediaProvider) {
|
||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.imageInfo.source) {
|
||||
LoadableImage(mediaSource: timelineItem.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID),
|
||||
blurhash: timelineItem.blurhash,
|
||||
size: timelineItem.imageInfo.size,
|
||||
mediaProvider: context?.mediaProvider) {
|
||||
placeholder
|
||||
}
|
||||
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
} scanningContent: {
|
||||
placeholder
|
||||
}
|
||||
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(L10n.commonSticker), \(timelineItem.body)")
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
.overlay { ProgressView() }
|
||||
.timelineMediaFrame(imageInfo: timelineItem.imageInfo)
|
||||
} unsafeContent: { failure in
|
||||
ContentScanningFailureView(failure: failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,29 +49,41 @@ struct StickerRoomTimelineView: View {
|
||||
|
||||
struct StickerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20.0) {
|
||||
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .randomEvent,
|
||||
body: "Some image",
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
imageInfo: .mockImage))
|
||||
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Some image"))
|
||||
|
||||
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: .randomEvent,
|
||||
body: "Blurhashed image",
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
imageInfo: .mockImage,
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
|
||||
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Blurhashed image",
|
||||
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
|
||||
VStack(spacing: 20.0) {
|
||||
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Scanning image"))
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
|
||||
StickerRoomTimelineView(timelineItem: makeTimelineItem(body: "Unsafe image"))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
private static func makeTimelineItem(body: String, blurhash: String? = nil) -> StickerRoomTimelineItem {
|
||||
StickerRoomTimelineItem(id: .randomEvent,
|
||||
body: body,
|
||||
timestamp: .mock,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "Bob"),
|
||||
imageInfo: .mockImage,
|
||||
blurhash: blurhash)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-11
@@ -13,23 +13,35 @@ struct VideoRoomTimelineView: View {
|
||||
@Environment(\.timelineContext) private var context
|
||||
let timelineItem: VideoRoomTimelineItem
|
||||
|
||||
@State private var contentScanningFailure: ContentScanningFailure?
|
||||
|
||||
private var hasMediaCaption: Bool {
|
||||
timelineItem.content.caption != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineStyler(timelineItem: timelineItem) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
thumbnail
|
||||
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonVideo)
|
||||
// This clip shape is distinct from the one in the styler as that one
|
||||
// operates on the entire message so wouldn't round the bottom corners.
|
||||
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
// The caption sits 8pts below the content scanner failure placeholder, 4pts below the media.
|
||||
VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) {
|
||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.content.videoInfo.source) {
|
||||
thumbnail
|
||||
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(L10n.commonVideo)
|
||||
// This clip shape is distinct from the one in the styler as that one
|
||||
// operates on the entire message so wouldn't round the bottom corners.
|
||||
.clipShape(RoundedRectangle(cornerRadius: hasMediaCaption ? 6 : 0))
|
||||
.onTapGesture {
|
||||
context?.send(viewAction: .mediaTapped(itemID: timelineItem.id))
|
||||
}
|
||||
} scanningContent: {
|
||||
placeholder
|
||||
.overlay { ProgressView() }
|
||||
.timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo)
|
||||
} unsafeContent: { failure in
|
||||
ContentScanningFailureView(failure: failure)
|
||||
}
|
||||
|
||||
if let attributedCaption = timelineItem.content.formattedCaption {
|
||||
FormattedBodyText(attributedString: attributedCaption,
|
||||
@@ -41,6 +53,7 @@ struct VideoRoomTimelineView: View {
|
||||
boostFontSize: timelineItem.shouldBoost)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +96,8 @@ struct VideoRoomTimelineView: View {
|
||||
|
||||
struct VideoRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
|
||||
static var previews: some View {
|
||||
ScrollView {
|
||||
@@ -101,6 +116,17 @@ struct VideoRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
.previewLayout(.fixed(width: 390, height: 975))
|
||||
.padding(.bottom, 20)
|
||||
|
||||
VStack(spacing: 20.0) {
|
||||
VideoRoomTimelineView(timelineItem: makeTimelineItem())
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
VideoRoomTimelineView(timelineItem: makeTimelineItem(caption: "This is an unsafe video."))
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
private static func makeTimelineItem(caption: String? = nil, isEdited: Bool = false) -> VideoRoomTimelineItem {
|
||||
|
||||
+35
-7
@@ -30,12 +30,25 @@ struct VoiceMessageRoomTimelineContent: View {
|
||||
let playerState: AudioPlayerState
|
||||
|
||||
var body: some View {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: { onPlaybackSeek($0) },
|
||||
onScrubbing: { onPlaybackScrubbing($0) },
|
||||
onPlaybackSpeedChange: onPlaybackSpeedChange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ContentScanningView(contentScannerService: context?.contentScannerService,
|
||||
mediaSource: timelineItem.content.source) {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
onPlayPause: onPlaybackPlayPause,
|
||||
onSeek: { onPlaybackSeek($0) },
|
||||
onScrubbing: { onPlaybackScrubbing($0) },
|
||||
onPlaybackSpeedChange: onPlaybackSpeedChange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} scanningContent: {
|
||||
VoiceMessageRoomPlaybackView(playerState: playerState,
|
||||
isScanning: true,
|
||||
onPlayPause: { },
|
||||
onSeek: { _ in },
|
||||
onScrubbing: { _ in },
|
||||
onPlaybackSpeedChange: { })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} unsafeContent: { failure in
|
||||
ContentScanningFailureView(failure: failure)
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlaybackPlayPause() {
|
||||
@@ -67,6 +80,8 @@ struct VoiceMessageRoomTimelineContent: View {
|
||||
|
||||
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let scanningViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: nil)))
|
||||
static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false)))
|
||||
static let timelineItemIdentifier = TimelineItemIdentifier.randomEvent
|
||||
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
|
||||
timestamp: .mock,
|
||||
@@ -77,7 +92,7 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
content: .init(filename: "audio.ogg",
|
||||
duration: 300,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
source: nil,
|
||||
source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil),
|
||||
fileSize: nil,
|
||||
contentType: nil))
|
||||
|
||||
@@ -89,6 +104,19 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
|
||||
static var previews: some View {
|
||||
body.environmentObject(viewModel.context)
|
||||
|
||||
VStack(spacing: 20) {
|
||||
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
|
||||
.environmentObject(scanningViewModel.context)
|
||||
.environment(\.timelineContext, scanningViewModel.context)
|
||||
|
||||
VoiceMessageRoomTimelineView(timelineItem: voiceRoomTimelineItem, playerState: playerState)
|
||||
.environmentObject(unsafeViewModel.context)
|
||||
.environment(\.timelineContext, unsafeViewModel.context)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Content Scanner")
|
||||
}
|
||||
|
||||
static var body: some View {
|
||||
|
||||
@@ -16,6 +16,10 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
@ScaledMetric private var waveformLinePadding = 2.0
|
||||
@GestureState var isDragging = false
|
||||
|
||||
/// Whether the voice message is being scanned by the content scanner, in which case
|
||||
/// the play button shows a spinner and playback isn't available (which prevents the audio file from being downloaded).
|
||||
var isScanning = false
|
||||
|
||||
let onPlayPause: () -> Void
|
||||
let onSeek: (Double) -> Void
|
||||
let onScrubbing: (Bool) -> Void
|
||||
@@ -23,7 +27,7 @@ struct VoiceMessageRoomPlaybackView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState),
|
||||
VoiceMessageButton(state: isScanning ? .loading : .init(playerState.playerButtonPlaybackState),
|
||||
size: .medium,
|
||||
action: onPlayPause)
|
||||
VStack(spacing: 2) {
|
||||
|
||||
Reference in New Issue
Block a user