Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,10 @@ extension AccessibilityTests {
try await performAccessibilityAudit(named: "SFNumberedListView_Previews")
}

func testScanStateMediaEventsTimelineViews() async throws {
try await performAccessibilityAudit(named: "ScanStateMediaEventsTimelineViews_Previews")
}

func testSearchScreen() async throws {
try await performAccessibilityAudit(named: "SearchScreen_Previews")
}
Expand Down
4 changes: 4 additions & 0 deletions ElementX.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@
7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */; };
743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; };
748F482FEF4E04D61C39AAD7 /* EmojiPickerScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F174A5627CDB3CAF280D1880 /* EmojiPickerScreenModels.swift */; };
74998B11397D89DD6C24AE33 /* ScanStateMediaEventsTimelineViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */; };
74DF5BC17DE9F51E077FD457 /* LinkNewDeviceServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA257D747DD7E6FFA5C2BE2D /* LinkNewDeviceServiceMock.swift */; };
7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B987FC3FDBAA0E1C5AA235C /* PaginationIndicatorRoomTimelineItem.swift */; };
75072C529C9C363F531721F1 /* RecoveryKeyScreenHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE97AD18893E2D8120F559D /* RecoveryKeyScreenHook.swift */; };
Expand Down Expand Up @@ -1907,6 +1908,7 @@
28EA8BE9EEDBD17555141C7E /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = el; path = el.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
2910422CB628D3B2BBE47449 /* SeparatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorRoomTimelineView.swift; sourceTree = "<group>"; };
292EEE1F71DCC205C45728F7 /* ReportRoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportRoomScreenCoordinator.swift; sourceTree = "<group>"; };
29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStateMediaEventsTimelineViews.swift; sourceTree = "<group>"; };
29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = "<group>"; };
2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = "<group>"; };
2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4571,6 +4573,7 @@
FC9044BE0E4A66F5B963E834 /* AudioFileEventsTimelineView.swift */,
430C73079A84654BF46A7FF5 /* FileMediaEventsTimelineView.swift */,
1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */,
29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */,
13F354AD441E2FD83DED89AF /* SeparatorMediaEventsTimelineView.swift */,
2B1FB56520A847DD2532D5C8 /* VideoMediaEventsTimelineView.swift */,
AD3521DF92391551E02004D8 /* VoiceMessageMediaEventsTimelineView.swift */,
Expand Down Expand Up @@ -8975,6 +8978,7 @@
82434593648CB74121F1A821 /* RowDivider.swift in Sources */,
3F55721B5C08E8D9F1295592 /* SDKListener.swift in Sources */,
88CBF1595E39CE697928DE48 /* SFNumberedListView.swift in Sources */,
74998B11397D89DD6C24AE33 /* ScanStateMediaEventsTimelineViews.swift in Sources */,
FB595EC9C00AB32F39034055 /* SceneDelegate.swift in Sources */,
0437765FF480249486893CC7 /* ScreenTrackerViewModifier.swift in Sources */,
0BFA67AFD757EE2BA569836A /* ScrollViewAdapter.swift in Sources */,
Expand Down
8 changes: 8 additions & 0 deletions ElementX/Sources/Other/Extensions/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ nonisolated extension URL {
nonisolated static var mockMXCUserAvatar: URL {
"mxc://matrix.org/1234567890AvAtArUsEr"
}

nonisolated static var mockMXCScanning: URL {
"mxc://matrix.org/1234567890ScAnNiNg"
}

nonisolated static var mockMXCUnsafe: URL {
"mxc://matrix.org/1234567890UnSaFe"
}
}

// MARK: - Helpers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ enum TestablePreviewsDictionary {
"RoomSelectionScreen_Previews" : RoomSelectionScreen_Previews.self,
"RoomThreadListScreen_Previews" : RoomThreadListScreen_Previews.self,
"SFNumberedListView_Previews" : SFNumberedListView_Previews.self,
"ScanStateMediaEventsTimelineViews_Previews" : ScanStateMediaEventsTimelineViews_Previews.self,
"SearchScreen_Previews" : SearchScreen_Previews.self,
"SecureBackupKeyBackupScreen_Previews" : SecureBackupKeyBackupScreen_Previews.self,
"SecurityAndPrivacyScreen_Previews" : SecurityAndPrivacyScreen_Previews.self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,25 +293,25 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {

private static func makeViewModel(empty: Bool = false,
screenMode: MediaEventsTimelineScreenMode) -> MediaEventsTimelineScreenViewModel {
MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: makeTimelineViewModel(empty: empty),
filesTimelineViewModel: makeTimelineViewModel(empty: empty),
MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .media),
filesTimelineViewModel: makeTimelineViewModel(empty: empty, screenMode: .files),
initialScreenMode: screenMode,
mediaProvider: MediaProviderMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock())
}

