From 12b6b7ec7479be850e95e0f259f64d3afc21f8c7 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 26 Mar 2026 20:28:50 +0200 Subject: [PATCH] Introduce support for multiple windows on mac and iPad OS Once the app starts the WindowManager is configured with SwiftUI's environment OpenWindowAction. It can then be used to register coordinators (that provide the toPresentable view) and an optional flow coordinator (as most of the screens are part of a flow, especially rooms). Once a coordinator is registed, the WindowManager invokes the OpenWindowAction which in turn makes the Application call its newly introduced WindowManagerWindowType WindowGroup's block to instantiate a new visual window rooting that view. The WindowManager is also responsible for wrapping the presentable in a disappearance block and clean up the coordinator stack. # Conflicts: # ElementX/Sources/Application/AppCoordinator.swift --- .../AccessibilityTestsAppCoordinator.swift | 6 +- .../Sources/Application/AppCoordinator.swift | 97 +++++++++++-------- .../Application/AppCoordinatorProtocol.swift | 4 +- .../Sources/Application/Application.swift | 45 ++++++--- .../Application/Windowing/WindowManager.swift | 27 ++++++ .../Windowing/WindowManagerProtocol.swift | 13 +++ .../ChatsTabFlowCoordinator.swift | 23 ++++- .../Mocks/Generated/GeneratedMocks.swift | 41 ++++++++ .../HomeScreen/HomeScreenCoordinator.swift | 3 + .../Screens/HomeScreen/HomeScreenModels.swift | 2 + .../HomeScreen/HomeScreenViewModel.swift | 2 + .../HomeScreen/View/HomeScreenRoomList.swift | 11 +++ .../UITests/UITestsAppCoordinator.swift | 6 +- .../UnitTests/UnitTestsAppCoordinator.swift | 6 +- ElementX/SupportingFiles/Info.plist | 5 + ElementX/SupportingFiles/target.yml | 3 + 16 files changed, 231 insertions(+), 63 deletions(-) diff --git a/ElementX/Sources/AccessibilityTests/AccessibilityTestsAppCoordinator.swift b/ElementX/Sources/AccessibilityTests/AccessibilityTestsAppCoordinator.swift index 04f37afe6..7ab95c2eb 100644 --- a/ElementX/Sources/AccessibilityTests/AccessibilityTestsAppCoordinator.swift +++ b/ElementX/Sources/AccessibilityTests/AccessibilityTestsAppCoordinator.swift @@ -13,10 +13,14 @@ import SwiftUI class AccessibilityTestsAppCoordinator: AppCoordinatorProtocol { var windowManager: any SecureWindowManagerProtocol - func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool { + func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool { fatalError("Not implemented") } + func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) { + fatalError("Not implemented.") + } + func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool { fatalError("Not implemented") } diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index daf867484..e1f39bdb4 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -176,7 +176,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg .sink { [weak self] action in switch action { case .startCall(let roomID, let isVoiceCall): - self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall)) + self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), windowType: nil) case .receivedIncomingCallRequest: // When reporting a VoIP call through the CXProvider's `reportNewIncomingVoIPPushPayload` // the UIApplication states don't change and syncing is neither started nor ran on @@ -233,14 +233,41 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) }) return true } + + func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) { + if let windowType { + windowManager.handleRoute(appRoute, windowType: windowType) + return + } + + var handled = false + + switch appRoute { + case .accountProvisioningLink: + if let authenticationFlowCoordinator { + authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active) + handled = true + } + default: + if let userSessionFlowCoordinator { + userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active) + handled = true + } + } + + if !handled { + storedAppRoute = appRoute + } + } - func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool { + func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool { // Parse into an AppRoute to redirect these in a type safe way. if let route = appRouteURLParser.route(from: url) { switch route { case .accountProvisioningLink: - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) case .genericCallLink(let url): if let userSessionFlowCoordinator { userSessionFlowCoordinator.handleAppRoute(route, animated: true) @@ -249,33 +276,43 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } case .userProfile(let userID): if isExternalURL { - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) } else { - handleAppRoute(.roomMemberDetails(userID: userID)) + handleAppRoute(.roomMemberDetails(userID: userID), + windowType: windowType) } case .room(let roomID, let via): if isExternalURL { - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) } else { - handleAppRoute(.childRoom(roomID: roomID, via: via)) + handleAppRoute(.childRoom(roomID: roomID, via: via), + windowType: windowType) } case .roomAlias(let alias): if isExternalURL { - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) } else { - handleAppRoute(.childRoomAlias(alias)) + handleAppRoute(.childRoomAlias(alias), + windowType: windowType) } case .event(let eventID, let roomID, let via): if isExternalURL { - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) } else { - handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via)) + handleAppRoute(.childEvent(eventID: eventID, roomID: roomID, via: via), + windowType: windowType) } case .eventOnRoomAlias(let eventID, let alias): if isExternalURL { - handleAppRoute(route) + handleAppRoute(route, + windowType: windowType) } else { - handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias)) + handleAppRoute(.childEventOnRoomAlias(eventID: eventID, alias: alias), + windowType: windowType) } case .share(let payload): guard isExternalURL else { @@ -284,7 +321,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } do { - try handleAppRoute(.share(payload.withDefaultTemporaryDirectory())) + try handleAppRoute(.share(payload.withDefaultTemporaryDirectory()), + windowType: windowType) } catch { MXLog.error("Failed moving payload out of the app group container: \(error)") } @@ -309,7 +347,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } MXLog.info("Starting call in room: \(roomIdentifier)") - handleAppRoute(AppRoute.call(roomID: roomIdentifier, isVoiceCall: false)) + handleAppRoute(AppRoute.call(roomID: roomIdentifier, isVoiceCall: false), windowType: nil) } // MARK: - AuthenticationFlowCoordinatorDelegate @@ -363,15 +401,15 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } else { storedRoomsToAwait = [roomID] } - handleAppRoute(.room(roomID: roomID, via: [])) + handleAppRoute(.room(roomID: roomID, via: []), windowType: nil) } else if appSettings.threadsEnabled, let threadRootEventID = content.threadRootEventID { - handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID)) + handleAppRoute(.thread(roomID: roomID, threadRootEventID: threadRootEventID, focusEventID: eventID), windowType: nil) } else if let eventID { // Only track main timeline event deeplinking ServiceLocator.shared.analytics.signpost.startTransaction(.notificationToMessage) - handleAppRoute(.event(eventID: eventID, roomID: roomID, via: [])) + handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), windowType: nil) } else { - handleAppRoute(.room(roomID: roomID, via: [])) + handleAppRoute(.room(roomID: roomID, via: []), windowType: nil) } } @@ -906,27 +944,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg .store(in: &cancellables) } - private func handleAppRoute(_ appRoute: AppRoute) { - var handled = false - - switch appRoute { - case .accountProvisioningLink: - if let authenticationFlowCoordinator { - authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active) - handled = true - } - default: - if let userSessionFlowCoordinator { - userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active) - handled = true - } - } - - if !handled { - storedAppRoute = appRoute - } - } - private func clearCache() { guard let userSession else { fatalError("User session not setup") diff --git a/ElementX/Sources/Application/AppCoordinatorProtocol.swift b/ElementX/Sources/Application/AppCoordinatorProtocol.swift index 4835a4b7d..096e1ee61 100644 --- a/ElementX/Sources/Application/AppCoordinatorProtocol.swift +++ b/ElementX/Sources/Application/AppCoordinatorProtocol.swift @@ -12,7 +12,9 @@ import Foundation protocol AppCoordinatorProtocol: CoordinatorProtocol { var windowManager: SecureWindowManagerProtocol { get } - @discardableResult func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool + @discardableResult func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool + + func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) func handlePotentialPhishingAttempt(url: URL, openURLAction: @escaping (URL) -> Void) -> Bool diff --git a/ElementX/Sources/Application/Application.swift b/ElementX/Sources/Application/Application.swift index 280fa3f70..f142a8842 100644 --- a/ElementX/Sources/Application/Application.swift +++ b/ElementX/Sources/Application/Application.swift @@ -12,6 +12,7 @@ import SwiftUI struct Application: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @Environment(\.openURL) private var openURL + @Environment(\.openWindow) private var openWindow private var appCoordinator: AppCoordinatorProtocol! @@ -39,19 +40,7 @@ struct Application: App { Divider().ignoresSafeArea() } } - .environment(\.openURL, OpenURLAction { url in - if appCoordinator.handleDeepLink(url, isExternalURL: false) { - return .handled - } - - if appCoordinator.handlePotentialPhishingAttempt(url: url, openURLAction: { url in - openURL(url, isExternalURL: false) - }) { - return .handled - } - - return .systemAction - }) + .environment(\.openURL, openURLAction(appCoordinator: appCoordinator, windowType: nil)) .onOpenURL { url in openURL(url, isExternalURL: true) } @@ -62,14 +51,42 @@ struct Application: App { } .task { appCoordinator.start() + appCoordinator.windowManager.configure(with: openWindow) } } + .handlesExternalEvents(matching: ["*"]) + + // This is invoked in response of the WindowManager receiving a register + // coordinator request and invoking the `OpenWindowAction` with which + // it's configured in the task above. + WindowGroup(for: WindowManagerWindowType.self) { $type in + if let type { + appCoordinator.windowManager.windowForType(type) + .environment(\.openURL, openURLAction(appCoordinator: appCoordinator, windowType: type)) + } + } + } + + private func openURLAction(appCoordinator: AppCoordinatorProtocol, windowType: WindowManagerWindowType?) -> OpenURLAction { + .init { url in + if appCoordinator.handleDeepLink(url, isExternalURL: false, windowType: windowType) { + return .handled + } + + if appCoordinator.handlePotentialPhishingAttempt(url: url, openURLAction: { url in + openURL(url, isExternalURL: false) + }) { + return .handled + } + + return .systemAction + } } // MARK: - Private private func openURL(_ url: URL, isExternalURL: Bool) { - if !appCoordinator.handleDeepLink(url, isExternalURL: isExternalURL) { + if !appCoordinator.handleDeepLink(url, isExternalURL: isExternalURL, windowType: nil) { openURLInSystemBrowser(url) } } diff --git a/ElementX/Sources/Application/Windowing/WindowManager.swift b/ElementX/Sources/Application/Windowing/WindowManager.swift index 2808488bc..33f0534f7 100644 --- a/ElementX/Sources/Application/Windowing/WindowManager.swift +++ b/ElementX/Sources/Application/Windowing/WindowManager.swift @@ -18,6 +18,8 @@ class WindowManager: SecureWindowManagerProtocol { private(set) var overlayWindow: UIWindow! private(set) var globalSearchWindow: UIWindow! private(set) var alternateWindow: UIWindow! + + private(set) var openWindowAction: OpenWindowAction! var windows: [UIWindow] { [mainWindow, overlayWindow, globalSearchWindow, alternateWindow] @@ -29,6 +31,8 @@ class WindowManager: SecureWindowManagerProtocol { /// A duration that allows window switching to wait a couple of frames to avoid a transition through black. private let windowHideDelay = Duration.milliseconds(33) + private var coordinators: [WindowManagerWindowType: (coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?)] = [:] + init(appDelegate: AppDelegate) { self.appDelegate = appDelegate } @@ -54,6 +58,29 @@ class WindowManager: SecureWindowManagerProtocol { delegate?.windowManagerDidConfigureWindows(self) } + func configure(with openWindowAction: OpenWindowAction) { + self.openWindowAction = openWindowAction + } + + func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: WindowManagerWindowType) { + coordinators[type] = (coordinator, flowCoordinator) + openWindowAction(value: type) + } + + func windowForType(_ type: WindowManagerWindowType) -> AnyView? { + guard let coordinator = coordinators[type]?.coordinator else { + return nil + } + + return coordinator.toPresentable() + } + + func handleRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType) { + if let flowCoordinator = coordinators[windowType]?.flowCoordinator { + flowCoordinator.handleAppRoute(appRoute, animated: true) + } + } + func switchToMain() { mainWindow.isHidden = false overlayWindow.isHidden = false diff --git a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift index af69a265f..d53366b48 100644 --- a/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift +++ b/ElementX/Sources/Application/Windowing/WindowManagerProtocol.swift @@ -8,6 +8,11 @@ import SwiftUI +enum WindowManagerWindowType: Hashable, Codable { + case room(roomID: String) + case settings +} + protocol SecureWindowManagerDelegate: AnyObject { /// The window manager has configured its windows. func windowManagerDidConfigureWindows(_ windowManager: SecureWindowManagerProtocol) @@ -20,6 +25,12 @@ protocol SecureWindowManagerProtocol: WindowManagerProtocol { /// Configures the window manager to operate on the supplied scene. func configure(with windowScene: UIWindowScene) + func configure(with openWindowAction: OpenWindowAction) + + func handleRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType) + + func windowForType(_ type: WindowManagerWindowType) -> AnyView? + /// Shows the main and overlay window combo, hiding the alternate window. func switchToMain() @@ -43,6 +54,8 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol { /// All the windows being managed var windows: [UIWindow] { get } + func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: WindowManagerWindowType) + /// Makes the global search window key. Used to get automatic text field focus. func showGlobalSearch() diff --git a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift index 66cab87fd..fa1214436 100644 --- a/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/ChatsTabFlowCoordinator.swift @@ -321,7 +321,11 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { if case .space = detailState { dismissRoomFlow(animated: animated) } - startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated) + startRoomFlow(roomID: roomID, + via: via, + entryPoint: entryPoint, + detached: false, + animated: animated) } actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user. } @@ -393,6 +397,8 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoom(let roomID): handleAppRoute(.room(roomID: roomID, via: []), animated: true) + case .detachRoom(let roomID): + startRoomFlow(roomID: roomID, via: [], entryPoint: .room, detached: true, animated: true) case .presentRoomDetails(let roomID): handleAppRoute(.roomDetails(roomID: roomID), animated: true) case .presentReportRoom(let roomID): @@ -516,8 +522,10 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { private func startRoomFlow(roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint, + detached: Bool, animated: Bool) { - let navigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator) + let navigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: detached ? nil : navigationSplitCoordinator) + let coordinator = RoomFlowCoordinator(roomID: roomID, isChildFlow: false, navigationStackCoordinator: navigationStackCoordinator, @@ -539,9 +547,14 @@ class ChatsTabFlowCoordinator: FlowCoordinatorProtocol { } .store(in: &cancellables) - roomFlowCoordinator = coordinator - - navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated) + if detached { + flowParameters.windowManager.registerCoordinator(navigationStackCoordinator, + flowCoordinator: coordinator, + forWindowType: .room(roomID: roomID)) + } else { + roomFlowCoordinator = coordinator + navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated) + } switch entryPoint { case .room: diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 791bb6132..7e96005fd 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -21358,6 +21358,47 @@ class WindowManagerMock: WindowManagerProtocol, @unchecked Sendable { var alternateWindow: UIWindow! var windows: [UIWindow] = [] + //MARK: - registerCoordinator + + var registerCoordinatorFlowCoordinatorForWindowTypeUnderlyingCallsCount = 0 + var registerCoordinatorFlowCoordinatorForWindowTypeCallsCount: Int { + get { + if Thread.isMainThread { + return registerCoordinatorFlowCoordinatorForWindowTypeUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = registerCoordinatorFlowCoordinatorForWindowTypeUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + registerCoordinatorFlowCoordinatorForWindowTypeUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + registerCoordinatorFlowCoordinatorForWindowTypeUnderlyingCallsCount = newValue + } + } + } + } + var registerCoordinatorFlowCoordinatorForWindowTypeCalled: Bool { + return registerCoordinatorFlowCoordinatorForWindowTypeCallsCount > 0 + } + var registerCoordinatorFlowCoordinatorForWindowTypeReceivedArguments: (coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, type: WindowManagerWindowType)? + var registerCoordinatorFlowCoordinatorForWindowTypeReceivedInvocations: [(coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, type: WindowManagerWindowType)] = [] + var registerCoordinatorFlowCoordinatorForWindowTypeClosure: ((CoordinatorProtocol, FlowCoordinatorProtocol?, WindowManagerWindowType) -> Void)? + + func registerCoordinator(_ coordinator: CoordinatorProtocol, flowCoordinator: FlowCoordinatorProtocol?, forWindowType type: WindowManagerWindowType) { + registerCoordinatorFlowCoordinatorForWindowTypeCallsCount += 1 + registerCoordinatorFlowCoordinatorForWindowTypeReceivedArguments = (coordinator: coordinator, flowCoordinator: flowCoordinator, type: type) + DispatchQueue.main.async { + self.registerCoordinatorFlowCoordinatorForWindowTypeReceivedInvocations.append((coordinator: coordinator, flowCoordinator: flowCoordinator, type: type)) + } + registerCoordinatorFlowCoordinatorForWindowTypeClosure?(coordinator, flowCoordinator, type) + } //MARK: - showGlobalSearch var showGlobalSearchUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index cd588fb2d..ddaf642b4 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -21,6 +21,7 @@ struct HomeScreenCoordinatorParameters { enum HomeScreenCoordinatorAction { case presentRoom(roomIdentifier: String) + case detachRoom(roomIdentifier: String) case presentRoomDetails(roomIdentifier: String) case presentReportRoom(roomIdentifier: String) case presentDeclineAndBlock(userID: String, roomID: String) @@ -65,6 +66,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol { switch action { case .presentRoom(let roomIdentifier): actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier)) + case .detachRoom(let roomIdentifier): + actionsSubject.send(.detachRoom(roomIdentifier: roomIdentifier)) case .presentRoomDetails(roomIdentifier: let roomIdentifier): actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier)) case .presentReportRoom(let roomIdentifier): diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 4999d09bc..4cc096150 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -12,6 +12,7 @@ import UIKit enum HomeScreenViewModelAction { case presentRoom(roomIdentifier: String) + case detachRoom(roomIdentifier: String) case presentRoomDetails(roomIdentifier: String) case presentReportRoom(roomIdentifier: String) case presentDeclineAndBlock(userID: String, roomID: String) @@ -30,6 +31,7 @@ enum HomeScreenViewModelAction { enum HomeScreenViewAction { case selectRoom(roomIdentifier: String) + case detachRoom(roomIdentifier: String) case showRoomDetails(roomIdentifier: String) case leaveRoom(roomIdentifier: String) case confirmLeaveRoom(roomIdentifier: String) diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 96d073df0..5e388a5ee 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -173,6 +173,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol switch viewAction { case .selectRoom(let roomIdentifier): actionsSubject.send(.presentRoom(roomIdentifier: roomIdentifier)) + case .detachRoom(let roomIdentifier): + actionsSubject.send(.detachRoom(roomIdentifier: roomIdentifier)) case .showRoomDetails(let roomIdentifier): actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier)) case .leaveRoom(let roomIdentifier): diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomList.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomList.swift index 5263a81c2..9a60cceb9 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomList.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomList.swift @@ -34,7 +34,18 @@ struct HomeScreenRoomList: View { let isSelected = context.viewState.selectedRoomID == room.id HomeScreenRoomCell(room: room, isSelected: isSelected, mediaProvider: context.mediaProvider, action: context.send) + .simultaneousGesture(TapGesture(count: 2).onEnded { + context.send(viewAction: .detachRoom(roomIdentifier: room.id)) + }) .contextMenu { + if !UIDevice.current.isPhone { + Button { + context.send(viewAction: .detachRoom(roomIdentifier: room.id)) + } label: { + Label("Open in new window", icon: \.popOut) + } + } + if room.badges.isDotShown { Button { context.send(viewAction: .markRoomAsRead(roomIdentifier: room.id)) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 922827853..421053334 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -65,7 +65,11 @@ class UITestsAppCoordinator: AppCoordinatorProtocol, SecureWindowManagerDelegate fatalError("Not implemented.") } - func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool { + func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool { + fatalError("Not implemented.") + } + + func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) { fatalError("Not implemented.") } diff --git a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift index 0f2987085..836b45c1d 100644 --- a/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift +++ b/ElementX/Sources/UnitTests/UnitTestsAppCoordinator.swift @@ -50,7 +50,11 @@ class UnitTestsAppCoordinator: AppCoordinatorProtocol { fatalError("Not implemented.") } - func handleDeepLink(_ url: URL, isExternalURL: Bool) -> Bool { + func handleDeepLink(_ url: URL, isExternalURL: Bool, windowType: WindowManagerWindowType?) -> Bool { + fatalError("Not implemented.") + } + + func handleAppRoute(_ appRoute: AppRoute, windowType: WindowManagerWindowType?) { fatalError("Not implemented.") } diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index 714655422..c790bba88 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -82,6 +82,11 @@ INSendMessageIntent INStartCallIntent + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UIBackgroundModes audio diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index ab118baa6..a51cfeccc 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -67,6 +67,9 @@ targets: ] } ] + UIApplicationSceneManifest: { + UIApplicationSupportsMultipleScenes: true + } UISupportedInterfaceOrientations: [ UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown,