Handle external links to a user. (#2690)

This commit is contained in:
Doug 2024-04-15 11:08:00 +01:00 committed by GitHub
parent e0138eeb35
commit e7af7fb59c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 450 additions and 154 deletions

View File

@ -481,6 +481,7 @@
7354D094A4C59B555F407FA1 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; };
7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C1A3D524D63815B28FA4D62 /* EmojiCategory.swift */; };
73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; };
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; };
7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; };
743790BF6A5B0577EA74AF14 /* ReadMarkerRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3D25B3EDB283B5807EADCF /* ReadMarkerRoomTimelineItem.swift */; };
74604ACFDBE7F54260E7B617 /* ApplicationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8903A9F615BBD0E6D7CD133 /* ApplicationProtocol.swift */; };
@ -1576,6 +1577,7 @@
71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = "<group>"; };
71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenModels.swift; sourceTree = "<group>"; };
71D52BAA5BADB06E5E8C295D /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = "<group>"; };
71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenViewModelTests.swift; sourceTree = "<group>"; };
72F37B5DA798C9AE436F2C2C /* AttributedStringBuilderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilderProtocol.swift; sourceTree = "<group>"; };
7310D8DFE01AF45F0689C3AA /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = "<group>"; };
7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportFlowCoordinator.swift; sourceTree = "<group>"; };
@ -3620,6 +3622,7 @@
EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */,
2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */,
BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */,
71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */,
4FA29BAE9B0F2D90E57B261C /* UserSessionFlowCoordinatorTests.swift */,
283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */,
AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */,
@ -5819,6 +5822,7 @@
E313BDD2B8813144139B2E00 /* UserDiscoveryServiceTest.swift in Sources */,
A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */,
04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */,
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */,
627139A3D79F032BA81E3A53 /* UserSessionFlowCoordinatorTests.swift in Sources */,
81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */,
21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */,

View File

@ -193,8 +193,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
} else {
navigationRootCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)))
}
case .roomMemberDetails:
userSessionFlowCoordinator?.handleAppRoute(route, animated: true)
case .userProfile(let userID):
if isExternalURL {
userSessionFlowCoordinator?.handleAppRoute(route, animated: true)
} else {
userSessionFlowCoordinator?.handleAppRoute(.roomMemberDetails(userID: userID), animated: true)
}
case .room(let roomID):
// check that the room is joined here, if not use a joinRoom route.
if isExternalURL {

View File

@ -129,6 +129,8 @@ final class AppSettings {
let encryptionURL: URL = "https://element.io/help#encryption"
/// A URL where users can go read more about the chat backup.
let chatBackupDetailsURL: URL = "https://element.io/help#encryption5"
/// Any domains that Element web may be hosted on - used for handling links.
let elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
@UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store))
var appAppearance: AppAppearance

View File