private static func makeTimelineViewModel(empty: Bool) -> TimelineViewModel {
private static func makeTimelineViewModel(empty: Bool, screenMode: MediaEventsTimelineScreenMode) -> TimelineViewModel {
let timelineController = if empty {
TimelineControllerMock.emptyMediaGallery
} else {
TimelineControllerMock.mediaGallery
makeTimelineController(screenMode: screenMode)
}

let appSettings = AppSettings.volatile()
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
timelineController: timelineController,
userSession: UserSessionMock(.init()),
userSession: UserSessionMock(.init(contentScannerService: contentScannerService)),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock(.init()),
Expand All @@ -321,4 +321,84 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
linkMetadataProvider: LinkMetadataProvider(),
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
}

// MARK: Content scanning

/// A content scanner that reports the dedicated mock sources as being scanned/unsafe and everything else as safe.
private static let contentScannerService = {
let contentScannerService = ContentScannerServiceMock()
contentScannerService.scanResultFromSourceClosure = { source in
switch source.url {
case .mockMXCScanning: nil
case .mockMXCUnsafe: false
default: true
}
}
contentScannerService.loadScanResultFromSourceClosure = { source in
switch source.url {
case .mockMXCScanning:
// Never resolve so that the scanning state remains visible.
try? await Task.sleep(for: .seconds(3600))
return .failure(.failedScanning)
case .mockMXCUnsafe:
return .success(false)
default:
return .success(true)
}
}
return contentScannerService
}()

/// The regular gallery items followed by one that is being scanned and an unsafe one.
private static func makeTimelineController(screenMode: MediaEventsTimelineScreenMode) -> TimelineControllerMock {
var timelineItems: [RoomTimelineItemProtocol] = (0..<5).reduce([]) { partialResult, _ in
partialResult + [TimelineFixtures.separator] + TimelineFixtures.mediaChunk
}

switch screenMode {
case .media:
timelineItems.append(contentsOf: [makeImageItem(url: .mockMXCScanning), makeImageItem(url: .mockMXCUnsafe)])
case .files:
timelineItems.append(contentsOf: [makeFileItem(url: .mockMXCScanning), makeFileItem(url: .mockMXCUnsafe)])
}

return TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: timelineItems))
}

private static func makeImageItem(url: URL) -> ImageRoomTimelineItem {
guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpg") else {
fatalError("Invalid mock media source URL")
}

return ImageRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org"),
content: .init(filename: "image.jpg",
imageInfo: .init(source: mediaSource, width: 2730, height: 2048, mimeType: "image/jpg", fileSize: nil),
thumbnailInfo: nil,
blurhash: "KpE4oyayR5|GbHb];3j@of"))
}

private static func makeFileItem(url: URL) -> FileRoomTimelineItem {
guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: nil) else {
fatalError("Invalid mock media source URL")
}

return FileRoomTimelineItem(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
sender: .init(id: "@bob:matrix.org"),
content: .init(filename: "important-document.pdf",
caption: nil,
formattedCaption: nil,
source: mediaSource,
fileSize: 3 * 1024 * 1024,
thumbnailSource: nil,
contentType: nil))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,29 @@ import Compound
import SwiftUI

struct AudioMediaEventsTimelineView: View {
@Environment(\.timelineContext) private var context

let timelineItem: AudioRoomTimelineItem

/// Whether the item's media failed content scanning, in which case the bubble adopts
/// the critical styling. Reported by the `ContentScanningView` through the preference key.
@State private var contentScanningFailure: ContentScanningFailure?

var body: some View {
MediaFileRoomTimelineContent(filename: timelineItem.content.filename,
fileSize: timelineItem.content.fileSize,
caption: timelineItem.content.caption,
formattedCaption: timelineItem.content.formattedCaption,
trailingReservedSize: timelineItem.trailingReservedSize,
isAudioFile: true)
isAudioFile: true,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source)
.accessibilityLabel(L10n.commonAudio)
.frame(maxWidth: .infinity, alignment: .leading)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
color: contentScanningFailure == nil ? .compound.bgSubtleSecondary : .compound.bgCriticalSubtle,
borderColor: contentScanningFailure == nil ? nil : .compound.borderCriticalSubtle)
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,28 @@ import Compound
import SwiftUI

