From 1a72a4e17b2717ef8448f0040c108ac41e5d1f90 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 7 Aug 2026 06:11:40 -0600 Subject: [PATCH] Allow tapping the avatar in the room member bottom sheet to view it fullscreen (#5989) * Extract a shared helper for loading a media preview The four screens that show a fullscreen avatar each had their own copy of the same load-and-wrap logic. Move it to MediaPreviewItem.load, with the mime type as a parameter defaulting to JPEG, and leave each screen's loading indicator where it is. Signed-off-by: Daniel Anderson * Only dismiss a QuickLook preview that is still presented UIKit forwards dismiss() up the presentation chain when the receiver has nothing presented, so dismissing the avatar view was dismissing the profile details sheet also. Signed-off-by: Daniel Anderson * Allow tapping the avatar in the room member bottom sheet to view it fullscreen Signed-off-by: Daniel Anderson --------- Signed-off-by: Daniel Anderson --- .../InteractiveQuickLook.swift | 12 ++++++++++++ .../ManageRoomMemberSheetModels.swift | 4 ++++ .../ManageRoomMemberSheetViewModel.swift | 12 ++++++++++++ .../View/ManageRoomMemberSheetView.swift | 9 +++++++-- .../RoomDetailsScreenViewModel.swift | 6 +----- .../RoomMemberDetailsScreenViewModel.swift | 6 +----- .../UserProfileScreenViewModel.swift | 6 +----- .../ManageRoomMemberSheetViewModelTests.swift | 18 ++++++++++++++++++ 8 files changed, 56 insertions(+), 17 deletions(-) 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) + } }