@ -29,8 +29,9 @@ enum AppRoute: Equatable {
/// The information about a particular room.
case roomDetails(roomID: String)
/// The profile of a member within the current room.
/// (This can be specialised into 2 routes when we support user permalinks).
case roomMemberDetails(userID: String)
/// The profile of a matrix user (outside of a room).
case userProfile(userID: String)
/// An Element Call link generated outside of a chat room.
case genericCallLink(url: URL)
/// The settings screen.
@ -45,6 +46,7 @@ struct AppRouteURLParser {
init(appSettings: AppSettings) {
urlParsers = [
MatrixPermalinkParser(),
ElementWebURLParser(domains: appSettings.elementWebHosts),
OIDCCallbackURLParser(appSettings: appSettings),
ElementCallURLParser()
]
@ -64,9 +66,6 @@ struct AppRouteURLParser {
/// Represents a type that can parse a `URL` into an `AppRoute`.
///
/// The following Universal Links are missing parsers.
/// - app.element.io
/// - staging.element.io
/// - develop.element.io
/// - mobile.element.io
protocol URLParser {
func route(from url: URL) -> AppRoute?
@ -123,17 +122,43 @@ struct ElementCallURLParser: URLParser {
struct MatrixPermalinkParser: URLParser {
func route(from url: URL) -> AppRoute? {
guard let matrixEntity = parseMatrixEntityFrom(uri: url.absoluteString) else {
return nil
}
switch matrixEntity.id {
case .user(let userID):
return .roomMemberDetails(userID: userID)
case .room(let roomID):
return .room(roomID: roomID)
switch parseMatrixEntityFrom(uri: url.absoluteString)?.id {
case .room(let id):
return .room(roomID: id)
case .user(let id):
return .userProfile(userID: id)
default:
return nil
}
}
}
struct ElementWebURLParser: URLParser {
let domains: [String]
let paths = ["room", "user"]
private let permalinkParser = MatrixPermalinkParser()
func route(from url: URL) -> AppRoute? {
guard let matrixToURL = buildMatrixToURL(from: url) else { return nil }
return permalinkParser.route(from: matrixToURL)
}
private func buildMatrixToURL(from url: URL) -> URL? {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
for domain in domains where domain == url.host {
components.host = "matrix.to"
for path in paths {
components.fragment?.replace("/\(path)", with: "")
}
guard let matrixToURL = components.url else { continue }
return matrixToURL
}
return url
}
}

View File

@ -129,7 +129,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
}
case .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
case .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
break
}
}
@ -875,7 +875,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
roomProxy: roomProxy,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController)
userIndicatorController: userIndicatorController,
analytics: analytics)
let coordinator = RoomMemberDetailsScreenCoordinator(parameters: params)
coordinator.actions.sink { [weak self] action in
@ -883,8 +884,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .openUserProfile:
stateMachine.tryEvent(.presentUserProfile(userID: userID))
case .openDirectChat(let displayName):
openDirectChat(with: userID, displayName: displayName)
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
}
}
.store(in: &cancellables)
@ -896,16 +897,20 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private func replaceRoomMemberDetailsWithUserProfile(userID: String) {
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
isPresentedModally: false,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController)
userIndicatorController: userIndicatorController,
analytics: analytics)
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)
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
case .dismiss:
break // Not supported when pushed.
}
}
.store(in: &cancellables)
@ -917,37 +922,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
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()

View File

@ -195,6 +195,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomList, .roomMemberDetails:
self.roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .userProfile(let userID):
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
case .genericCallLink(let url):
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
case .oidcCallback:
@ -302,6 +304,11 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
presentRoomDirectorySearch()
case (.roomDirectorySearchScreen, .dismissedRoomDirectorySearchScreen, .roomList):
dismissRoomDirectorySearch()
case (_, .showUserProfileScreen(let userID), .userProfileScreen):
presentUserProfileScreen(userID: userID, animated: animated)
case (.userProfileScreen, .dismissedUserProfileScreen, .roomList):
break
default:
fatalError("Unknown transition: \(context)")
@ -654,4 +661,36 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private func dismissRoomDirectorySearch() {
navigationSplitCoordinator.setFullScreenCoverCoordinator(nil)
}
// MARK: User Profile
private func presentUserProfileScreen(userID: String, animated: Bool) {
clearRoute(animated: animated)
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
isPresentedModally: true,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: analytics)
let coordinator = UserProfileScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .openDirectChat(let roomID):
navigationSplitCoordinator.setSheetCoordinator(nil)
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false))
case .dismiss:
navigationSplitCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(coordinator, animated: false)
navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.dismissedUserProfileScreen)
}
}
}

View File

