mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
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:
@@ -83,6 +83,8 @@ private struct MediaPreviewViewController: UIViewControllerRepresentable {
|
|||||||
dismissalObserver = dismissalPublisher.sink { [weak self] _ in
|
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
|
// 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
|
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)
|
self?.dismiss(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,16 @@ class MediaPreviewItem: NSObject, QLPreviewItem {
|
|||||||
self.file = file
|
self.file = file
|
||||||
previewItemTitle = title
|
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
|
// MARK: - Previews
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
enum ManageRoomMemberSheetViewModelAction: Equatable {
|
enum ManageRoomMemberSheetViewModelAction: Equatable {
|
||||||
case dismiss(shouldShowDetails: Bool)
|
case dismiss(shouldShowDetails: Bool)
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,7 @@ struct ManageRoomMemberSheetViewState: BindableState {
|
|||||||
|
|
||||||
struct ManageRoomMemberSheetViewStateBindings {
|
struct ManageRoomMemberSheetViewStateBindings {
|
||||||
var alertInfo: AlertInfo<ManageRoomMemberSheetViewAlertType>?
|
var alertInfo: AlertInfo<ManageRoomMemberSheetViewAlertType>?
|
||||||
|
var mediaPreviewItem: MediaPreviewItem?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ManageRoomMemberSheetViewAlertType {
|
enum ManageRoomMemberSheetViewAlertType {
|
||||||
@@ -59,6 +62,7 @@ enum ManageRoomMemberSheetViewAction {
|
|||||||
case ban
|
case ban
|
||||||
case unban
|
case unban
|
||||||
case displayDetails
|
case displayDetails
|
||||||
|
case displayAvatar(URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ManageRoomMemberDetails {
|
enum ManageRoomMemberDetails {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage
|
|||||||
private let roomProxy: JoinedRoomProxyProtocol
|
private let roomProxy: JoinedRoomProxyProtocol
|
||||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||||
private let analyticsService: AnalyticsServiceProtocol
|
private let analyticsService: AnalyticsServiceProtocol
|
||||||
|
private let mediaProvider: MediaProviderProtocol
|
||||||
|
|
||||||
private var actionsSubject: PassthroughSubject<ManageRoomMemberSheetViewModelAction, Never> = .init()
|
private var actionsSubject: PassthroughSubject<ManageRoomMemberSheetViewModelAction, Never> = .init()
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class ManageRoomMemberSheetViewModel: ManageRoomMemberSheetViewModelType, Manage
|
|||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
self.roomProxy = roomProxy
|
self.roomProxy = roomProxy
|
||||||
self.analyticsService = analyticsService
|
self.analyticsService = analyticsService
|
||||||
|
self.mediaProvider = mediaProvider
|
||||||
super.init(initialViewState: .init(memberDetails: memberDetails, permissions: permissions), 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))
|
actionsSubject.send(.dismiss(shouldShowDetails: true))
|
||||||
case .unban:
|
case .unban:
|
||||||
displayAlert(.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) {
|
private func showManageMemberIndicator(title: String) {
|
||||||
userIndicatorController.submitIndicator(UserIndicator(id: title,
|
userIndicatorController.submitIndicator(UserIndicator(id: title,
|
||||||
type: .toast(progress: .indeterminate),
|
type: .toast(progress: .indeterminate),
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ struct ManageRoomMemberSheetView: View {
|
|||||||
case .memberDetails(let member):
|
case .memberDetails(let member):
|
||||||
AvatarHeaderView(member: member,
|
AvatarHeaderView(member: member,
|
||||||
avatarSize: .user(on: .memberDetails),
|
avatarSize: .user(on: .memberDetails),
|
||||||
mediaProvider: context.mediaProvider) {
|
mediaProvider: context.mediaProvider) { url in
|
||||||
|
context.send(viewAction: .displayAvatar(url))
|
||||||
|
} footer: {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
case .loadingMemberDetails(let sender):
|
case .loadingMemberDetails(let sender):
|
||||||
AvatarHeaderView(sender: sender,
|
AvatarHeaderView(sender: sender,
|
||||||
avatarSize: .user(on: .memberDetails),
|
avatarSize: .user(on: .memberDetails),
|
||||||
mediaProvider: context.mediaProvider) {
|
mediaProvider: context.mediaProvider) { url in
|
||||||
|
context.send(viewAction: .displayAvatar(url))
|
||||||
|
} footer: {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,7 @@ struct ManageRoomMemberSheetView: View {
|
|||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationDetents([.large, .fraction(0.67)]) // Maybe find a way to use the ideal height somehow?
|
.presentationDetents([.large, .fraction(0.67)]) // Maybe find a way to use the ideal height somehow?
|
||||||
.alert(item: $context.alertInfo)
|
.alert(item: $context.alertInfo)
|
||||||
|
.interactiveQuickLook(item: $context.mediaPreviewItem, allowEditing: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -486,11 +486,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
|||||||
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
|
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't actually know the mime type here, assume it's an image.
|
state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: roomProxy.infoPublisher.value.displayName, using: userSession.mediaProvider)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -176,11 +176,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
|
|||||||
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
|
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||||
defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) }
|
defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) }
|
||||||
|
|
||||||
// We don't actually know the mime type here, assume it's an image.
|
state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: roomMemberProxy.displayName, using: userSession.mediaProvider)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openDirectChat() {
|
private func openDirectChat() {
|
||||||
|
|||||||
@@ -119,11 +119,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
|
|||||||
showLoadingIndicator(allowsInteraction: false)
|
showLoadingIndicator(allowsInteraction: false)
|
||||||
defer { hideLoadingIndicator() }
|
defer { hideLoadingIndicator() }
|
||||||
|
|
||||||
// We don't actually know the mime type here, assume it's an image.
|
state.bindings.mediaPreviewItem = await MediaPreviewItem.load(from: url, title: userProfile.displayName, using: userSession.mediaProvider)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openDirectChat() {
|
private func openDirectChat() {
|
||||||
|
|||||||
@@ -97,4 +97,22 @@ struct ManageRoomMemberSheetViewModelTests {
|
|||||||
try await deferredAction.fulfill()
|
try await deferredAction.fulfill()
|
||||||
#expect(context.alertInfo == nil)
|
#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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user