struct FileMediaEventsTimelineView: View {
@Environment(\.timelineContext) private var context

let timelineItem: FileRoomTimelineItem

/// Whether the item's media failed content scanning, in which case the bubble adopts
/// the critical styling. Reported by the `ContentScanningView` through the preference key.
@State private var contentScanningFailure: ContentScanningFailure?

var body: some View {
MediaFileRoomTimelineContent(filename: timelineItem.content.filename,
fileSize: timelineItem.content.fileSize,
caption: timelineItem.content.caption,
formattedCaption: timelineItem.content.formattedCaption,
trailingReservedSize: timelineItem.trailingReservedSize)
trailingReservedSize: timelineItem.trailingReservedSize,
contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.source)
.accessibilityLabel(L10n.commonFile)
.frame(maxWidth: .infinity, alignment: .leading)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing)
.bubbleBackground(isOutgoing: timelineItem.isOutgoing,
color: contentScanningFailure == nil ? .compound.bgSubtleSecondary : .compound.bgCriticalSubtle,
borderColor: contentScanningFailure == nil ? nil : .compound.borderCriticalSubtle)
.onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ struct ImageMediaEventsTimelineView: View {
let timelineItem: ImageRoomTimelineItem

var body: some View {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
loadableImage
}
.clipped()
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
ContentScanningView(contentScannerService: context?.contentScannerService,
mediaSource: timelineItem.content.imageInfo.source) {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
loadableImage
}
.clipped()
.accessibilityElement(children: .ignore)
.accessibilityLabel(L10n.commonImage)
} scanningContent: {
ScanningMediaEventsTimelineView()
} unsafeContent: { failure in
UnsafeMediaEventsTimelineView(failure: failure)
}
}

@ViewBuilder
Expand Down Expand Up @@ -57,13 +64,30 @@ struct ImageMediaEventsTimelineView: View {
struct ImageMediaEventsTimelineView_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 {
ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
.frame(width: 100, height: 100)
.environmentObject(viewModel.context)
.environment(\.timelineContext, viewModel.context)
.previewLayout(.sizeThatFits)
.background(.black)

HStack(spacing: 16) {
ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
.frame(width: 100, height: 100)
.environmentObject(scanningViewModel.context)
.environment(\.timelineContext, scanningViewModel.context)

ImageMediaEventsTimelineView(timelineItem: makeTimelineItem())
.frame(width: 100, height: 100)
.environmentObject(unsafeViewModel.context)
.environment(\.timelineContext, unsafeViewModel.context)
}
.previewLayout(.sizeThatFits)
.previewDisplayName("Content Scanner")
}

private static func makeTimelineItem() -> ImageRoomTimelineItem {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// 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 grid item shown in place of a media item whilst it's being scanned by the content scanner.
struct ScanningMediaEventsTimelineView: View {
var body: some View {
Color.compound.bgSubtleSecondary
.opacity(0.3)
Comment thread
Velin92 marked this conversation as resolved.
.aspectRatio(1, contentMode: .fill)
.overlay {
ProgressView()
}
.accessibilityLabel(L10n.commonLoading)
}
}

/// The grid item shown in place of a media item that failed content scanning.
struct UnsafeMediaEventsTimelineView: View {
let failure: ContentScanningFailure

var body: some View {
Color.compound.bgCriticalSubtle
.aspectRatio(1, contentMode: .fill)
.overlay {
CompoundIcon(\.error, size: .medium, relativeTo: .compound.headingLG)
.foregroundStyle(.compound.iconCriticalPrimary)
}
.accessibilityLabel(accessibilityLabel)
}

private var accessibilityLabel: String {
switch failure {
case .notSafe: L10n.contentScannerUnsafeTitle
case .notFound: L10n.contentScannerNotFoundTitle
}
}
}

// MARK: - Previews

struct ScanStateMediaEventsTimelineViews_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
HStack(spacing: 16) {
ScanningMediaEventsTimelineView()
.frame(width: 100, height: 100)

UnsafeMediaEventsTimelineView(failure: .notSafe)
.frame(width: 100, height: 100)
}
.padding()
}
}
Loading
Loading