@ -27,7 +27,7 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing the home screen. The `selectedRoomID` represents the timeline shown on the detail panel (if any)
case roomList(selectedRoomID: String?)
/// Showing the session verification flows
/// Showing the feedback screen.
case feedbackScreen(selectedRoomID: String?)
/// Showing the settings screen
@ -45,10 +45,13 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing Room Directory Search screen
case roomDirectorySearchScreen(selectedRoomID: String?)
/// Showing the user profile screen. This screen clears the navigation.
case userProfileScreen
/// The selected room ID from the state if available.
var selectedRoomID: String? {
switch self {
case .initial:
case .initial, .userProfileScreen:
nil
case .roomList(let selectedRoomID),
.feedbackScreen(let selectedRoomID),
@ -102,9 +105,15 @@ class UserSessionFlowCoordinatorStateMachine {
/// Logout has been cancelled
case dismissedLogoutConfirmationScreen
/// Request presentation of the room directory search screen.
case showRoomDirectorySearchScreen
/// The room directory search screen has been dismissed.
case dismissedRoomDirectorySearchScreen
/// Request presentation of the user profile screen.
case showUserProfileScreen(userID: String)
/// The user profile screen has been dismissed.
case dismissedUserProfileScreen
}
private let stateMachine: StateMachine<State, Event>
@ -169,6 +178,11 @@ class UserSessionFlowCoordinatorStateMachine {
return .roomDirectorySearchScreen(selectedRoomID: selectedRoomID)
case (.roomDirectorySearchScreen(let selectedRoomID), .dismissedRoomDirectorySearchScreen):
return .roomList(selectedRoomID: selectedRoomID)
case (_, .showUserProfileScreen):
return .userProfileScreen
case (.userProfileScreen, .dismissedUserProfileScreen):
return .roomList(selectedRoomID: nil)
default:
return nil

View File

@ -2077,6 +2077,74 @@ class ClientProxyMock: ClientProxyProtocol {
return accountURLActionReturnValue
}
}
//MARK: - createDirectRoomIfNeeded
var createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = 0
var createDirectRoomIfNeededWithExpectedRoomNameCallsCount: Int {
get {
if Thread.isMainThread {
return createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
createDirectRoomIfNeededWithExpectedRoomNameUnderlyingCallsCount = newValue
}
}
}
}
var createDirectRoomIfNeededWithExpectedRoomNameCalled: Bool {
return createDirectRoomIfNeededWithExpectedRoomNameCallsCount > 0
}
var createDirectRoomIfNeededWithExpectedRoomNameReceivedArguments: (userID: String, expectedRoomName: String?)?
var createDirectRoomIfNeededWithExpectedRoomNameReceivedInvocations: [(userID: String, expectedRoomName: String?)] = []
var createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>!
var createDirectRoomIfNeededWithExpectedRoomNameReturnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>! {
get {
if Thread.isMainThread {
return createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue
} else {
var returnValue: Result<(roomID: String, isNewRoom: Bool), ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
createDirectRoomIfNeededWithExpectedRoomNameUnderlyingReturnValue = newValue
}
}
}
}
var createDirectRoomIfNeededWithExpectedRoomNameClosure: ((String, String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError>)?
func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError> {
createDirectRoomIfNeededWithExpectedRoomNameCallsCount += 1
createDirectRoomIfNeededWithExpectedRoomNameReceivedArguments = (userID: userID, expectedRoomName: expectedRoomName)
createDirectRoomIfNeededWithExpectedRoomNameReceivedInvocations.append((userID: userID, expectedRoomName: expectedRoomName))
if let createDirectRoomIfNeededWithExpectedRoomNameClosure = createDirectRoomIfNeededWithExpectedRoomNameClosure {
return await createDirectRoomIfNeededWithExpectedRoomNameClosure(userID, expectedRoomName)
} else {
return createDirectRoomIfNeededWithExpectedRoomNameReturnValue
}
}
//MARK: - directRoomForUserID
var directRoomForUserIDUnderlyingCallsCount = 0

View File

@ -23,11 +23,12 @@ struct RoomMemberDetailsScreenCoordinatorParameters {
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService
}
enum RoomMemberDetailsScreenCoordinatorAction {
case openUserProfile
case openDirectChat(displayName: String?)
case openDirectChat(roomID: String)
}
final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
@ -45,7 +46,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
roomProxy: parameters.roomProxy,
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
analytics: parameters.analytics)
}
func start() {
@ -55,8 +57,8 @@ final class RoomMemberDetailsScreenCoordinator: CoordinatorProtocol {
switch action {
case .openUserProfile:
actionsSubject.send(.openUserProfile)
case .openDirectChat(let displayName):
actionsSubject.send(.openDirectChat(displayName: displayName))
case .openDirectChat(let roomID):
actionsSubject.send(.openDirectChat(roomID: roomID))
}
}
.store(in: &cancellables)

View File

@ -18,7 +18,7 @@ import Foundation
enum RoomMemberDetailsScreenViewModelAction {
case openUserProfile
case openDirectChat(displayName: String?)
case openDirectChat(roomID: String)
}
struct RoomMemberDetailsScreenViewState: BindableState {
@ -86,5 +86,6 @@ enum RoomMemberDetailsScreenViewAction {
}
enum RoomMemberDetailsScreenError: Hashable {
case failedOpeningDirectChat
case unknown
}

View File

@ -24,6 +24,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
private let clientProxy: ClientProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
private var actionsSubject: PassthroughSubject<RoomMemberDetailsScreenViewModelAction, Never> = .init()
@ -37,11 +38,13 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
roomProxy: RoomProxyProtocol,
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) {
self.roomProxy = roomProxy
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
self.userIndicatorController = userIndicatorController
self.analytics = analytics
let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, bindings: .init())
@ -85,13 +88,9 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
case .unignoreConfirmed:
Task { await unignoreUser() }
case .displayAvatar:
displayFullScreenAvatar()
Task { await displayFullScreenAvatar() }
case .openDirectChat:
guard let roomMemberProxy else {
fatalError()
}
actionsSubject.send(.openDirectChat(displayName: roomMemberProxy.displayName))
Task { await openDirectChat() }
}
}
@ -145,7 +144,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
}
}
private func displayFullScreenAvatar() {
private func displayFullScreenAvatar() async {
guard let roomMemberProxy else {
fatalError()
}
@ -156,16 +155,32 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
let loadingIndicatorIdentifier = "roomMemberAvatarLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
Task {
defer {
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
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: roomMemberProxy.displayName)
// 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: roomMemberProxy.displayName)
}
}
private func openDirectChat() async {
guard let roomMemberProxy else { fatalError() }
let loadingIndicatorIdentifier = "openDirectChatLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLoading,
persistent: true))
defer { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) }
switch await clientProxy.createDirectRoomIfNeeded(with: roomMemberProxy.userID, expectedRoomName: roomMemberProxy.displayName) {
case .success((let roomID, let isNewRoom)):
if isNewRoom {
analytics.trackCreatedRoom(isDM: true)
}
actionsSubject.send(.openDirectChat(roomID: roomID))
case .failure:
state.bindings.alertInfo = .init(id: .failedOpeningDirectChat)
}
}

