Add a UserProfileScreen for profiles of non-members. (#2687)

This commit is contained in:
Doug
2024-04-12 09:54:14 +01:00
committed by GitHub
parent 47912d999b
commit 5c9d0975ce
22 changed files with 520 additions and 56 deletions
@@ -185,6 +185,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .roomMemberDetails(userID: userID, fromRoomMembersList: true)
case (.roomMemberDetails(_, let fromRoomMembersList), .dismissRoomMemberDetails):
return fromRoomMembersList ? .roomMembersList : .room
case (.roomMemberDetails(_, fromRoomMembersList: false), .presentUserProfile(let userID)):
return .userProfile(userID: userID)
case (.userProfile, .dismissUserProfile):
return .room
case (.roomDetails, .presentInviteUsersScreen):
return .inviteUsersScreen(fromRoomMembersList: false)
@@ -304,6 +309,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentRoomMemberDetails(userID: userID)
case (.roomMemberDetails, .dismissRoomMemberDetails, .roomMembersList):
break
case (.roomMemberDetails, .presentUserProfile(let userID), .userProfile):
replaceRoomMemberDetailsWithUserProfile(userID: userID)
case (.userProfile, .dismissUserProfile, .room):
break
case (.roomDetails, .presentInviteUsersScreen, .inviteUsersScreen):
presentInviteUsersScreen()
@@ -874,35 +884,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .openUserProfile:
stateMachine.tryEvent(.presentUserProfile(userID: userID))
case .openDirectChat(let displayName):
let loadingIndicatorIdentifier = "OpenDirectChatLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLoading,
persistent: true))
Task { [weak self] in
guard let self else { return }
let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(userID)
switch currentDirectRoom {
case .success(.some(let roomID)):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
case .success(nil):
switch await userSession.clientProxy.createDirectRoom(with: userID, expectedRoomName: displayName) {
case .success(let roomID):
analytics.trackCreatedRoom(isDM: true)
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
case .failure:
userIndicatorController.alertInfo = .init(id: UUID())
}
case .failure:
userIndicatorController.alertInfo = .init(id: UUID())
}
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
openDirectChat(with: userID, displayName: displayName)
}
}
.store(in: &cancellables)
@@ -912,6 +897,60 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func replaceRoomMemberDetailsWithUserProfile(userID: String) {
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController)
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .openDirectChat(let displayName):
openDirectChat(with: userID, displayName: displayName)
}
}
.store(in: &cancellables)
// Replace the RoomMemberDetailsScreen without any animation.
navigationStackCoordinator.pop(animated: false)
navigationStackCoordinator.push(coordinator, animated: false) { [weak self] in
self?.stateMachine.tryEvent(.dismissUserProfile)
}
}
private func openDirectChat(with userID: String, displayName: String?) {
let loadingIndicatorIdentifier = "OpenDirectChatLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLoading,
persistent: true))
Task { [weak self] in
guard let self else { return }
let currentDirectRoom = await userSession.clientProxy.directRoomForUserID(userID)
switch currentDirectRoom {
case .success(.some(let roomID)):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
case .success(nil):
switch await userSession.clientProxy.createDirectRoom(with: userID, expectedRoomName: displayName) {
case .success(let roomID):
analytics.trackCreatedRoom(isDM: true)
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
case .failure:
userIndicatorController.alertInfo = .init(id: UUID())
}
case .failure:
userIndicatorController.alertInfo = .init(id: UUID())
}
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
}
private func presentMessageForwarding(for itemID: TimelineItemIdentifier) {
guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider, let eventID = itemID.eventID else {
fatalError()
@@ -1160,6 +1199,7 @@ private extension RoomFlowCoordinator {
case globalNotificationSettings
case roomMembersList
case roomMemberDetails(userID: String, fromRoomMembersList: Bool)
case userProfile(userID: String)
case inviteUsersScreen(fromRoomMembersList: Bool)
case mediaUploadPicker(source: MediaPickerScreenSource)
case mediaUploadPreview(fileURL: URL)
@@ -1207,6 +1247,9 @@ private extension RoomFlowCoordinator {
case presentRoomMemberDetails(userID: String)
case dismissRoomMemberDetails
case presentUserProfile(userID: String)
case dismissUserProfile
case presentInviteUsersScreen
case dismissInviteUsersScreen
@@ -58,6 +58,22 @@ struct AvatarHeaderView<Footer: View>: View {
self.onAvatarTap = onAvatarTap
self.footer = footer
}
init(user: UserProfileProxy,
avatarSize: AvatarSize,
imageProvider: ImageProviderProtocol? = nil,
onAvatarTap: (() -> Void)? = nil,
@ViewBuilder footer: @escaping () -> Footer) {
id = user.userID
name = user.displayName
subtitle = user.displayName == nil ? nil : user.userID
avatarURL = user.avatarURL
self.avatarSize = avatarSize
self.imageProvider = imageProvider
self.onAvatarTap = onAvatarTap
self.footer = footer
}
var body: some View {
VStack(spacing: 8.0) {
@@ -26,6 +26,7 @@ struct RoomMemberDetailsScreenCoordinatorParameters {
}
enum RoomMemberDetailsScreenCoordinatorAction {
case openUserProfile
case openDirectChat(displayName: String?)
}
@@ -52,6 +53,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .openUserProfile:
actionsSubject.send(.openUserProfile)
case .openDirectChat(let displayName):
actionsSubject.send(.openDirectChat(displayName: displayName))
}
@@ -17,6 +17,7 @@
import Foundation
enum RoomMemberDetailsScreenViewModelAction {
case openUserProfile
case openDirectChat(displayName: String?)
}
@@ -59,8 +59,8 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
state.memberDetails = RoomMemberDetails(withProxy: member)
state.isOwnMemberDetails = member.userID == roomProxy.ownUserID
case .failure(let error):
state.bindings.alertInfo = .init(id: .unknown)
MXLog.error("[RoomFlowCoordinator] Failed to get member: \(error)")
MXLog.warning("Failed to find member: \(error)")
actionsSubject.send(.openUserProfile)
}
}
}
@@ -21,19 +21,6 @@ struct RoomMemberDetailsScreen: View {
@ObservedObject var context: RoomMemberDetailsScreenViewModel.Context
var body: some View {
content
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
.alert(item: $context.ignoreUserAlert, actions: blockUserAlertActions, message: blockUserAlertMessage)
.alert(item: $context.alertInfo)
.track(screen: .User)
.interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true)
}
// MARK: - Private
@ViewBuilder
private var content: some View {
Form {
headerSection
@@ -42,8 +29,16 @@ struct RoomMemberDetailsScreen: View {
blockUserSection
}
}
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
.alert(item: $context.ignoreUserAlert, actions: blockUserAlertActions, message: blockUserAlertMessage)
.alert(item: $context.alertInfo)
.track(screen: .User)
.interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true)
}
// MARK: - Private
@ViewBuilder
private var headerSection: some View {
if let memberDetails = context.viewState.memberDetails {
@@ -63,7 +58,7 @@ struct RoomMemberDetailsScreen: View {
}
}
} else {
AvatarHeaderView(member: .init(loading: context.viewState.userID),
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider,
footer: { })
@@ -0,0 +1,67 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct UserProfileScreenCoordinatorParameters {
let userID: String
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum UserProfileScreenCoordinatorAction {
case openDirectChat(displayName: String?)
}
final class UserProfileScreenCoordinator: CoordinatorProtocol {
private var viewModel: UserProfileScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<UserProfileScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserProfileScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: UserProfileScreenCoordinatorParameters) {
viewModel = UserProfileScreenViewModel(userID: parameters.userID,
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .openDirectChat(let displayName):
actionsSubject.send(.openDirectChat(displayName: displayName))
}
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(UserProfileScreen(context: viewModel.context))
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum UserProfileScreenViewModelAction {
case openDirectChat(displayName: String?)
}
struct UserProfileScreenViewState: BindableState {
let userID: String
let isOwnUser: Bool
var userProfile: UserProfileProxy?
var permalink: URL?
var bindings: UserProfileScreenViewStateBindings
}
struct UserProfileScreenViewStateBindings {
var alertInfo: AlertInfo<UserProfileScreenError>?
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
}
enum UserProfileScreenViewAction {
case displayAvatar
case openDirectChat
}
enum UserProfileScreenError: Hashable {
case unknown
}
@@ -0,0 +1,119 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import MatrixRustSDK
import SwiftUI
typealias UserProfileScreenViewModelType = StateStoreViewModel<UserProfileScreenViewState, UserProfileScreenViewAction>
class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var actionsSubject: PassthroughSubject<UserProfileScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserProfileScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(userID: String,
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
self.userIndicatorController = userIndicatorController
let initialViewState = UserProfileScreenViewState(userID: userID,
isOwnUser: userID == clientProxy.userID,
bindings: .init())
super.init(initialViewState: initialViewState, imageProvider: mediaProvider)
showMemberLoadingIndicator()
Task {
defer {
hideMemberLoadingIndicator()
}
switch await clientProxy.profile(for: userID) {
case .success(let userProfile):
state.userProfile = userProfile
state.permalink = (try? matrixToUserPermalink(userId: userID)).flatMap(URL.init(string:))
case .failure(let error):
state.bindings.alertInfo = .init(id: .unknown)
MXLog.error("Failed to find user profile: \(error)")
}
}
}
// MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
hideMemberLoadingIndicator()
}
override func process(viewAction: UserProfileScreenViewAction) {
switch viewAction {
case .displayAvatar:
displayFullScreenAvatar()
case .openDirectChat:
guard let userProfile = state.userProfile else { fatalError() }
actionsSubject.send(.openDirectChat(displayName: userProfile.displayName))
}
}
// MARK: - Private
private func displayFullScreenAvatar() {
guard let userProfile = state.userProfile else { fatalError() }
guard let avatarURL = userProfile.avatarURL else { return }
let loadingIndicatorIdentifier = "roomMemberAvatarLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
Task {
defer {
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
// We don't actually know the mime type here, assume it's an image.
if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) {
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: userProfile.displayName)
}
}
}
// MARK: Loading indicator
private static let loadingIndicatorIdentifier = "\(UserProfileScreenViewModel.self)-Loading"
private func showMemberLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
title: L10n.commonLoading,
persistent: true),
delay: .milliseconds(100))
}
private func hideMemberLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -0,0 +1,25 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol UserProfileScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<UserProfileScreenViewModelAction, Never> { get }
var context: UserProfileScreenViewModelType.Context { get }
func stop()
}
@@ -0,0 +1,99 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Compound
import SwiftUI
struct UserProfileScreen: View {
@ObservedObject var context: UserProfileScreenViewModel.Context
var body: some View {
Form {
headerSection
if context.viewState.userProfile != nil, !context.viewState.isOwnUser {
directChatSection
}
}
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
.alert(item: $context.alertInfo)
.track(screen: .User)
.interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true)
}
// MARK: - Private
@ViewBuilder
private var headerSection: some View {
if let userProfile = context.viewState.userProfile {
AvatarHeaderView(user: userProfile,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider) {
context.send(viewAction: .displayAvatar)
} footer: {
if let permalink = context.viewState.permalink {
HStack(spacing: 32) {
ShareLink(item: permalink) {
CompoundIcon(\.shareIos)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionShare))
}
.padding(.top, 32)
}
}
} else {
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider,
footer: { })
}
}
private var directChatSection: some View {
Section {
ListRow(label: .default(title: L10n.commonDirectChat,
icon: \.chat),
kind: .button {
context.send(viewAction: .openDirectChat)
})
.accessibilityIdentifier(A11yIdentifiers.roomMemberDetailsScreen.directChat)
}
}
}
// MARK: - Previews
struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
static let otherUserViewModel = makeViewModel(userID: RoomMemberProxyMock.mockDan.userID)
static let accountOwnerViewModel = makeViewModel(userID: RoomMemberProxyMock.mockMe.userID)
static var previews: some View {
UserProfileScreen(context: otherUserViewModel.context)
.previewDisplayName("Other User")
.snapshot(delay: 0.25)
UserProfileScreen(context: accountOwnerViewModel.context)
.previewDisplayName("Account Owner")
.snapshot(delay: 0.25)
}
static func makeViewModel(userID: String) -> UserProfileScreenViewModel {
UserProfileScreenViewModel(userID: userID,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@@ -48,18 +48,6 @@ extension RoomMemberDetails {
isBanned = proxy.membership == .ban
role = .init(proxy.role)
}
init(loading id: String) {
self.id = id
name = nil
avatarURL = nil
permalink = nil
isInvited = false
isIgnored = false
isBanned = false
role = .user
}
}
extension RoomMemberDetails.Role {