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 <daniel.anderson@toptal.com>

* 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 <daniel.anderson@toptal.com>

* Allow tapping the avatar in the room member bottom sheet to view it fullscreen

Signed-off-by: Daniel Anderson <daniel.anderson@toptal.com>

---------

Signed-off-by: Daniel Anderson <daniel.anderson@toptal.com>
This commit is contained in:
Danny
2026-08-07 06:11:40 -06:00
committed by GitHub
parent bbdbbe1081
commit 1a72a4e17b
8 changed files with 56 additions and 17 deletions
@@ -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
@@ -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<ManageRoomMemberSheetViewAlertType>?
var mediaPreviewItem: MediaPreviewItem?
}
enum ManageRoomMemberSheetViewAlertType {
@@ -59,6 +62,7 @@ enum ManageRoomMemberSheetViewAction {
case ban
case unban
case displayDetails
case displayAvatar(URL)
}
enum ManageRoomMemberDetails {
@@ -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<ManageRoomMemberSheetViewModelAction, Never> = .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),
@@ -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)
}
}
@@ -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)
}
}
@@ -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() {
@@ -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() {
@@ -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)
}
}