View File

@ -114,41 +114,9 @@ struct RoomMemberDetailsScreen: View {
// MARK: - Previews
struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
static let otherUserViewModel = {
let member = RoomMemberProxyMock.mockDan
let roomProxyMock = RoomProxyMock(with: .init(name: ""))
roomProxyMock.getMemberUserIDReturnValue = .success(member)
return RoomMemberDetailsScreenViewModel(userID: member.userID,
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}()
static let accountOwnerViewModel = {
let member = RoomMemberProxyMock.mockMe
let roomProxyMock = RoomProxyMock(with: .init(name: ""))
roomProxyMock.getMemberUserIDReturnValue = .success(member)
return RoomMemberDetailsScreenViewModel(userID: member.userID,
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}()
static let ignoredUserViewModel = {
let member = RoomMemberProxyMock.mockIgnored
let roomProxyMock = RoomProxyMock(with: .init(name: ""))
roomProxyMock.getMemberUserIDReturnValue = .success(member)
return RoomMemberDetailsScreenViewModel(userID: member.userID,
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}()
static let otherUserViewModel = makeViewModel(member: .mockDan)
static let accountOwnerViewModel = makeViewModel(member: .mockMe)
static let ignoredUserViewModel = makeViewModel(member: .mockIgnored)
static var previews: some View {
RoomMemberDetailsScreen(context: otherUserViewModel.context)
@ -161,4 +129,16 @@ struct RoomMemberDetailsScreen_Previews: PreviewProvider, TestablePreview {
.previewDisplayName("Ignored User")
.snapshot(delay: 0.25)
}
static func makeViewModel(member: RoomMemberProxyMock) -> RoomMemberDetailsScreenViewModel {
let roomProxyMock = RoomProxyMock(with: .init(name: ""))
roomProxyMock.getMemberUserIDReturnValue = .success(member)
return RoomMemberDetailsScreenViewModel(userID: member.userID,
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
}
}

View File

@ -19,13 +19,16 @@ import SwiftUI
struct UserProfileScreenCoordinatorParameters {
let userID: String
let isPresentedModally: Bool
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
let analytics: AnalyticsService
}
enum UserProfileScreenCoordinatorAction {
case openDirectChat(displayName: String?)
case openDirectChat(roomID: String)
case dismiss
}
final class UserProfileScreenCoordinator: CoordinatorProtocol {
@ -40,9 +43,11 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol {
init(parameters: UserProfileScreenCoordinatorParameters) {
viewModel = UserProfileScreenViewModel(userID: parameters.userID,
isPresentedModally: parameters.isPresentedModally,
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
userIndicatorController: parameters.userIndicatorController,
analytics: parameters.analytics)
}
func start() {
@ -50,8 +55,10 @@ final class UserProfileScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .openDirectChat(let displayName):
actionsSubject.send(.openDirectChat(displayName: displayName))
case .openDirectChat(let roomID):
actionsSubject.send(.openDirectChat(roomID: roomID))
case .dismiss:
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)

View File

@ -17,12 +17,14 @@
import Foundation
enum UserProfileScreenViewModelAction {
case openDirectChat(displayName: String?)
case openDirectChat(roomID: String)
case dismiss
}
struct UserProfileScreenViewState: BindableState {
let userID: String
let isOwnUser: Bool
let isPresentedModally: Bool
var userProfile: UserProfileProxy?
var permalink: URL?
@ -40,8 +42,10 @@ struct UserProfileScreenViewStateBindings {
enum UserProfileScreenViewAction {
case displayAvatar
case openDirectChat
case dismiss
}
enum UserProfileScreenError: Hashable {
case failedOpeningDirectChat
case unknown
}

View File

@ -24,6 +24,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
private let clientProxy: ClientProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let analytics: AnalyticsService
private var actionsSubject: PassthroughSubject<UserProfileScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<UserProfileScreenViewModelAction, Never> {
@ -31,23 +32,27 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
}
init(userID: String,
isPresentedModally: Bool,
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
userIndicatorController: UserIndicatorControllerProtocol,
analytics: AnalyticsService) {
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
self.userIndicatorController = userIndicatorController
self.analytics = analytics
let initialViewState = UserProfileScreenViewState(userID: userID,
isOwnUser: userID == clientProxy.userID,
isPresentedModally: isPresentedModally,
bindings: .init())
super.init(initialViewState: initialViewState, imageProvider: mediaProvider)
showMemberLoadingIndicator()
showLoadingIndicator(allowsInteraction: true)
Task {
defer {
hideMemberLoadingIndicator()
hideLoadingIndicator()
}
switch await clientProxy.profile(for: userID) {
@ -67,37 +72,49 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
hideMemberLoadingIndicator()
hideLoadingIndicator()
}
override func process(viewAction: UserProfileScreenViewAction) {
switch viewAction {
case .displayAvatar:
displayFullScreenAvatar()
Task { await displayFullScreenAvatar() }
case .openDirectChat:
guard let userProfile = state.userProfile else { fatalError() }
actionsSubject.send(.openDirectChat(displayName: userProfile.displayName))
Task { await openDirectChat() }
case .dismiss:
actionsSubject.send(.dismiss)
}
}
// MARK: - Private
private func displayFullScreenAvatar() {
private func displayFullScreenAvatar() async {
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))
showLoadingIndicator(allowsInteraction: false)
defer { hideLoadingIndicator() }
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)
}
}
private func openDirectChat() async {
guard let userProfile = state.userProfile else { fatalError() }
showLoadingIndicator(allowsInteraction: false)
defer { hideLoadingIndicator() }
// 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)
switch await clientProxy.createDirectRoomIfNeeded(with: userProfile.userID, expectedRoomName: userProfile.displayName) {
case .success((let roomID, let isNewRoom)):
if isNewRoom {
analytics.trackCreatedRoom(isDM: true)
}
actionsSubject.send(.openDirectChat(roomID: roomID))
case .failure:
state.bindings.alertInfo = .init(id: .failedOpeningDirectChat)
}
}
@ -105,15 +122,15 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
private static let loadingIndicatorIdentifier = "\(UserProfileScreenViewModel.self)-Loading"
private func showMemberLoadingIndicator() {
private func showLoadingIndicator(allowsInteraction: Bool) {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: allowsInteraction),
title: L10n.commonLoading,
persistent: true),
delay: .milliseconds(100))
}
private func hideMemberLoadingIndicator() {
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}

View File

@ -30,6 +30,8 @@ struct UserProfileScreen: View {
}
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.alert(item: $context.alertInfo)
.track(screen: .User)
.interactiveQuickLook(item: $context.mediaPreviewItem, shouldHideControls: true)
@ -73,6 +75,17 @@ struct UserProfileScreen: View {
.accessibilityIdentifier(A11yIdentifiers.roomMemberDetailsScreen.directChat)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
if context.viewState.isPresentedModally {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionDone) {
context.send(viewAction: .dismiss)
}
}
}
}
}
// MARK: - Previews
@ -92,8 +105,10 @@ struct UserProfileScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(userID: String) -> UserProfileScreenViewModel {
UserProfileScreenViewModel(userID: userID,
isPresentedModally: false,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
}
}

View File

@ -289,6 +289,23 @@ class ClientProxy: ClientProxyProtocol {
try? client.accountUrl(action: action).flatMap(URL.init(string:))
}
func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError> {
let currentDirectRoom = await directRoomForUserID(userID)
switch currentDirectRoom {
case .success(.some(let roomID)):
return .success((roomID: roomID, isNewRoom: false))
case .success(.none):
switch await createDirectRoom(with: userID, expectedRoomName: expectedRoomName) {
case .success(let roomID):
return .success((roomID: roomID, isNewRoom: true))
case .failure(let error):
return .failure(.sdkError(error))
}
case .failure(let error):
return .failure(.sdkError(error))
}
}
func directRoomForUserID(_ userID: String) async -> Result<String?, ClientProxyError> {
await Task.dispatch(on: clientQueue) {
do {

View File

@ -109,6 +109,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func accountURL(action: AccountManagementAction) -> URL?
func createDirectRoomIfNeeded(with userID: String, expectedRoomName: String?) async -> Result<(roomID: String, isNewRoom: Bool), ClientProxyError>
func directRoomForUserID(_ userID: String) async -> Result<String?, ClientProxyError>
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>

View File

@ -118,7 +118,7 @@ class AppRouteURLParserTests: XCTestCase {
let route = appRouteURLParser.route(from: url)
XCTAssertEqual(route, .roomMemberDetails(userID: userID))
XCTAssertEqual(route, .userProfile(userID: userID))
}
func testMatrixRoomIdentifierURL() {
@ -132,4 +132,28 @@ class AppRouteURLParserTests: XCTestCase {
XCTAssertEqual(route, .room(roomID: id))
}
func testWebRoomIDURL() {
let id = "!abcdefghijklmnopqrstuvwxyz1234567890:matrix.org"
guard let url = URL(string: "https://app.element.io/#/room/\(id)") else {
XCTFail("URL invalid")
return
}
let route = appRouteURLParser.route(from: url)
XCTAssertEqual(route, .room(roomID: id))
}
func testWebUserIDURL() {
let id = "@alice:matrix.org"
guard let url = URL(string: "https://develop.element.io/#/user/\(id)") else {
XCTFail("URL invalid")
return
}
let route = appRouteURLParser.route(from: url)
XCTAssertEqual(route, .userProfile(userID: id))
}
}

View File

@ -39,7 +39,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -55,7 +56,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -92,7 +94,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -128,7 +131,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -163,7 +167,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -198,7 +203,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()
@ -214,7 +220,8 @@ class RoomMemberDetailsViewModelTests: XCTestCase {
roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.memberDetails != nil }
try await waitForMemberToLoad.fulfill()

View File

@ -0,0 +1,65 @@
//
// 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 XCTest
@testable import ElementX
@MainActor
class UserProfileScreenViewModelTests: XCTestCase {
var viewModel: UserProfileScreenViewModel!
var context: UserProfileScreenViewModelType.Context { viewModel.context }
func testInitialState() async throws {
let profile = UserProfileProxy(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: .picturesDirectory)
let clientProxy = ClientProxyMock(.init())
clientProxy.profileForReturnValue = .success(profile)
viewModel = UserProfileScreenViewModel(userID: profile.userID,
isPresentedModally: false,
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.userProfile != nil }
try await waitForMemberToLoad.fulfill()
XCTAssertFalse(context.viewState.isOwnUser)
XCTAssertEqual(context.viewState.userProfile, profile)
XCTAssertNotNil(context.viewState.permalink)
}
func testInitialStateAccountOwner() async throws {
let profile = UserProfileProxy(userID: RoomMemberProxyMock.mockMe.userID, displayName: "Me", avatarURL: .picturesDirectory)
let clientProxy = ClientProxyMock(.init())
clientProxy.profileForReturnValue = .success(profile)
viewModel = UserProfileScreenViewModel(userID: profile.userID,
isPresentedModally: false,
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
let waitForMemberToLoad = deferFulfillment(context.$viewState) { $0.userProfile != nil }
try await waitForMemberToLoad.fulfill()
XCTAssertTrue(context.viewState.isOwnUser)
XCTAssertEqual(context.viewState.userProfile, profile)
XCTAssertNotNil(context.viewState.permalink)
}
}

View File

@ -27,14 +27,9 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
var detailCoordinator: CoordinatorProtocol? {
let navigationSplitCoordinator = navigationRootCoordinator.rootCoordinator as? NavigationSplitCoordinator
return navigationSplitCoordinator?.detailCoordinator
}
var detailNavigationStack: NavigationStackCoordinator? {
detailCoordinator as? NavigationStackCoordinator
}
var splitCoordinator: NavigationSplitCoordinator? { navigationRootCoordinator.rootCoordinator as? NavigationSplitCoordinator }
var detailCoordinator: CoordinatorProtocol? { splitCoordinator?.detailCoordinator }
var detailNavigationStack: NavigationStackCoordinator? { detailCoordinator as? NavigationStackCoordinator }
override func setUp() async throws {
cancellables.removeAll()
@ -157,6 +152,21 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
XCTAssertNotNil(detailCoordinator)
}
func testUserProfileClearsStack() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator)
try await process(route: .userProfile(userID: "alice"), expectedState: .userProfileScreen)
XCTAssertNil(detailNavigationStack?.rootCoordinator)
guard let sheetStackCoordinator = splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator else {
XCTFail("There should be a navigation stack presented as a sheet.")
return
}
XCTAssertTrue(sheetStackCoordinator.rootCoordinator is UserProfileScreenCoordinator)
}
// MARK: - Private
private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws {