diff --git a/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift b/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift index 630568987..56e0b6a84 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/InteractiveQuickLook.swift @@ -83,6 +83,8 @@ private struct MediaPreviewViewController: UIViewControllerRepresentable { dismissalObserver = dismissalPublisher.sink { [weak self] _ in // Dispatching on main.async with weak self we avoid doing an extra dismiss if the view is presented on top of another modal DispatchQueue.main.async { [weak self] in + // Only dismiss a preview we still have up, UIKit forwards dismiss() to an ancestor otherwise. + guard self?.presentedViewController != nil else { return } self?.dismiss(animated: true) } } @@ -163,6 +165,16 @@ class MediaPreviewItem: NSObject, QLPreviewItem { self.file = file previewItemTitle = title } + + /// Loads the media at the given URL, guessing at a JPEG for callers such as avatars that don't know the real mime type. + static func load(from url: URL, title: String?, mimeType: String = "image/jpeg", using mediaProvider: MediaProviderProtocol) async -> MediaPreviewItem? { + guard let mediaSource = try? MediaSourceProxy(url: url, mimeType: mimeType), + case let .success(file) = await mediaProvider.loadFileFromSource(mediaSource) else { + return nil + } + + return MediaPreviewItem(file: file, title: title) + } } // MARK: - Previews diff --git a/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetModels.swift b/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetModels.swift index 227018b95..c9d60928f 100644 --- a/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetModels.swift +++ b/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetModels.swift @@ -6,6 +6,8 @@ // Please see LICENSE files in the repository root for full details. // +import Foundation + enum ManageRoomMemberSheetViewModelAction: Equatable { case dismiss(shouldShowDetails: Bool) } @@ -46,6 +48,7 @@ struct ManageRoomMemberSheetViewState: BindableState { struct ManageRoomMemberSheetViewStateBindings { var alertInfo: AlertInfo? + var mediaPreviewItem: MediaPreviewItem? } enum ManageRoomMemberSheetViewAlertType { @@ -59,6 +62,7 @@ enum ManageRoomMemberSheetViewAction { case ban case unban case displayDetails + case displayAvatar(URL) } enum ManageRoomMemberDetails { diff --git a/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetViewModel.swift b/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetViewModel.swift index 36aef5983..808f00f5d 100644 --- a/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetViewModel.swift +++ b/ElementX/Sources/Screens/ManageRoomMemberSheet/ManageRoomMemberSheetViewModel.swift @@ -16,6 +16,7 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage private let roomProxy: JoinedRoomProxyProtocol private let userIndicatorController: UserIndicatorControllerProtocol private let analyticsService: AnalyticsServiceProtocol + private let mediaProvider: MediaProviderProtocol private var actionsSubject: PassthroughSubject = .init() @@ -32,6 +33,7 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage self.userIndicatorController = userIndicatorController self.roomProxy = roomProxy self.analyticsService = analyticsService + self.mediaProvider = mediaProvider super.init(initialViewState: .init(memberDetails: memberDetails, permissions: permissions), mediaProvider: mediaProvider) } @@ -45,6 +47,8 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage actionsSubject.send(.dismiss(shouldShowDetails: true)) case .unban: displayAlert(.unban) + case .displayAvatar(let url): + Task { await displayFullScreenAvatar(url) } } } @@ -127,6 +131,14 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage } } + private func displayFullScreenAvatar(_ url: URL) async { + let loadingIndicatorIdentifier = "manageRoomMemberAvatarLoadingIndicator" + userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) + defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } + + state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: state.memberDetails.name, using: mediaProvider) + } + private func showManageMemberIndicator(title: String) { userIndicatorController.submitIndicator(UserIndicator(id: title, type: .toast(progress: .indeterminate), diff --git a/ElementX/Sources/Screens/ManageRoomMemberSheet/View/ManageRoomMemberSheetView.swift b/ElementX/Sources/Screens/ManageRoomMemberSheet/View/ManageRoomMemberSheetView.swift index dd7c3b817..18b0d3cf4 100644 --- a/ElementX/Sources/Screens/ManageRoomMemberSheet/View/ManageRoomMemberSheetView.swift +++ b/ElementX/Sources/Screens/ManageRoomMemberSheet/View/ManageRoomMemberSheetView.swift @@ -18,13 +18,17 @@ struct ManageRoomMemberSheetView: View { case .memberDetails(let member): AvatarHeaderView(member: member, avatarSize: .user(on: .memberDetails), - mediaProvider: context.mediaProvider) { + mediaProvider: context.mediaProvider) { url in + context.send(viewAction: .displayAvatar(url)) + } footer: { EmptyView() } case .loadingMemberDetails(let sender): AvatarHeaderView(sender: sender, avatarSize: .user(on: .memberDetails), - mediaProvider: context.mediaProvider) { + mediaProvider: context.mediaProvider) { url in + context.send(viewAction: .displayAvatar(url)) + } footer: { EmptyView() } } @@ -76,6 +80,7 @@ struct ManageRoomMemberSheetView: View { .presentationDragIndicator(.visible) .presentationDetents([.large, .fraction(0.67)]) // Maybe find a way to use the ideal height somehow? .alert(item: $context.alertInfo) + .interactiveQuickLook(item: $context.mediaPreviewItem, allowEditing: false) } } diff --git a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift index e2e9682c1..e86a1e08f 100644 --- a/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift @@ -486,11 +486,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } - // We don't actually know the mime type here, assume it's an image. - if let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpeg"), - case let .success(file) = await userSession.mediaProvider.loadFileFromSource(mediaSource) { - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: roomProxy.infoPublisher.value.displayName) - } + state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: roomProxy.infoPublisher.value.displayName, using: userSession.mediaProvider) } } diff --git a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift index 1eb224ee9..c13304c34 100644 --- a/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberDetailsScreen/RoomMemberDetailsScreenViewModel.swift @@ -176,11 +176,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true)) defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } - // We don't actually know the mime type here, assume it's an image. - if let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpeg"), - case let .success(file) = await userSession.mediaProvider.loadFileFromSource(mediaSource) { - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: roomMemberProxy.displayName) - } + state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: roomMemberProxy.displayName, using: userSession.mediaProvider) } private func openDirectChat() { diff --git a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift index a0494f315..d78442eb4 100644 --- a/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift +++ b/ElementX/Sources/Screens/UserProfileScreen/UserProfileScreenViewModel.swift @@ -119,11 +119,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr showLoadingIndicator(allowsInteraction: false) defer { hideLoadingIndicator() } - // We don't actually know the mime type here, assume it's an image. - if let mediaSource = try? MediaSourceProxy(url: url, mimeType: "image/jpeg"), - case let .success(file) = await userSession.mediaProvider.loadFileFromSource(mediaSource) { - state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: userProfile.displayName) - } + state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: userProfile.displayName, using: userSession.mediaProvider) } private func openDirectChat() { diff --git a/UnitTests/Sources/ManageRoomMemberSheetViewModelTests.swift b/UnitTests/Sources/ManageRoomMemberSheetViewModelTests.swift index 9785eabc7..034af6f72 100644 --- a/UnitTests/Sources/ManageRoomMemberSheetViewModelTests.swift +++ b/UnitTests/Sources/ManageRoomMemberSheetViewModelTests.swift @@ -97,4 +97,22 @@ struct ManageRoomMemberSheetViewModelTests { try await deferredAction.fulfill() #expect(context.alertInfo == nil) } + + @Test + mutating func displayAvatar() async throws { + let member = RoomMemberDetails(withProxy: RoomMemberProxyMock.mockDan) + viewModel = ManageRoomMemberSheetViewModel(memberDetails: .memberDetails(roomMember: member), + permissions: .init(canKick: true, canBan: true, ownPowerLevel: RoomMemberProxyMock.mockAdmin.powerLevel), + roomProxy: JoinedRoomProxyMock(.init()), + userIndicatorController: UserIndicatorControllerMock(), + analyticsService: AnalyticsServiceMock(.init()), + mediaProvider: MediaProviderMock(.init())) + + let avatarURL = try #require(member.avatarURL) + let deferred = deferFulfillment(context.observe(\.viewState.bindings.mediaPreviewItem)) { $0 != nil } + context.send(viewAction: .displayAvatar(avatarURL)) + try await deferred.fulfill() + + #expect(context.mediaPreviewItem?.previewItemTitle == member.name) + } }