mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Content scanning view for reply preview (#5842)
* 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 * implemented content scanning in the timeline reply view * pr suggestions
This commit is contained in:
@@ -43,6 +43,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
|
|
||||||
private let contentScannerService: ContentScannerServiceProtocol?
|
private let contentScannerService: ContentScannerServiceProtocol?
|
||||||
private let mediaSource: MediaSourceProxy?
|
private let mediaSource: MediaSourceProxy?
|
||||||
|
private let containerShowsFailure: Bool
|
||||||
private let safeContent: () -> SafeContent
|
private let safeContent: () -> SafeContent
|
||||||
private let scanningContent: () -> ScanningContent
|
private let scanningContent: () -> ScanningContent
|
||||||
private let unsafeContent: (ContentScanningFailure) -> UnsafeContent
|
private let unsafeContent: (ContentScanningFailure) -> UnsafeContent
|
||||||
@@ -54,16 +55,22 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
/// scanning and the content is always rendered.
|
/// scanning and the content is always rendered.
|
||||||
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
|
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
|
||||||
/// makes it convenient to wrap items whose media source is optional.
|
/// makes it convenient to wrap items whose media source is optional.
|
||||||
|
/// - containerShowsFailure: Whether a failure is reported up the view hierarchy through
|
||||||
|
/// ``ContentScanningFailurePreferenceKey`` so that the surrounding container (such as the
|
||||||
|
/// message bubble) adopts the critical styling. Pass `false` when the failure should only
|
||||||
|
/// affect this view, e.g. inside a reply preview where the bubble must stay regular.
|
||||||
/// - safeContent: The regular presentation of the media, including any interactions with it.
|
/// - safeContent: The regular presentation of the media, including any interactions with it.
|
||||||
/// - scanningContent: The presentation of the media whilst it's being scanned.
|
/// - scanningContent: The presentation of the media whilst it's being scanned.
|
||||||
/// - unsafeContent: The presentation of the media when it can't be shown.
|
/// - unsafeContent: The presentation of the media when it can't be shown.
|
||||||
init(contentScannerService: ContentScannerServiceProtocol?,
|
init(contentScannerService: ContentScannerServiceProtocol?,
|
||||||
mediaSource: MediaSourceProxy?,
|
mediaSource: MediaSourceProxy?,
|
||||||
|
containerShowsFailure: Bool = true,
|
||||||
@ViewBuilder safeContent: @escaping () -> SafeContent,
|
@ViewBuilder safeContent: @escaping () -> SafeContent,
|
||||||
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
|
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
|
||||||
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
|
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
|
||||||
self.contentScannerService = contentScannerService
|
self.contentScannerService = contentScannerService
|
||||||
self.mediaSource = mediaSource
|
self.mediaSource = mediaSource
|
||||||
|
self.containerShowsFailure = containerShowsFailure
|
||||||
self.safeContent = safeContent
|
self.safeContent = safeContent
|
||||||
self.scanningContent = scanningContent
|
self.scanningContent = scanningContent
|
||||||
self.unsafeContent = unsafeContent
|
self.unsafeContent = unsafeContent
|
||||||
@@ -71,7 +78,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.preference(key: ContentScanningFailurePreferenceKey.self, value: scanFailure)
|
.preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
|
||||||
.task(id: mediaSource?.url.absoluteString) {
|
.task(id: mediaSource?.url.absoluteString) {
|
||||||
await scan()
|
await scan()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,23 +15,51 @@ enum TimelineReplyViewPlacement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TimelineReplyView: View {
|
struct TimelineReplyView: View {
|
||||||
|
@Environment(\.timelineContext) private var timelineContext
|
||||||
|
|
||||||
let placement: TimelineReplyViewPlacement
|
let placement: TimelineReplyViewPlacement
|
||||||
let timelineItemReplyDetails: TimelineItemReplyDetails?
|
let timelineItemReplyDetails: TimelineItemReplyDetails?
|
||||||
var maxWidth: CGFloat?
|
var maxWidth: CGFloat?
|
||||||
|
|
||||||
private let backgroundShape = RoundedRectangle(cornerRadius: 8)
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
mediaSource: scannedMediaSource,
|
||||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
containerShowsFailure: false) {
|
||||||
.padding(4.0)
|
content
|
||||||
.background {
|
.roundedContainer(padding: 4,
|
||||||
ZStack {
|
maxWidth: maxWidth,
|
||||||
backgroundShape.fill(.compound.bgCanvasDefault)
|
backgroundColor: .compound.bgCanvasDefault,
|
||||||
backgroundShape.stroke(.compound.separatorPrimary)
|
borderColor: .compound.separatorPrimary)
|
||||||
}
|
} scanningContent: {
|
||||||
}
|
LoadingReplyView()
|
||||||
|
.roundedContainer(padding: 4,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
backgroundColor: .compound.bgCanvasDefault,
|
||||||
|
borderColor: .compound.separatorPrimary)
|
||||||
|
} unsafeContent: { failure in
|
||||||
|
ContentScanningFailureView(failure: failure)
|
||||||
|
.roundedContainer(padding: 8,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
backgroundColor: .compound.bgCriticalSubtle,
|
||||||
|
borderColor: .compound.borderCriticalSubtle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The media source validated by the content scanner when the replied to message contains media.
|
||||||
|
private var scannedMediaSource: MediaSourceProxy? {
|
||||||
|
guard case .loaded(_, _, let eventContent) = timelineItemReplyDetails,
|
||||||
|
case .message(let message) = eventContent else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch message {
|
||||||
|
case .audio(let content): return content.source
|
||||||
|
case .file(let content): return content.source
|
||||||
|
case .image(let content): return content.imageInfo.source
|
||||||
|
case .video(let content): return content.videoInfo.source
|
||||||
|
case .voice(let content): return content.source
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -189,8 +217,29 @@ struct TimelineReplyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
/// Styles the view as a rounded container with a background fill and a border.
|
||||||
|
func roundedContainer(padding: CGFloat = 12,
|
||||||
|
maxWidth: CGFloat? = nil,
|
||||||
|
backgroundColor: Color,
|
||||||
|
borderColor: Color) -> some View {
|
||||||
|
let backgroundShape = RoundedRectangle(cornerRadius: 8)
|
||||||
|
return fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||||
|
.padding(padding)
|
||||||
|
.background {
|
||||||
|
ZStack {
|
||||||
|
backgroundShape.fill(backgroundColor)
|
||||||
|
backgroundShape.stroke(borderColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
|
struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = TimelineViewModel.mock
|
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 attributedStringWithMention = {
|
static let attributedStringWithMention = {
|
||||||
var attributedString = AttributedString("To be replaced")
|
var attributedString = AttributedString("To be replaced")
|
||||||
@@ -350,5 +399,28 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
|
|||||||
.padding()
|
.padding()
|
||||||
.environmentObject(viewModel.context)
|
.environmentObject(viewModel.context)
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
imageReply
|
||||||
|
.environmentObject(scanningViewModel.context)
|
||||||
|
.environment(\.timelineContext, scanningViewModel.context)
|
||||||
|
|
||||||
|
imageReply
|
||||||
|
.environmentObject(unsafeViewModel.context)
|
||||||
|
.environment(\.timelineContext, unsafeViewModel.context)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("Content Scanner")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var imageReply: TimelineReplyView {
|
||||||
|
TimelineReplyView(placement: .timeline,
|
||||||
|
timelineItemReplyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||||
|
eventID: "123",
|
||||||
|
eventContent: .message(.image(.init(filename: "image.jpg",
|
||||||
|
caption: "Some image",
|
||||||
|
imageInfo: .mockImage,
|
||||||
|
thumbnailInfo: .mockThumbnail)))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,6 +479,46 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
|||||||
groupStyle: .single))
|
groupStyle: .single))
|
||||||
.environmentObject(unsafeViewModel.context)
|
.environmentObject(unsafeViewModel.context)
|
||||||
.environment(\.timelineContext, unsafeViewModel.context)
|
.environment(\.timelineContext, unsafeViewModel.context)
|
||||||
|
|
||||||
|
// A safe message replying to an unsafe image: only the reply preview shows the failure.
|
||||||
|
RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .randomEvent,
|
||||||
|
timestamp: .mock,
|
||||||
|
isOutgoing: true,
|
||||||
|
isEditable: false,
|
||||||
|
canBeRepliedTo: true,
|
||||||
|
sender: .init(id: "whoever"),
|
||||||
|
content: .init(body: "Replying to an unsafe image."),
|
||||||
|
properties: .init(replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"),
|
||||||
|
eventID: "123",
|
||||||
|
eventContent: .message(.image(.init(filename: "amazing.jpeg",
|
||||||
|
imageInfo: .mockImage,
|
||||||
|
thumbnailInfo: .mockThumbnail)))))),
|
||||||
|
groupStyle: .single))
|
||||||
|
.environmentObject(unsafeViewModel.context)
|
||||||
|
.environment(\.timelineContext, unsafeViewModel.context)
|
||||||
|
|
||||||
|
// An unsafe file replying to an unsafe image: the whole bubble and the reply preview show the failure.
|
||||||
|
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 to an unsafe image.",
|
||||||
|
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(.image(.init(filename: "amazing.jpeg",
|
||||||
|
imageInfo: .mockImage,
|
||||||
|
thumbnailInfo: .mockThumbnail)))))),
|
||||||
|
groupStyle: .single))
|
||||||
|
.environmentObject(unsafeViewModel.context)
|
||||||
|
.environment(\.timelineContext, unsafeViewModel.context)
|
||||||
}
|
}
|
||||||
.environmentObject(viewModel.context)
|
.environmentObject(viewModel.context)
|
||||||
.environment(\.timelineContext, viewModel.context)
|
.environment(\.timelineContext, viewModel.context)
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e39edab07bcc2e86378cd7b20ca03c57118ec04f5548a5cda729680e01ef3ee6
|
oid sha256:71c45fbeff45298f424939143b382268cd3353b7dbbafedb55f0f981c25fd07b
|
||||||
size 136868
|
size 202962
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f7cb8dc14692fa2817766fe8036a37b0a1d8e7a2d2fe45201cd25a2612e73a56
|
oid sha256:95a89caaf492918522847861d1d9d32d4e05b9fef7010a135fa845ffd7742880
|
||||||
size 145143
|
size 230860
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2a91badc1b4d5a0227464ddba3fb35e757a64365c9599e122697beb62f4361be
|
oid sha256:91358b8fdb5a052816eba91ab27d8129f8d0098d71519a6120d37e3ff9ea5692
|
||||||
size 93094
|
size 154421
|
||||||
|
|||||||
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3f583508ac5f41bb91a5000610b0419e3bb3315ef23c8262853fd887ba320678
|
oid sha256:d8e8c41a709a5fb7720a67a9f391372aceae7a018e1931bc5ff6855d75966829
|
||||||
size 103536
|
size 184733
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:10547353e92607e08c2fe9d60f4bea6a0a67869eaa2acde8e2ccde5e751c3d0f
|
||||||
|
size 29688
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e838d2b7eff4ce2103c6dc5fc879a18ea6cdfada662d6f6765f53ff27379462e
|
||||||
|
size 35457
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:98cfb7f8110291d2b710da446bc2fe45abb13350c1946da482f2e5a53baa7a33
|
||||||
|
size 19880
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c0af1641702a78c67524d4a9db5a7a607d166dc7829c55a52a9bd968ae278deb
|
||||||
|
size 28102
|
||||||
Reference in New Issue
Block a user