mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 20:39:24 +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 mediaSource: MediaSourceProxy?
|
||||
private let containerShowsFailure: Bool
|
||||
private let safeContent: () -> SafeContent
|
||||
private let scanningContent: () -> ScanningContent
|
||||
private let unsafeContent: (ContentScanningFailure) -> UnsafeContent
|
||||
@@ -54,16 +55,22 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
||||
/// scanning and the content is always rendered.
|
||||
/// - mediaSource: The media source to scan. Passing `nil` renders the content, this
|
||||
/// 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.
|
||||
/// - scanningContent: The presentation of the media whilst it's being scanned.
|
||||
/// - unsafeContent: The presentation of the media when it can't be shown.
|
||||
init(contentScannerService: ContentScannerServiceProtocol?,
|
||||
mediaSource: MediaSourceProxy?,
|
||||
containerShowsFailure: Bool = true,
|
||||
@ViewBuilder safeContent: @escaping () -> SafeContent,
|
||||
@ViewBuilder scanningContent: @escaping () -> ScanningContent,
|
||||
@ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) {
|
||||
self.contentScannerService = contentScannerService
|
||||
self.mediaSource = mediaSource
|
||||
self.containerShowsFailure = containerShowsFailure
|
||||
self.safeContent = safeContent
|
||||
self.scanningContent = scanningContent
|
||||
self.unsafeContent = unsafeContent
|
||||
@@ -71,7 +78,7 @@ struct ContentScanningView<SafeContent: View, ScanningContent: View, UnsafeConte
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.preference(key: ContentScanningFailurePreferenceKey.self, value: scanFailure)
|
||||
.preference(key: ContentScanningFailurePreferenceKey.self, value: containerShowsFailure ? scanFailure : nil)
|
||||
.task(id: mediaSource?.url.absoluteString) {
|
||||
await scan()
|
||||
}
|
||||
|
||||
@@ -15,23 +15,51 @@ enum TimelineReplyViewPlacement {
|
||||
}
|
||||
|
||||
struct TimelineReplyView: View {
|
||||
@Environment(\.timelineContext) private var timelineContext
|
||||
|
||||
let placement: TimelineReplyViewPlacement
|
||||
let timelineItemReplyDetails: TimelineItemReplyDetails?
|
||||
var maxWidth: CGFloat?
|
||||
|
||||
private let backgroundShape = RoundedRectangle(cornerRadius: 8)
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: maxWidth, alignment: .leading)
|
||||
.padding(4.0)
|
||||
.background {
|
||||
ZStack {
|
||||
backgroundShape.fill(.compound.bgCanvasDefault)
|
||||
backgroundShape.stroke(.compound.separatorPrimary)
|
||||
}
|
||||
}
|
||||
ContentScanningView(contentScannerService: timelineContext?.contentScannerService,
|
||||
mediaSource: scannedMediaSource,
|
||||
containerShowsFailure: false) {
|
||||
content
|
||||
.roundedContainer(padding: 4,
|
||||
maxWidth: maxWidth,
|
||||
backgroundColor: .compound.bgCanvasDefault,
|
||||
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
|
||||
@@ -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 {
|
||||
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 = {
|
||||
var attributedString = AttributedString("To be replaced")
|
||||
@@ -350,5 +399,28 @@ struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
|
||||
.padding()
|
||||
.environmentObject(viewModel.context)
|
||||
.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))
|
||||
.environmentObject(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)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
|
||||
Reference in New Issue
Block a user